From e1346e20b9e9c695d33d2393c687dc7bf37f046b Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:15:49 -0400
Subject: [PATCH 01/15] refactor(expo-native): move native SDK code from
@clerk/expo into packages/expo-native
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/{expo => expo-native}/.gitignore | 0
packages/{expo => expo-native}/android/build.gradle | 0
.../{expo => expo-native}/android/src/main/AndroidManifest.xml | 0
.../src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt | 0
.../main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt | 0
.../android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 0
.../src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt | 0
.../java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt | 0
.../main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt | 0
.../test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt | 0
.../expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt | 0
packages/{expo => expo-native}/app.plugin.js | 0
packages/{expo => expo-native}/expo-module.config.json | 0
.../{expo => expo-native}/ios/ClerkAppDelegateSubscriber.swift | 0
packages/{expo => expo-native}/ios/ClerkAuthNativeView.swift | 0
packages/{expo => expo-native}/ios/ClerkExpo.podspec | 0
packages/{expo => expo-native}/ios/ClerkExpoModule.swift | 0
packages/{expo => expo-native}/ios/ClerkNativeBridge.swift | 0
packages/{expo => expo-native}/ios/ClerkNativeViewHost.swift | 0
.../{expo => expo-native}/ios/ClerkUserButtonNativeView.swift | 0
.../{expo => expo-native}/ios/ClerkUserProfileNativeView.swift | 0
.../ios/Tests/ClerkAuthNativeViewPaperTests.swift | 0
.../{expo => expo-native}/ios/Tests/ClerkNativeBridgeTests.swift | 0
.../ios/Tests/ClerkNativeViewHostTests.swift | 0
.../ios/Tests/ClerkUserProfileCustomPageStateTests.swift | 0
packages/{expo => expo-native}/react-native.config.js | 0
packages/{expo/src/native => expo-native/src}/AuthView.tsx | 0
packages/{expo/src/native => expo-native/src}/AuthView.types.ts | 0
.../src/native => expo-native/src}/EmbeddedNavigation.types.ts | 0
packages/{expo/src/native => expo-native/src}/UserButton.tsx | 0
.../src/native => expo-native/src}/UserProfileCustomPages.tsx | 0
packages/{expo/src/native => expo-native/src}/UserProfileView.tsx | 0
.../src/native => expo-native/src}/__tests__/AuthView.test.tsx | 0
.../src/native => expo-native/src}/__tests__/UserButton.test.tsx | 0
.../src}/__tests__/UserProfileCustomPages.test.tsx | 0
.../native => expo-native/src}/__tests__/UserProfileView.test.tsx | 0
.../{expo => expo-native}/src/__tests__/appPlugin.theme.test.js | 0
.../src}/__tests__/useAuthViewState.test.tsx | 0
packages/{expo/src/native => expo-native/src}/index.ts | 0
.../src/specs/NativeClerkAuthView.android.ts | 0
packages/{expo => expo-native}/src/specs/NativeClerkAuthView.ts | 0
.../src/specs/NativeClerkUserButtonView.android.ts | 0
.../{expo => expo-native}/src/specs/NativeClerkUserButtonView.ts | 0
.../src/specs/NativeClerkUserProfileView.android.ts | 0
.../{expo => expo-native}/src/specs/NativeClerkUserProfileView.ts | 0
.../src/specs/__tests__/native-view.web.test.ts | 0
packages/{expo/src/native => expo-native/src}/useAuthViewState.ts | 0
47 files changed, 0 insertions(+), 0 deletions(-)
rename packages/{expo => expo-native}/.gitignore (100%)
rename packages/{expo => expo-native}/android/build.gradle (100%)
rename packages/{expo => expo-native}/android/src/main/AndroidManifest.xml (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt (100%)
rename packages/{expo => expo-native}/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt (100%)
rename packages/{expo => expo-native}/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt (100%)
rename packages/{expo => expo-native}/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt (100%)
rename packages/{expo => expo-native}/app.plugin.js (100%)
rename packages/{expo => expo-native}/expo-module.config.json (100%)
rename packages/{expo => expo-native}/ios/ClerkAppDelegateSubscriber.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkAuthNativeView.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkExpo.podspec (100%)
rename packages/{expo => expo-native}/ios/ClerkExpoModule.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkNativeBridge.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkNativeViewHost.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkUserButtonNativeView.swift (100%)
rename packages/{expo => expo-native}/ios/ClerkUserProfileNativeView.swift (100%)
rename packages/{expo => expo-native}/ios/Tests/ClerkAuthNativeViewPaperTests.swift (100%)
rename packages/{expo => expo-native}/ios/Tests/ClerkNativeBridgeTests.swift (100%)
rename packages/{expo => expo-native}/ios/Tests/ClerkNativeViewHostTests.swift (100%)
rename packages/{expo => expo-native}/ios/Tests/ClerkUserProfileCustomPageStateTests.swift (100%)
rename packages/{expo => expo-native}/react-native.config.js (100%)
rename packages/{expo/src/native => expo-native/src}/AuthView.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/AuthView.types.ts (100%)
rename packages/{expo/src/native => expo-native/src}/EmbeddedNavigation.types.ts (100%)
rename packages/{expo/src/native => expo-native/src}/UserButton.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/UserProfileCustomPages.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/UserProfileView.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/__tests__/AuthView.test.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/__tests__/UserButton.test.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/__tests__/UserProfileCustomPages.test.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/__tests__/UserProfileView.test.tsx (100%)
rename packages/{expo => expo-native}/src/__tests__/appPlugin.theme.test.js (100%)
rename packages/{expo/src/native => expo-native/src}/__tests__/useAuthViewState.test.tsx (100%)
rename packages/{expo/src/native => expo-native/src}/index.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkAuthView.android.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkAuthView.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkUserButtonView.android.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkUserButtonView.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkUserProfileView.android.ts (100%)
rename packages/{expo => expo-native}/src/specs/NativeClerkUserProfileView.ts (100%)
rename packages/{expo => expo-native}/src/specs/__tests__/native-view.web.test.ts (100%)
rename packages/{expo/src/native => expo-native/src}/useAuthViewState.ts (100%)
diff --git a/packages/expo/.gitignore b/packages/expo-native/.gitignore
similarity index 100%
rename from packages/expo/.gitignore
rename to packages/expo-native/.gitignore
diff --git a/packages/expo/android/build.gradle b/packages/expo-native/android/build.gradle
similarity index 100%
rename from packages/expo/android/build.gradle
rename to packages/expo-native/android/build.gradle
diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo-native/android/src/main/AndroidManifest.xml
similarity index 100%
rename from packages/expo/android/src/main/AndroidManifest.xml
rename to packages/expo-native/android/src/main/AndroidManifest.xml
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
rename to packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo-native/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
similarity index 100%
rename from packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
rename to packages/expo-native/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo-native/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
similarity index 100%
rename from packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
rename to packages/expo-native/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
diff --git a/packages/expo/app.plugin.js b/packages/expo-native/app.plugin.js
similarity index 100%
rename from packages/expo/app.plugin.js
rename to packages/expo-native/app.plugin.js
diff --git a/packages/expo/expo-module.config.json b/packages/expo-native/expo-module.config.json
similarity index 100%
rename from packages/expo/expo-module.config.json
rename to packages/expo-native/expo-module.config.json
diff --git a/packages/expo/ios/ClerkAppDelegateSubscriber.swift b/packages/expo-native/ios/ClerkAppDelegateSubscriber.swift
similarity index 100%
rename from packages/expo/ios/ClerkAppDelegateSubscriber.swift
rename to packages/expo-native/ios/ClerkAppDelegateSubscriber.swift
diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo-native/ios/ClerkAuthNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkAuthNativeView.swift
rename to packages/expo-native/ios/ClerkAuthNativeView.swift
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo-native/ios/ClerkExpo.podspec
similarity index 100%
rename from packages/expo/ios/ClerkExpo.podspec
rename to packages/expo-native/ios/ClerkExpo.podspec
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo-native/ios/ClerkExpoModule.swift
similarity index 100%
rename from packages/expo/ios/ClerkExpoModule.swift
rename to packages/expo-native/ios/ClerkExpoModule.swift
diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo-native/ios/ClerkNativeBridge.swift
similarity index 100%
rename from packages/expo/ios/ClerkNativeBridge.swift
rename to packages/expo-native/ios/ClerkNativeBridge.swift
diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo-native/ios/ClerkNativeViewHost.swift
similarity index 100%
rename from packages/expo/ios/ClerkNativeViewHost.swift
rename to packages/expo-native/ios/ClerkNativeViewHost.swift
diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo-native/ios/ClerkUserButtonNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkUserButtonNativeView.swift
rename to packages/expo-native/ios/ClerkUserButtonNativeView.swift
diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo-native/ios/ClerkUserProfileNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkUserProfileNativeView.swift
rename to packages/expo-native/ios/ClerkUserProfileNativeView.swift
diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo-native/ios/Tests/ClerkAuthNativeViewPaperTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift
rename to packages/expo-native/ios/Tests/ClerkAuthNativeViewPaperTests.swift
diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo-native/ios/Tests/ClerkNativeBridgeTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkNativeBridgeTests.swift
rename to packages/expo-native/ios/Tests/ClerkNativeBridgeTests.swift
diff --git a/packages/expo/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native/ios/Tests/ClerkNativeViewHostTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkNativeViewHostTests.swift
rename to packages/expo-native/ios/Tests/ClerkNativeViewHostTests.swift
diff --git a/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo-native/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
rename to packages/expo-native/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
diff --git a/packages/expo/react-native.config.js b/packages/expo-native/react-native.config.js
similarity index 100%
rename from packages/expo/react-native.config.js
rename to packages/expo-native/react-native.config.js
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo-native/src/AuthView.tsx
similarity index 100%
rename from packages/expo/src/native/AuthView.tsx
rename to packages/expo-native/src/AuthView.tsx
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo-native/src/AuthView.types.ts
similarity index 100%
rename from packages/expo/src/native/AuthView.types.ts
rename to packages/expo-native/src/AuthView.types.ts
diff --git a/packages/expo/src/native/EmbeddedNavigation.types.ts b/packages/expo-native/src/EmbeddedNavigation.types.ts
similarity index 100%
rename from packages/expo/src/native/EmbeddedNavigation.types.ts
rename to packages/expo-native/src/EmbeddedNavigation.types.ts
diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo-native/src/UserButton.tsx
similarity index 100%
rename from packages/expo/src/native/UserButton.tsx
rename to packages/expo-native/src/UserButton.tsx
diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo-native/src/UserProfileCustomPages.tsx
similarity index 100%
rename from packages/expo/src/native/UserProfileCustomPages.tsx
rename to packages/expo-native/src/UserProfileCustomPages.tsx
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo-native/src/UserProfileView.tsx
similarity index 100%
rename from packages/expo/src/native/UserProfileView.tsx
rename to packages/expo-native/src/UserProfileView.tsx
diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo-native/src/__tests__/AuthView.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/AuthView.test.tsx
rename to packages/expo-native/src/__tests__/AuthView.test.tsx
diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo-native/src/__tests__/UserButton.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/UserButton.test.tsx
rename to packages/expo-native/src/__tests__/UserButton.test.tsx
diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo-native/src/__tests__/UserProfileCustomPages.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx
rename to packages/expo-native/src/__tests__/UserProfileCustomPages.test.tsx
diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo-native/src/__tests__/UserProfileView.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/UserProfileView.test.tsx
rename to packages/expo-native/src/__tests__/UserProfileView.test.tsx
diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo-native/src/__tests__/appPlugin.theme.test.js
similarity index 100%
rename from packages/expo/src/__tests__/appPlugin.theme.test.js
rename to packages/expo-native/src/__tests__/appPlugin.theme.test.js
diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/useAuthViewState.test.tsx
rename to packages/expo-native/src/__tests__/useAuthViewState.test.tsx
diff --git a/packages/expo/src/native/index.ts b/packages/expo-native/src/index.ts
similarity index 100%
rename from packages/expo/src/native/index.ts
rename to packages/expo-native/src/index.ts
diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native/src/specs/NativeClerkAuthView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkAuthView.android.ts
rename to packages/expo-native/src/specs/NativeClerkAuthView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo-native/src/specs/NativeClerkAuthView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkAuthView.ts
rename to packages/expo-native/src/specs/NativeClerkAuthView.ts
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo-native/src/specs/NativeClerkUserButtonView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserButtonView.android.ts
rename to packages/expo-native/src/specs/NativeClerkUserButtonView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo-native/src/specs/NativeClerkUserButtonView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserButtonView.ts
rename to packages/expo-native/src/specs/NativeClerkUserButtonView.ts
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo-native/src/specs/NativeClerkUserProfileView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserProfileView.android.ts
rename to packages/expo-native/src/specs/NativeClerkUserProfileView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo-native/src/specs/NativeClerkUserProfileView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserProfileView.ts
rename to packages/expo-native/src/specs/NativeClerkUserProfileView.ts
diff --git a/packages/expo/src/specs/__tests__/native-view.web.test.ts b/packages/expo-native/src/specs/__tests__/native-view.web.test.ts
similarity index 100%
rename from packages/expo/src/specs/__tests__/native-view.web.test.ts
rename to packages/expo-native/src/specs/__tests__/native-view.web.test.ts
diff --git a/packages/expo/src/native/useAuthViewState.ts b/packages/expo-native/src/useAuthViewState.ts
similarity index 100%
rename from packages/expo/src/native/useAuthViewState.ts
rename to packages/expo-native/src/useAuthViewState.ts
From 5520da881558d3f2f5bd9049ff656c37204bd949 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:28:11 -0400
Subject: [PATCH 02/15] feat(expo-native): scaffold @clerk/expo-native package
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/expo-native/LICENSE | 21 ++++
packages/expo-native/README.md | 107 ++++++++++++++++++
packages/expo-native/android/build.gradle | 20 +++-
packages/expo-native/app.plugin.d.ts | 5 +
packages/expo-native/app.plugin.js | 104 +++--------------
packages/expo-native/package.json | 83 ++++++++++++++
packages/expo-native/src/AuthView.tsx | 8 +-
packages/expo-native/src/UserButton.tsx | 6 +-
packages/expo-native/src/UserProfileView.tsx | 8 +-
.../src/__tests__/AuthView.test.tsx | 4 +-
.../src/__tests__/UserButton.test.tsx | 4 +-
.../src/__tests__/UserProfileView.test.tsx | 4 +-
.../src/__tests__/appPlugin.theme.test.js | 49 +-------
.../src/__tests__/useAuthViewState.test.tsx | 6 +-
packages/expo-native/src/index.ts | 6 +-
.../src/specs/NativeClerkModule.ts | 6 +
.../src/specs/NativeClerkModule.types.ts | 9 ++
.../src/specs/NativeClerkModule.web.ts | 3 +
packages/expo-native/src/useAuthViewState.ts | 6 +-
.../expo-native/src/utils/native-module.ts | 7 ++
.../expo-native/tsconfig.declarations.json | 16 +++
packages/expo-native/tsconfig.json | 26 +++++
packages/expo-native/tsconfig.test.json | 6 +
packages/expo-native/tsdown.config.mts | 33 ++++++
packages/expo-native/vitest.config.mts | 10 ++
packages/expo-native/vitest.setup.mts | 22 ++++
pnpm-lock.yaml | 25 ++++
27 files changed, 437 insertions(+), 167 deletions(-)
create mode 100644 packages/expo-native/LICENSE
create mode 100644 packages/expo-native/README.md
create mode 100644 packages/expo-native/app.plugin.d.ts
create mode 100644 packages/expo-native/package.json
create mode 100644 packages/expo-native/src/specs/NativeClerkModule.ts
create mode 100644 packages/expo-native/src/specs/NativeClerkModule.types.ts
create mode 100644 packages/expo-native/src/specs/NativeClerkModule.web.ts
create mode 100644 packages/expo-native/src/utils/native-module.ts
create mode 100644 packages/expo-native/tsconfig.declarations.json
create mode 100644 packages/expo-native/tsconfig.json
create mode 100644 packages/expo-native/tsconfig.test.json
create mode 100644 packages/expo-native/tsdown.config.mts
create mode 100644 packages/expo-native/vitest.config.mts
create mode 100644 packages/expo-native/vitest.setup.mts
diff --git a/packages/expo-native/LICENSE b/packages/expo-native/LICENSE
new file mode 100644
index 00000000000..66914b6af7c
--- /dev/null
+++ b/packages/expo-native/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Clerk, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/expo-native/README.md b/packages/expo-native/README.md
new file mode 100644
index 00000000000..35fe6587c9d
--- /dev/null
+++ b/packages/expo-native/README.md
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
@clerk/expo-native
+
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native)
+[](https://x.com/intent/follow?screen_name=clerk)
+
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native/CHANGELOG.md)
+·
+[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
+·
+[Request a Feature](https://feedback.clerk.com/roadmap)
+·
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native)
+
+
+
+## Getting Started
+
+`@clerk/expo-native` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs:
+
+- **iOS**: [clerk-ios](https://github.com/clerk/clerk-ios) (SwiftUI)
+- **Android**: [clerk-android](https://github.com/clerk/clerk-android) (Jetpack Compose)
+
+Install it only if you use `AuthView`, `UserProfileView`, `UserButton`, or biometric credentials. Apps that only use `@clerk/expo` do not include the Clerk native SDKs.
+
+### Prerequisites
+
+- `@clerk/expo`
+- Expo 54 or later, with a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (Expo Go is not supported)
+- iOS 17 or later
+
+### Installation
+
+```sh
+npx expo install @clerk/expo @clerk/expo-native
+```
+
+Add the config plugin alongside `@clerk/expo` in your app config, then rebuild your native app:
+
+```json
+{
+ "expo": {
+ "plugins": ["@clerk/expo", "@clerk/expo-native"]
+ }
+}
+```
+
+The plugin accepts the following options:
+
+- `keychainService`: keychain service identifier to share the session with app extensions.
+- `theme`: path to a JSON file with `colors`, `darkColors`, and `design` keys that styles the native components.
+
+## Usage
+
+```tsx
+import { useAuth } from '@clerk/expo';
+import { AuthView } from '@clerk/expo-native';
+
+export default function SignInScreen() {
+ const { isSignedIn } = useAuth();
+
+ if (isSignedIn) {
+ return null;
+ }
+
+ return ;
+}
+```
+
+For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native).
+
+## Support
+
+For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native).
+
+## Community
+
+Join our [Discord community](https://clerk.com/discord) to connect with other developers.
+
+## Contributing
+
+We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md).
+
+## Security
+
+`@clerk/expo-native` follows good practices of security, but 100% security cannot be assured.
+
+`@clerk/expo-native` is provided **"as is"** without any **warranty**. Use at your own risk.
+
+_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
+
+## License
+
+This project is licensed under the **MIT license**.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native/LICENSE) for more information.
diff --git a/packages/expo-native/android/build.gradle b/packages/expo-native/android/build.gradle
index 8975e848f7d..5cb80b92dd8 100644
--- a/packages/expo-native/android/build.gradle
+++ b/packages/expo-native/android/build.gradle
@@ -13,8 +13,22 @@ applyKotlinExpoModulesCorePlugin()
group = 'com.clerk.expo'
version = '1.0.0'
-def clerkExpoPackageJson = new JsonSlurper().parse(new File(projectDir, "../package.json"))
-def clerkExpoVersion = clerkExpoPackageJson.version.toString()
+// The native SDKs report the @clerk/expo version in the x-clerk-host-sdk-version header.
+def resolveClerkExpoVersion() {
+ try {
+ def process = new ProcessBuilder("node", "--print", "require.resolve('@clerk/expo/package.json')")
+ .directory(projectDir)
+ .start()
+ def clerkExpoPackageJsonPath = process.inputStream.text.trim()
+ if (process.waitFor() == 0 && clerkExpoPackageJsonPath) {
+ return new JsonSlurper().parse(new File(clerkExpoPackageJsonPath)).version.toString()
+ }
+ } catch (Exception ignored) {
+ }
+ return new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString()
+}
+
+def clerkExpoVersion = resolveClerkExpoVersion()
// Dependency versions - centralized for easier updates
// See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs
@@ -97,7 +111,7 @@ try {
}
} catch (Exception ignored) {
// Future Gradle versions with Isolated Projects may block cross-project configuration.
- // In that case, users should add '@clerk/expo' to their app.json plugins array instead,
+ // In that case, users should add '@clerk/expo-native' to their app.json plugins array instead,
// which applies the flag via the config plugin's withClerkAndroid.
}
diff --git a/packages/expo-native/app.plugin.d.ts b/packages/expo-native/app.plugin.d.ts
new file mode 100644
index 00000000000..76cfe1c1acd
--- /dev/null
+++ b/packages/expo-native/app.plugin.d.ts
@@ -0,0 +1,5 @@
+import type { ConfigPlugin } from '@expo/config-plugins';
+
+declare const withClerkExpoNative: ConfigPlugin<{ keychainService?: string; theme?: string } | void>;
+
+export = withClerkExpoNative;
diff --git a/packages/expo-native/app.plugin.js b/packages/expo-native/app.plugin.js
index f8483877ff3..f8faa236dea 100644
--- a/packages/expo-native/app.plugin.js
+++ b/packages/expo-native/app.plugin.js
@@ -1,6 +1,6 @@
/**
- * Expo config plugin for @clerk/clerk-expo
- * Automatically configures iOS and Android to work with Clerk native components
+ * Expo config plugin for @clerk/expo-native
+ * Configures iOS and Android for the Clerk native SDKs (clerk-ios / clerk-android)
*
* When this plugin is used:
* 1. iOS is configured with the required deployment target and metadata
@@ -9,13 +9,11 @@
* Native modules and views are registered via Expo Modules autolinking.
*/
const {
- AndroidConfig,
+ createRunOncePlugin,
withXcodeProject,
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
- withAndroidManifest,
- withEntitlementsPlist,
} = require('@expo/config-plugins');
const path = require('path');
const fs = require('fs');
@@ -23,33 +21,14 @@ const packageJson = require('./package.json');
const CLERK_MIN_IOS_VERSION = '17.0';
-const addHostedAuthIntentFilter = (mainActivity, packageName) => {
- const callbackHost = `${packageName}.hosted-callback`;
- const intentFilters = mainActivity['intent-filter'] || [];
- const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
- const callbackIsRegistered = intentFilters.some(
- intentFilter =>
- hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
- hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
- hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
- intentFilter.data?.some(
- data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
- ),
- );
-
- if (callbackIsRegistered) {
- return;
+// The native SDKs report the @clerk/expo version in the x-clerk-host-sdk-version header.
+const resolveHostSdkVersion = projectRoot => {
+ try {
+ const paths = [projectRoot, process.cwd(), __dirname].filter(Boolean);
+ return require(require.resolve('@clerk/expo/package.json', { paths })).version;
+ } catch {
+ return packageJson.version;
}
-
- intentFilters.push({
- action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
- category: [
- { $: { 'android:name': 'android.intent.category.DEFAULT' } },
- { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
- ],
- data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
- });
- mainActivity['intent-filter'] = intentFilters;
};
const withClerkIOS = config => {
@@ -110,8 +89,9 @@ const withClerkIOS = config => {
return config;
});
+ const hostSdkVersion = resolveHostSdkVersion(config._internal?.projectRoot);
config = withInfoPlist(config, modConfig => {
- modConfig.modResults.ClerkExpoVersion = packageJson.version;
+ modConfig.modResults.ClerkExpoVersion = hostSdkVersion;
return modConfig;
});
@@ -125,15 +105,6 @@ const withClerkIOS = config => {
const withClerkAndroid = config => {
console.log('✅ Clerk Android plugin loaded');
- config = withAndroidManifest(config, modConfig => {
- const packageName = config.android?.package;
- if (packageName) {
- const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
- addHostedAuthIntentFilter(mainActivity, packageName);
- }
- return modConfig;
- });
-
return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;
@@ -186,15 +157,6 @@ const withClerkAndroid = config => {
});
};
-/**
- * Combined Clerk Expo plugin
- *
- * When this plugin is configured in app.json/app.config.js:
- * 1. iOS gets the deployment target and metadata required by Clerk native views
- * 2. Android gets packaging exclusions for dependency conflicts
- *
- * Native modules and views are registered via Expo Modules autolinking.
- */
/**
* Write ClerkKeychainService to Info.plist when keychainService is provided.
* This allows extension apps (watch, widget, app clip) to share the same
@@ -212,37 +174,6 @@ const withClerkKeychainService = (config, { keychainService } = {}) => {
});
};
-const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => {
- if (faceIDPermission === undefined) {
- return config;
- }
-
- if (typeof faceIDPermission !== 'string' || faceIDPermission.trim().length === 0) {
- throw new Error('Clerk: faceIDPermission must be a non-empty string');
- }
-
- return withInfoPlist(config, modConfig => {
- if (!Object.hasOwn(modConfig.modResults, 'NSFaceIDUsageDescription')) {
- modConfig.modResults.NSFaceIDUsageDescription = faceIDPermission;
- }
- return modConfig;
- });
-};
-
-/**
- * Add Sign in with Apple entitlement to the iOS app.
- * Required for the native Apple Sign In flow via ASAuthorizationController.
- */
-const withClerkAppleSignIn = config => {
- return withEntitlementsPlist(config, modConfig => {
- if (!modConfig.modResults['com.apple.developer.applesignin']) {
- modConfig.modResults['com.apple.developer.applesignin'] = ['Default'];
- console.log('✅ Added Sign in with Apple entitlement');
- }
- return modConfig;
- });
-};
-
/**
* Apply a custom theme to Clerk native components (iOS + Android).
*
@@ -363,23 +294,16 @@ const withClerkTheme = (config, props = {}) => {
return config;
};
-const withClerkExpo = (config, props = {}) => {
- const { appleSignIn = true } = props;
+const withClerkExpoNative = (config, props = {}) => {
config = withClerkIOS(config);
- if (appleSignIn !== false) {
- config = withClerkAppleSignIn(config);
- }
config = withClerkAndroid(config);
config = withClerkKeychainService(config, props);
- config = withClerkFaceIDPermission(config, props);
config = withClerkTheme(config, props);
return config;
};
-module.exports = withClerkExpo;
+module.exports = createRunOncePlugin(withClerkExpoNative, packageJson.name, packageJson.version);
module.exports._testing = {
- addHostedAuthIntentFilter,
- withClerkFaceIDPermission,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
diff --git a/packages/expo-native/package.json b/packages/expo-native/package.json
new file mode 100644
index 00000000000..25e1addf9d4
--- /dev/null
+++ b/packages/expo-native/package.json
@@ -0,0 +1,83 @@
+{
+ "name": "@clerk/expo-native",
+ "version": "0.0.1",
+ "description": "Native Clerk components and native SDK integration for Expo, powered by clerk-ios and clerk-android",
+ "keywords": [
+ "react",
+ "react-native",
+ "expo",
+ "clerk",
+ "auth",
+ "authentication",
+ "native"
+ ],
+ "homepage": "https://clerk.com/",
+ "bugs": {
+ "url": "https://github.com/clerk/javascript/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clerk/javascript.git",
+ "directory": "packages/expo-native"
+ },
+ "license": "MIT",
+ "author": "Clerk",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./app.plugin.js": "./app.plugin.js",
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.js",
+ "source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist",
+ "android",
+ "ios",
+ "src/specs",
+ "expo-module.config.json",
+ "react-native.config.js",
+ "app.plugin.js",
+ "app.plugin.d.ts"
+ ],
+ "scripts": {
+ "build": "tsdown",
+ "build:declarations": "tsc -p tsconfig.declarations.json",
+ "clean": "rimraf ./dist",
+ "dev": "tsdown --watch",
+ "dev:pub": "pnpm dev -- --env.publish",
+ "format": "node ../../scripts/format-package.mjs",
+ "format:check": "node ../../scripts/format-package.mjs --check",
+ "lint": "eslint src",
+ "test": "vitest run",
+ "test:watch": "vitest watch"
+ },
+ "dependencies": {
+ "tslib": "catalog:repo"
+ },
+ "devDependencies": {
+ "@clerk/expo": "workspace:*",
+ "@expo/config-plugins": "^54.0.4",
+ "react-native": "^0.86.0"
+ },
+ "peerDependencies": {
+ "@clerk/expo": "workspace:^",
+ "expo": "catalog:peer-expo",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-native": ">=0.75"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "codegenConfig": {
+ "name": "ClerkExpoSpec",
+ "type": "all",
+ "jsSrcsDir": "src/specs"
+ }
+}
diff --git a/packages/expo-native/src/AuthView.tsx b/packages/expo-native/src/AuthView.tsx
index 3f4b51ee346..7734de4dd86 100644
--- a/packages/expo-native/src/AuthView.tsx
+++ b/packages/expo-native/src/AuthView.tsx
@@ -2,9 +2,9 @@ import { useCallback } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { Text, View } from 'react-native';
-import NativeClerkAuthView from '../specs/NativeClerkAuthView';
-import { isNativeSupported } from '../utils/native-module';
import type { AuthViewProps } from './AuthView.types';
+import NativeClerkAuthView from './specs/NativeClerkAuthView';
+import { isNativeSupported } from './utils/native-module';
type AuthNativeEvent = NativeSyntheticEvent>;
@@ -24,7 +24,7 @@ type AuthNativeEvent = NativeSyntheticEvent>;
*
* @example
* ```tsx
- * import { AuthView } from '@clerk/expo/native';
+ * import { AuthView } from '@clerk/expo-native';
* import { useAuth } from '@clerk/expo';
*
* export default function SignInScreen() {
@@ -63,7 +63,7 @@ export function AuthView({
{!isNativeSupported
? 'Native AuthView is only available on iOS and Android'
- : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+ : 'Native AuthView requires a development build with the @clerk/expo-native config plugin. Add "@clerk/expo-native" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo-native/src/UserButton.tsx b/packages/expo-native/src/UserButton.tsx
index a1406f0652e..f3038fd4efc 100644
--- a/packages/expo-native/src/UserButton.tsx
+++ b/packages/expo-native/src/UserButton.tsx
@@ -3,8 +3,7 @@ import { useMemo, useRef } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { StyleSheet, useWindowDimensions } from 'react-native';
-import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView';
-import { isNativeSupported } from '../utils/native-module';
+import NativeClerkUserButtonView from './specs/NativeClerkUserButtonView';
import type {
NativeUserProfileNavigationHandle,
UserProfileCustomDestination,
@@ -16,6 +15,7 @@ import {
UserProfileCustomPageHosts,
useUserProfileCustomPages,
} from './UserProfileCustomPages';
+import { isNativeSupported } from './utils/native-module';
type CustomizableNativeUserButtonProps = ComponentProps> & {
customPages?: string;
@@ -47,7 +47,7 @@ export interface UserButtonProps {
*
* @example
* ```tsx
- * import { UserButton } from '@clerk/expo/native';
+ * import { UserButton } from '@clerk/expo-native';
*
* export default function Home() {
* return (
diff --git a/packages/expo-native/src/UserProfileView.tsx b/packages/expo-native/src/UserProfileView.tsx
index 3c39d955375..32aa4b5a6a9 100644
--- a/packages/expo-native/src/UserProfileView.tsx
+++ b/packages/expo-native/src/UserProfileView.tsx
@@ -3,9 +3,8 @@ import { useCallback, useMemo, useRef } from 'react';
import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
-import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
-import { isNativeSupported } from '../utils/native-module';
import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
+import NativeClerkUserProfileView from './specs/NativeClerkUserProfileView';
import type {
NativeUserProfileNavigationHandle,
UserProfileCustomDestination,
@@ -17,6 +16,7 @@ import {
UserProfileCustomPageHosts,
useUserProfileCustomPages,
} from './UserProfileCustomPages';
+import { isNativeSupported } from './utils/native-module';
type CustomizableNativeUserProfileProps = ComponentProps> & {
customPages?: string;
@@ -75,7 +75,7 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps {
*
* @example
* ```tsx
- * import { UserProfileView } from '@clerk/expo/native';
+ * import { UserProfileView } from '@clerk/expo-native';
* import { useAuth } from '@clerk/expo';
*
* export default function ProfileScreen() {
@@ -128,7 +128,7 @@ export function UserProfileView({
{!isNativeSupported
? 'Native UserProfileView is only available on iOS and Android'
- : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+ : 'Native UserProfileView requires a development build with the @clerk/expo-native config plugin. Add "@clerk/expo-native" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo-native/src/__tests__/AuthView.test.tsx b/packages/expo-native/src/__tests__/AuthView.test.tsx
index 6b3029ecdda..528e251fb2e 100644
--- a/packages/expo-native/src/__tests__/AuthView.test.tsx
+++ b/packages/expo-native/src/__tests__/AuthView.test.tsx
@@ -10,13 +10,13 @@ const mocks = vi.hoisted(() => {
};
});
-vi.mock('../../specs/NativeClerkAuthView', () => {
+vi.mock('../specs/NativeClerkAuthView', () => {
return {
default: mocks.NativeClerkAuthView,
};
});
-vi.mock('../../utils/native-module', () => {
+vi.mock('../utils/native-module', () => {
return {
isNativeSupported: true,
};
diff --git a/packages/expo-native/src/__tests__/UserButton.test.tsx b/packages/expo-native/src/__tests__/UserButton.test.tsx
index d4eaef80382..d414aa0f89f 100644
--- a/packages/expo-native/src/__tests__/UserButton.test.tsx
+++ b/packages/expo-native/src/__tests__/UserButton.test.tsx
@@ -8,14 +8,14 @@ const mocks = vi.hoisted(() => ({
nativeProps: vi.fn(),
}));
-vi.mock('../../specs/NativeClerkUserButtonView', () => ({
+vi.mock('../specs/NativeClerkUserButtonView', () => ({
default: React.forwardRef((props: Record, _ref) => {
mocks.nativeProps(props);
return null;
}),
}));
-vi.mock('../../utils/native-module', () => ({
+vi.mock('../utils/native-module', () => ({
isNativeSupported: true,
}));
diff --git a/packages/expo-native/src/__tests__/UserProfileView.test.tsx b/packages/expo-native/src/__tests__/UserProfileView.test.tsx
index 34af7275e6d..619c417305e 100644
--- a/packages/expo-native/src/__tests__/UserProfileView.test.tsx
+++ b/packages/expo-native/src/__tests__/UserProfileView.test.tsx
@@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => {
};
});
-vi.mock('../../specs/NativeClerkUserProfileView', () => {
+vi.mock('../specs/NativeClerkUserProfileView', () => {
return {
default: React.forwardRef((props: { children?: React.ReactNode }, ref) => {
React.useImperativeHandle(ref, () => ({ navigateCustomPage: mocks.navigateCustomPage }));
@@ -24,7 +24,7 @@ vi.mock('../../specs/NativeClerkUserProfileView', () => {
};
});
-vi.mock('../../utils/native-module', () => {
+vi.mock('../utils/native-module', () => {
return {
isNativeSupported: true,
};
diff --git a/packages/expo-native/src/__tests__/appPlugin.theme.test.js b/packages/expo-native/src/__tests__/appPlugin.theme.test.js
index fcff7577b0e..c0f89d02ab8 100644
--- a/packages/expo-native/src/__tests__/appPlugin.theme.test.js
+++ b/packages/expo-native/src/__tests__/appPlugin.theme.test.js
@@ -2,54 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const clerkPlugin = require('../../app.plugin.js');
-const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing;
-
-function applyInfoPlistMod(config, modResults) {
- return config.mods.ios.infoPlist({
- ...config,
- modRequest: {},
- modResults,
- });
-}
-
-describe('withClerkFaceIDPermission', () => {
- test('adds the configured Face ID usage description', async () => {
- const config = withClerkFaceIDPermission(
- { name: 'test', slug: 'test' },
- { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' },
- );
-
- const result = await applyInfoPlistMod(config, {});
-
- expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.');
- });
-
- test('preserves an app-provided Face ID usage description', async () => {
- const config = withClerkFaceIDPermission(
- { name: 'test', slug: 'test' },
- { faceIDPermission: 'Clerk-provided description' },
- );
-
- const result = await applyInfoPlistMod(config, {
- NSFaceIDUsageDescription: 'App-provided description',
- });
-
- expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description');
- });
-
- test('does not configure the Info.plist without an explicit permission description', () => {
- const config = { name: 'test', slug: 'test' };
-
- expect(withClerkFaceIDPermission(config)).toBe(config);
- expect(config).not.toHaveProperty('mods');
- });
-
- test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => {
- expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow(
- 'faceIDPermission must be a non-empty string',
- );
- });
-});
+const { validateThemeJson } = clerkPlugin._testing;
describe('validateThemeJson', () => {
beforeEach(() => {
diff --git a/packages/expo-native/src/__tests__/useAuthViewState.test.tsx b/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
index 988f2a57729..aab5496325c 100644
--- a/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
+++ b/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
@@ -1,7 +1,7 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types';
+import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types';
import { useAuthViewState } from '../useAuthViewState';
const mocks = vi.hoisted(() => ({
@@ -13,11 +13,11 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
}));
-vi.mock('../../hooks/useAuth', () => ({
+vi.mock('@clerk/expo', () => ({
useAuth: () => mocks.auth,
}));
-vi.mock('../../utils/native-module', () => ({
+vi.mock('../utils/native-module', () => ({
get ClerkExpoModule() {
return mocks.module;
},
diff --git a/packages/expo-native/src/index.ts b/packages/expo-native/src/index.ts
index 2a62047dd85..a20538f6186 100644
--- a/packages/expo-native/src/index.ts
+++ b/packages/expo-native/src/index.ts
@@ -7,12 +7,12 @@
*
* ## Installation
*
- * Native components require the `@clerk/expo` plugin to be configured in your `app.json`:
+ * Native components require the `@clerk/expo-native` config plugin in your `app.json`, alongside `@clerk/expo`:
*
* ```json
* {
* "expo": {
- * "plugins": ["@clerk/expo"]
+ * "plugins": ["@clerk/expo", "@clerk/expo-native"]
* }
* }
* ```
@@ -25,7 +25,7 @@
* - {@link UserProfileView} - User profile and account management, renders inline
* - {@link UserButton} - Avatar button that opens the native user profile
*
- * @module @clerk/expo/native
+ * @module @clerk/expo-native
*/
export { AuthView } from './AuthView';
diff --git a/packages/expo-native/src/specs/NativeClerkModule.ts b/packages/expo-native/src/specs/NativeClerkModule.ts
new file mode 100644
index 00000000000..072d011f539
--- /dev/null
+++ b/packages/expo-native/src/specs/NativeClerkModule.ts
@@ -0,0 +1,6 @@
+import { requireOptionalNativeModule } from 'expo';
+
+import type { Spec } from './NativeClerkModule.types';
+
+// Optional so it resolves to null in Expo Go instead of throwing at import time.
+export default requireOptionalNativeModule('ClerkExpo');
diff --git a/packages/expo-native/src/specs/NativeClerkModule.types.ts b/packages/expo-native/src/specs/NativeClerkModule.types.ts
new file mode 100644
index 00000000000..9df94448a91
--- /dev/null
+++ b/packages/expo-native/src/specs/NativeClerkModule.types.ts
@@ -0,0 +1,9 @@
+export type NativeAuthFlowState = {
+ isLoaded: boolean;
+ isAuthFlowComplete: boolean;
+};
+
+export interface Spec {
+ addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
+ getAuthFlowState?(): Promise;
+}
diff --git a/packages/expo-native/src/specs/NativeClerkModule.web.ts b/packages/expo-native/src/specs/NativeClerkModule.web.ts
new file mode 100644
index 00000000000..625a2ed22ae
--- /dev/null
+++ b/packages/expo-native/src/specs/NativeClerkModule.web.ts
@@ -0,0 +1,3 @@
+import type { Spec } from './NativeClerkModule.types';
+
+export default null as Spec | null;
diff --git a/packages/expo-native/src/useAuthViewState.ts b/packages/expo-native/src/useAuthViewState.ts
index ecb793f84f1..a4c4a5f3b61 100644
--- a/packages/expo-native/src/useAuthViewState.ts
+++ b/packages/expo-native/src/useAuthViewState.ts
@@ -1,8 +1,8 @@
+import { useAuth } from '@clerk/expo';
import { useEffect, useState } from 'react';
-import { useAuth } from '../hooks/useAuth';
-import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types';
-import { ClerkExpoModule as ClerkExpo } from '../utils/native-module';
+import type { NativeAuthFlowState } from './specs/NativeClerkModule.types';
+import { ClerkExpoModule as ClerkExpo } from './utils/native-module';
const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged';
diff --git a/packages/expo-native/src/utils/native-module.ts b/packages/expo-native/src/utils/native-module.ts
new file mode 100644
index 00000000000..a416ccd8917
--- /dev/null
+++ b/packages/expo-native/src/utils/native-module.ts
@@ -0,0 +1,7 @@
+import { Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+export const ClerkExpoModule = isNativeSupported ? NativeClerkModule : null;
diff --git a/packages/expo-native/tsconfig.declarations.json b/packages/expo-native/tsconfig.declarations.json
new file mode 100644
index 00000000000..ac04a85ce27
--- /dev/null
+++ b/packages/expo-native/tsconfig.declarations.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "incremental": false,
+ "skipLibCheck": true,
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true,
+ "sourceMap": false,
+ "declarationDir": "./dist"
+ },
+ "include": ["src"],
+ "exclude": ["**/__tests__/**/*", "app.plugin.js"]
+}
diff --git a/packages/expo-native/tsconfig.json b/packages/expo-native/tsconfig.json
new file mode 100644
index 00000000000..5510c6beb95
--- /dev/null
+++ b/packages/expo-native/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "outDir": "dist",
+ "lib": ["es2019", "dom"],
+ "jsx": "react-jsx",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "importHelpers": true,
+ "declaration": true,
+ "declarationMap": false,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "sourceMap": false,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "allowJs": true,
+ "target": "ES2019",
+ "noEmitOnError": false,
+ "incremental": true,
+ "moduleSuffixes": [".web", ".ios", ".android", ".native", ""]
+ },
+ "include": ["src", "app.plugin.js"]
+}
diff --git a/packages/expo-native/tsconfig.test.json b/packages/expo-native/tsconfig.test.json
new file mode 100644
index 00000000000..5635d6cd1b7
--- /dev/null
+++ b/packages/expo-native/tsconfig.test.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "sourceMap": true
+ }
+}
diff --git a/packages/expo-native/tsdown.config.mts b/packages/expo-native/tsdown.config.mts
new file mode 100644
index 00000000000..e84f7249174
--- /dev/null
+++ b/packages/expo-native/tsdown.config.mts
@@ -0,0 +1,33 @@
+import type { Options } from 'tsdown';
+import { defineConfig } from 'tsdown';
+
+import { runAfterLast } from '../../scripts/utils.ts';
+
+function preserveRelativeImports(id: string) {
+ return id.startsWith('.');
+}
+
+export default defineConfig(overrideOptions => {
+ const isWatch = !!overrideOptions.watch;
+ const shouldPublish = !!overrideOptions.env?.publish;
+
+ const options: Options = {
+ format: 'cjs',
+ fixedExtension: false,
+ outDir: './dist',
+ entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx,js}', '!./src/**/__tests__/**'],
+ unbundle: true,
+ clean: true,
+ minify: false,
+ sourcemap: true,
+ deps: {
+ // Keep relative import specifiers unchanged so Metro can apply platform-specific resolution.
+ neverBundle: preserveRelativeImports,
+ },
+ define: {
+ __DEV__: `${isWatch}`,
+ },
+ };
+
+ return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(options);
+});
diff --git a/packages/expo-native/vitest.config.mts b/packages/expo-native/vitest.config.mts
new file mode 100644
index 00000000000..20bc22a33db
--- /dev/null
+++ b/packages/expo-native/vitest.config.mts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [],
+ test: {
+ environment: 'jsdom',
+ includeSource: ['**/*.{js,ts,jsx,tsx}'],
+ setupFiles: './vitest.setup.mts',
+ },
+});
diff --git a/packages/expo-native/vitest.setup.mts b/packages/expo-native/vitest.setup.mts
new file mode 100644
index 00000000000..283c324f550
--- /dev/null
+++ b/packages/expo-native/vitest.setup.mts
@@ -0,0 +1,22 @@
+import { cleanup } from '@testing-library/react';
+import { afterEach, beforeAll, vi } from 'vitest';
+
+// Mock globalThis.expo for expo-modules-core
+if (!globalThis.expo) {
+ // @ts-expect-error - Mocking expo for tests
+ globalThis.expo = {
+ EventEmitter: vi.fn(),
+ };
+}
+
+// Define __DEV__ for expo-modules-core
+if (typeof globalThis.__DEV__ === 'undefined') {
+ // @ts-expect-error - Mocking __DEV__ for tests
+ globalThis.__DEV__ = false;
+}
+
+beforeAll(() => {});
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be2b32a7f6a..39ddc5fae02 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -634,6 +634,9 @@ importers:
'@clerk/clerk-js':
specifier: workspace:^
version: link:../clerk-js
+ '@clerk/expo-native':
+ specifier: workspace:>=0.0.1
+ version: link:../expo-native
'@clerk/react':
specifier: workspace:^
version: link:../react
@@ -708,6 +711,28 @@ importers:
specifier: ~54.0.36
version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ packages/expo-native:
+ dependencies:
+ expo:
+ specifier: catalog:peer-expo
+ version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react:
+ specifier: 18.3.1
+ version: 18.3.1
+ tslib:
+ specifier: catalog:repo
+ version: 2.8.1
+ devDependencies:
+ '@clerk/expo':
+ specifier: workspace:*
+ version: link:../expo
+ '@expo/config-plugins':
+ specifier: ^54.0.4
+ version: 54.0.5
+ react-native:
+ specifier: ^0.86.0
+ version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)
+
packages/expo-passkeys:
dependencies:
'@clerk/shared':
From 170964735a8569f1898b4d5dca061761160324f4 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:28:13 -0400
Subject: [PATCH 03/15] feat(expo): re-export native components from
@clerk/expo-native and delegate its config plugin
Co-Authored-By: Claude Opus 5.5 (1M context)
---
package.json | 2 +-
packages/expo/app.plugin.js | 156 ++++++++++++++++++
packages/expo/native/index.d.ts | 1 +
packages/expo/native/package.json | 2 +-
packages/expo/package.json | 16 +-
packages/expo/src/__tests__/appPlugin.test.js | 120 ++++++++++++++
.../__tests__/useBiometricCredentials.test.ts | 19 ++-
.../useBiometricCredentials.shared.ts | 15 +-
.../expo/src/native/__tests__/index.test.ts | 57 +++++++
packages/expo/src/native/index.ts | 27 +++
.../expo/src/native/loadClerkExpoNative.ts | 18 ++
.../expo/src/provider/nativeClientSync.tsx | 2 +-
12 files changed, 416 insertions(+), 19 deletions(-)
create mode 100644 packages/expo/app.plugin.js
create mode 100644 packages/expo/native/index.d.ts
create mode 100644 packages/expo/src/__tests__/appPlugin.test.js
create mode 100644 packages/expo/src/native/__tests__/index.test.ts
create mode 100644 packages/expo/src/native/index.ts
create mode 100644 packages/expo/src/native/loadClerkExpoNative.ts
diff --git a/package.json b/package.json
index 5884e61374c..7620564b4fd 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"changeset": "changeset",
"changeset:empty": "pnpm changeset --empty",
"clean": "turbo run clean",
- "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
+ "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
new file mode 100644
index 00000000000..fbfa61128bd
--- /dev/null
+++ b/packages/expo/app.plugin.js
@@ -0,0 +1,156 @@
+/**
+ * Expo config plugin for @clerk/expo
+ *
+ * When this plugin is used:
+ * 1. Android registers the hosted auth callback intent filter
+ * 2. iOS gets the Sign in with Apple entitlement and, when configured, the Face ID usage description
+ * 3. If @clerk/expo-native is installed, its config plugin is applied for the Clerk native SDKs
+ */
+const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins');
+
+const CLERK_EXPO_NATIVE = '@clerk/expo-native';
+const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme'];
+
+const addHostedAuthIntentFilter = (mainActivity, packageName) => {
+ const callbackHost = `${packageName}.hosted-callback`;
+ const intentFilters = mainActivity['intent-filter'] || [];
+ const hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name);
+ const callbackIsRegistered = intentFilters.some(
+ intentFilter =>
+ hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') &&
+ hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') &&
+ hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') &&
+ intentFilter.data?.some(
+ data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost,
+ ),
+ );
+
+ if (callbackIsRegistered) {
+ return;
+ }
+
+ intentFilters.push({
+ action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
+ category: [
+ { $: { 'android:name': 'android.intent.category.DEFAULT' } },
+ { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
+ ],
+ data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }],
+ });
+ mainActivity['intent-filter'] = intentFilters;
+};
+
+const withClerkHostedAuthCallback = config => {
+ return withAndroidManifest(config, modConfig => {
+ const packageName = config.android?.package;
+ if (packageName) {
+ const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
+ addHostedAuthIntentFilter(mainActivity, packageName);
+ }
+ return modConfig;
+ });
+};
+
+const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => {
+ if (faceIDPermission === undefined) {
+ return config;
+ }
+
+ if (typeof faceIDPermission !== 'string' || faceIDPermission.trim().length === 0) {
+ throw new Error('Clerk: faceIDPermission must be a non-empty string');
+ }
+
+ return withInfoPlist(config, modConfig => {
+ if (!Object.hasOwn(modConfig.modResults, 'NSFaceIDUsageDescription')) {
+ modConfig.modResults.NSFaceIDUsageDescription = faceIDPermission;
+ }
+ return modConfig;
+ });
+};
+
+/**
+ * Add Sign in with Apple entitlement to the iOS app.
+ * Required for the native Apple Sign In flow via ASAuthorizationController.
+ */
+const withClerkAppleSignIn = config => {
+ return withEntitlementsPlist(config, modConfig => {
+ if (!modConfig.modResults['com.apple.developer.applesignin']) {
+ modConfig.modResults['com.apple.developer.applesignin'] = ['Default'];
+ console.log('✅ Added Sign in with Apple entitlement');
+ }
+ return modConfig;
+ });
+};
+
+const resolveClerkExpoNativePlugin = config => {
+ try {
+ const paths = [config._internal?.projectRoot, process.cwd()].filter(Boolean);
+ return require(require.resolve(`${CLERK_EXPO_NATIVE}/app.plugin.js`, { paths }));
+ } catch {
+ return null;
+ }
+};
+
+const getListedPluginProps = (config, name) => {
+ for (const entry of config.plugins || []) {
+ if (entry === name) {
+ return {};
+ }
+ if (Array.isArray(entry) && entry[0] === name) {
+ return entry[1] || {};
+ }
+ }
+ return {};
+};
+
+/**
+ * Apply the @clerk/expo-native config plugin when it is installed, so apps that only list
+ * "@clerk/expo" keep the iOS deployment target and native SDK configuration they need.
+ */
+const withClerkExpoNative = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => {
+ const nativeProps = Object.fromEntries(
+ CLERK_EXPO_NATIVE_OPTIONS.filter(option => props[option] !== undefined).map(option => [option, props[option]]),
+ );
+ const nativeOptionNames = Object.keys(nativeProps)
+ .map(option => `"${option}"`)
+ .join(', ');
+
+ if (config._internal?.pluginHistory?.[CLERK_EXPO_NATIVE]) {
+ if (nativeOptionNames) {
+ console.warn(
+ `⚠️ Clerk: ${nativeOptionNames} passed to the "@clerk/expo" plugin will be ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`,
+ );
+ }
+ return config;
+ }
+
+ const clerkExpoNativePlugin = resolvePlugin(config);
+ if (!clerkExpoNativePlugin) {
+ if (nativeOptionNames) {
+ console.warn(
+ `⚠️ Clerk: ${nativeOptionNames} configure Clerk native components, which have moved to ${CLERK_EXPO_NATIVE}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`,
+ );
+ }
+ return config;
+ }
+
+ return clerkExpoNativePlugin(config, { ...nativeProps, ...getListedPluginProps(config, CLERK_EXPO_NATIVE) });
+};
+
+const withClerkExpo = (config, props = {}) => {
+ const { appleSignIn = true } = props;
+ if (appleSignIn !== false) {
+ config = withClerkAppleSignIn(config);
+ }
+ config = withClerkHostedAuthCallback(config);
+ config = withClerkFaceIDPermission(config, props);
+ config = withClerkExpoNative(config, props);
+ return config;
+};
+
+module.exports = withClerkExpo;
+module.exports._testing = {
+ addHostedAuthIntentFilter,
+ withClerkExpoNative,
+ withClerkFaceIDPermission,
+};
diff --git a/packages/expo/native/index.d.ts b/packages/expo/native/index.d.ts
new file mode 100644
index 00000000000..0c04017d415
--- /dev/null
+++ b/packages/expo/native/index.d.ts
@@ -0,0 +1 @@
+export * from '@clerk/expo-native';
diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json
index 6ae24b71af4..928da2cc7c8 100644
--- a/packages/expo/native/package.json
+++ b/packages/expo/native/package.json
@@ -1,4 +1,4 @@
{
"main": "../dist/native/index.js",
- "types": "../dist/native/index.d.ts"
+ "types": "./index.d.ts"
}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 95aa54c6c84..dc84ba82b93 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -30,7 +30,7 @@
},
"./app.plugin.js": "./app.plugin.js",
"./native": {
- "types": "./dist/native/index.d.ts",
+ "types": "./native/index.d.ts",
"default": "./dist/native/index.js"
},
"./web": {
@@ -85,8 +85,6 @@
"types": "./dist/index.d.ts",
"files": [
"dist",
- "android",
- "ios",
"native",
"web",
"local-credentials",
@@ -99,9 +97,6 @@
"hosted-auth",
"experimental",
"legacy",
- "src/specs",
- "expo-module.config.json",
- "react-native.config.js",
"app.plugin.js",
"app.plugin.d.ts"
],
@@ -142,6 +137,7 @@
},
"peerDependencies": {
"@clerk/expo-google-signin": ">=0.1.0",
+ "@clerk/expo-native": "workspace:>=0.0.1",
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
@@ -159,6 +155,9 @@
"@clerk/expo-google-signin": {
"optional": true
},
+ "@clerk/expo-native": {
+ "optional": true
+ },
"@clerk/expo-passkeys": {
"optional": true
},
@@ -192,10 +191,5 @@
},
"publishConfig": {
"access": "public"
- },
- "codegenConfig": {
- "name": "ClerkExpoSpec",
- "type": "all",
- "jsSrcsDir": "src/specs"
}
}
diff --git a/packages/expo/src/__tests__/appPlugin.test.js b/packages/expo/src/__tests__/appPlugin.test.js
new file mode 100644
index 00000000000..78e46629533
--- /dev/null
+++ b/packages/expo/src/__tests__/appPlugin.test.js
@@ -0,0 +1,120 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
+const { withClerkExpoNative, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing;
+
+function applyInfoPlistMod(config, modResults) {
+ return config.mods.ios.infoPlist({
+ ...config,
+ modRequest: {},
+ modResults,
+ });
+}
+
+describe('withClerkFaceIDPermission', () => {
+ test('adds the configured Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' },
+ );
+
+ const result = await applyInfoPlistMod(config, {});
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.');
+ });
+
+ test('preserves an app-provided Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Clerk-provided description' },
+ );
+
+ const result = await applyInfoPlistMod(config, {
+ NSFaceIDUsageDescription: 'App-provided description',
+ });
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description');
+ });
+
+ test('does not configure the Info.plist without an explicit permission description', () => {
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkFaceIDPermission(config)).toBe(config);
+ expect(config).not.toHaveProperty('mods');
+ });
+
+ test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => {
+ expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow(
+ 'faceIDPermission must be a non-empty string',
+ );
+ });
+});
+
+describe('withClerkExpoNative', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test('applies the @clerk/expo-native plugin with its options when it is installed', () => {
+ const nativePlugin = vi.fn(config => ({ ...config, applied: true }));
+ const config = { name: 'test', slug: 'test' };
+
+ const result = withClerkExpoNative(
+ config,
+ { keychainService: 'com.example.shared', theme: './theme.json', appleSignIn: false },
+ () => nativePlugin,
+ );
+
+ expect(result.applied).toBe(true);
+ expect(nativePlugin).toHaveBeenCalledWith(config, { keychainService: 'com.example.shared', theme: './theme.json' });
+ });
+
+ test('prefers options passed to an explicitly listed @clerk/expo-native plugin', () => {
+ const nativePlugin = vi.fn(config => config);
+ const config = {
+ name: 'test',
+ slug: 'test',
+ plugins: ['@clerk/expo', ['@clerk/expo-native', { theme: './native-theme.json' }]],
+ };
+
+ withClerkExpoNative(config, { keychainService: 'com.example.shared', theme: './theme.json' }, () => nativePlugin);
+
+ expect(nativePlugin).toHaveBeenCalledWith(config, {
+ keychainService: 'com.example.shared',
+ theme: './native-theme.json',
+ });
+ });
+
+ test('does not apply the @clerk/expo-native plugin twice', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const nativePlugin = vi.fn(config => config);
+ const config = {
+ name: 'test',
+ slug: 'test',
+ _internal: { pluginHistory: { '@clerk/expo-native': { name: '@clerk/expo-native', version: '0.0.1' } } },
+ };
+
+ expect(withClerkExpoNative(config, {}, () => nativePlugin)).toBe(config);
+ expect(nativePlugin).not.toHaveBeenCalled();
+ expect(warn).not.toHaveBeenCalled();
+
+ withClerkExpoNative(config, { theme: './theme.json' }, () => nativePlugin);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Pass them to the "@clerk/expo-native" plugin instead'));
+ });
+
+ test('leaves the config untouched when @clerk/expo-native is not installed', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkExpoNative(config, {}, () => null)).toBe(config);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ test('warns with install instructions when native-only options are passed without @clerk/expo-native', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkExpoNative(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native'));
+ });
+});
diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
index dee295ff1a5..1b7a7c46c5d 100644
--- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
+++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
useClerk: vi.fn(),
setActive: vi.fn(),
synchronizeNativeClientToJs: vi.fn(),
+ isNativeModuleInstalled: true,
nativeModule: {
getTrustedDeviceAvailability: vi.fn(),
listTrustedDevices: vi.fn(),
@@ -38,7 +39,9 @@ vi.mock('@clerk/react', () => ({
}));
vi.mock('../../utils/native-module', () => ({
- ClerkExpoModule: mocks.nativeModule,
+ get ClerkExpoModule() {
+ return mocks.isNativeModuleInstalled ? mocks.nativeModule : null;
+ },
}));
vi.mock('react-native', () => ({
@@ -539,13 +542,25 @@ describe('useBiometricCredentials on iOS', () => {
});
});
+ test('explains how to install @clerk/expo-native when the native module is missing', async () => {
+ mocks.isNativeModuleInstalled = false;
+
+ try {
+ await expect(renderBiometricCredentials().signIn()).rejects.toThrow(
+ 'Biometric credentials require the @clerk/expo-native package in a development build. Install it with `npx expo install @clerk/expo-native`',
+ );
+ } finally {
+ mocks.isNativeModuleInstalled = true;
+ }
+ });
+
test('explains that the development client must contain the native methods', async () => {
const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice;
Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined });
try {
await expect(renderBiometricCredentials().signIn()).rejects.toThrow(
- 'Biometric credentials require a development build containing a compatible version of @clerk/expo.',
+ 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native.',
);
} finally {
Object.assign(mocks.nativeModule, { signInWithTrustedDevice });
diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
index 3e92e5287be..49f611363f1 100644
--- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
+++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
@@ -14,6 +14,9 @@ import type {
const DEFAULT_POLICY = 'biometry_current_set';
+const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS =
+ 'Install it with `npx expo install @clerk/expo-native`, add "@clerk/expo-native" to the plugins array in your app config, then rebuild your development build.';
+
function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform {
return platform === 'ios' || platform === 'android' ? platform : 'unknown';
}
@@ -25,15 +28,21 @@ function toBiometricCredentialStatus(status: string): BiometricCredentialStatus
function getNativeModule(): NativeBiometricCredentialModule {
const nativeModule = ClerkExpoModule;
+ if (!nativeModule) {
+ return errorThrower.throw(
+ `Biometric credentials require the @clerk/expo-native package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`,
+ );
+ }
+
if (
- !nativeModule?.getTrustedDeviceAvailability ||
+ !nativeModule.getTrustedDeviceAvailability ||
!nativeModule.listTrustedDevices ||
!nativeModule.enrollTrustedDevice ||
!nativeModule.revokeTrustedDevice ||
!nativeModule.signInWithTrustedDevice
) {
return errorThrower.throw(
- 'Biometric credentials require a development build containing a compatible version of @clerk/expo.',
+ 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native.',
);
}
@@ -86,7 +95,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
const nativeModule = getNativeModule();
if (typeof nativeModule.reverifyWithBiometrics !== 'function') {
return errorThrower.throw(
- 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.',
+ 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native.',
);
}
const level = params?.level ?? 'first_factor';
diff --git a/packages/expo/src/native/__tests__/index.test.ts b/packages/expo/src/native/__tests__/index.test.ts
new file mode 100644
index 00000000000..6d936310043
--- /dev/null
+++ b/packages/expo/src/native/__tests__/index.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+const importNativeEntry = () => import('../index');
+
+describe('@clerk/expo/native', () => {
+ afterEach(() => {
+ vi.doUnmock('../loadClerkExpoNative');
+ vi.resetModules();
+ vi.restoreAllMocks();
+ });
+
+ describe('when @clerk/expo-native is not installed', () => {
+ beforeEach(() => {
+ vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => null }));
+ });
+
+ test.each(['AuthView', 'UserButton', 'UserProfileView'] as const)(
+ 'rendering %s throws an error with install instructions',
+ async name => {
+ const Component = (await importNativeEntry())[name] as (props: object) => unknown;
+
+ expect(() => Component({})).toThrow(
+ /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native package\. Install it with `npx expo install @clerk\/expo-native`/,
+ );
+ },
+ );
+
+ test.each(['useAuthViewState', 'useUserProfileCustomPageNavigation'] as const)(
+ 'calling %s throws an error with install instructions',
+ async name => {
+ const hook = (await importNativeEntry())[name] as () => unknown;
+
+ expect(() => hook()).toThrow(`\`${name}\` is unavailable.`);
+ expect(() => hook()).toThrow('add "@clerk/expo-native" to the plugins array in your app config');
+ },
+ );
+ });
+
+ test('re-exports @clerk/expo-native when it is installed', async () => {
+ const clerkExpoNative = {
+ AuthView: vi.fn(() => null),
+ UserButton: vi.fn(() => null),
+ UserProfileView: vi.fn(() => null),
+ useAuthViewState: vi.fn(),
+ useUserProfileCustomPageNavigation: vi.fn(),
+ };
+ vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => clerkExpoNative }));
+
+ const nativeEntry = await importNativeEntry();
+
+ expect(nativeEntry.AuthView).toBe(clerkExpoNative.AuthView);
+ expect(nativeEntry.UserButton).toBe(clerkExpoNative.UserButton);
+ expect(nativeEntry.UserProfileView).toBe(clerkExpoNative.UserProfileView);
+ expect(nativeEntry.useAuthViewState).toBe(clerkExpoNative.useAuthViewState);
+ expect(nativeEntry.useUserProfileCustomPageNavigation).toBe(clerkExpoNative.useUserProfileCustomPageNavigation);
+ });
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
new file mode 100644
index 00000000000..34cc5902993
--- /dev/null
+++ b/packages/expo/src/native/index.ts
@@ -0,0 +1,27 @@
+import { errorThrower } from '../errorThrower';
+import type { ClerkExpoNativeModule } from './loadClerkExpoNative';
+import { loadClerkExpoNative } from './loadClerkExpoNative';
+
+// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native.
+
+const CLERK_EXPO_NATIVE_MISSING_MESSAGE =
+ 'Native components have moved to the @clerk/expo-native package. ' +
+ 'Install it with `npx expo install @clerk/expo-native`, add "@clerk/expo-native" to the plugins array in your app config, ' +
+ 'then rebuild your native app. You can then import them from "@clerk/expo-native".';
+
+const clerkExpoNative = loadClerkExpoNative();
+
+function resolveExport(name: K) {
+ return (
+ clerkExpoNative?.[name] ??
+ function ClerkExpoNativeMissing(): never {
+ return errorThrower.throw(`\`${name}\` is unavailable. ${CLERK_EXPO_NATIVE_MISSING_MESSAGE}`);
+ }
+ );
+}
+
+export const AuthView = resolveExport('AuthView');
+export const UserButton = resolveExport('UserButton');
+export const UserProfileView = resolveExport('UserProfileView');
+export const useAuthViewState = resolveExport('useAuthViewState');
+export const useUserProfileCustomPageNavigation = resolveExport('useUserProfileCustomPageNavigation');
diff --git a/packages/expo/src/native/loadClerkExpoNative.ts b/packages/expo/src/native/loadClerkExpoNative.ts
new file mode 100644
index 00000000000..06f8e66b9dc
--- /dev/null
+++ b/packages/expo/src/native/loadClerkExpoNative.ts
@@ -0,0 +1,18 @@
+type ClerkExpoNativeExport = (...args: never[]) => unknown;
+
+export type ClerkExpoNativeModule = Partial<
+ Record<
+ 'AuthView' | 'UserButton' | 'UserProfileView' | 'useAuthViewState' | 'useUserProfileCustomPageNavigation',
+ ClerkExpoNativeExport
+ >
+>;
+
+export function loadClerkExpoNative(): ClerkExpoNativeModule | null {
+ try {
+ // Synchronous require() in try/catch so Metro treats @clerk/expo-native as an optional dependency.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require('@clerk/expo-native') as ClerkExpoNativeModule;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx
index 5f40ed5287c..99adbcd6e38 100644
--- a/packages/expo/src/provider/nativeClientSync.tsx
+++ b/packages/expo/src/provider/nativeClientSync.tsx
@@ -1163,7 +1163,7 @@ export function useNativeClientBootstrap({
if (__DEV__) {
console.debug(
`[ClerkProvider] Native Clerk module not available. ` +
- `To enable native features, add "@clerk/expo" to your app.json plugins array.`,
+ `To enable native features, install @clerk/expo-native and add "@clerk/expo-native" to your app.json plugins array.`,
);
}
} else if (__DEV__) {
From a01e01c7a9027439657529ec495f89df5c332938 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:28:14 -0400
Subject: [PATCH 04/15] chore(expo-native): wire the package into CI, the
native fixture, and add changesets
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-native-components-moved.md | 25 +++++++++++++++++++
.changeset/introduce-expo-native.md | 5 ++++
.../clerk-monorepo/references/package-map.md | 1 +
.github/labeler.yml | 4 +++
.github/workflows/api-changes.yml | 2 ++
.github/workflows/expo-native-build.yml | 10 +++++---
integration/templates/expo-native/app.json | 2 +-
7 files changed, 45 insertions(+), 4 deletions(-)
create mode 100644 .changeset/expo-native-components-moved.md
create mode 100644 .changeset/introduce-expo-native.md
diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md
new file mode 100644
index 00000000000..6cf1fedf844
--- /dev/null
+++ b/.changeset/expo-native-components-moved.md
@@ -0,0 +1,25 @@
+---
+'@clerk/expo': minor
+---
+
+The native components (`AuthView`, `UserButton`, `UserProfileView`), the native module behind biometric credentials, and native client sync have moved to the new `@clerk/expo-native` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
+
+If you use the native components or `useBiometricCredentials()`, install the new package:
+
+```sh
+npx expo install @clerk/expo-native
+```
+
+add its config plugin alongside `@clerk/expo` in your app config:
+
+```json
+{
+ "expo": {
+ "plugins": ["@clerk/expo", "@clerk/expo-native"]
+ }
+}
+```
+
+then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native`, which you can also import from directly. Without `@clerk/expo-native` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it.
+
+The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native` is installed, and warns otherwise.
diff --git a/.changeset/introduce-expo-native.md b/.changeset/introduce-expo-native.md
new file mode 100644
index 00000000000..637d416fff9
--- /dev/null
+++ b/.changeset/introduce-expo-native.md
@@ -0,0 +1,5 @@
+---
+'@clerk/expo-native': minor
+---
+
+Add `@clerk/expo-native`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options.
diff --git a/.claude/skills/clerk-monorepo/references/package-map.md b/.claude/skills/clerk-monorepo/references/package-map.md
index c5cabf3765b..b86fb4e5a44 100644
--- a/.claude/skills/clerk-monorepo/references/package-map.md
+++ b/.claude/skills/clerk-monorepo/references/package-map.md
@@ -34,6 +34,7 @@ backwards-compatibility contract (see `breaking-changes.md`).
| `@clerk/react-router` | adapter | | React Router v7 SDK. |
| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. |
| `@clerk/expo` | adapter | | React Native / Expo SDK. |
+| `@clerk/expo-native` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. |
| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. |
| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. |
| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. |
diff --git a/.github/labeler.yml b/.github/labeler.yml
index b6f18eeb545..675d48e803f 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -28,6 +28,10 @@ expo:
- changed-files:
- any-glob-to-any-file: packages/expo/**
+expo-native:
+ - changed-files:
+ - any-glob-to-any-file: packages/expo-native/**
+
express:
- changed-files:
- any-glob-to-any-file: packages/express/**
diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml
index 25971a1ad1b..4d7ca8eda83 100644
--- a/.github/workflows/api-changes.yml
+++ b/.github/workflows/api-changes.yml
@@ -19,6 +19,7 @@ on:
- 'packages/clerk-js/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
+ - 'packages/expo-native/**'
- 'packages/expo-passkeys/**'
- 'packages/express/**'
- 'packages/fastify/**'
@@ -56,6 +57,7 @@ env:
--filter=@clerk/clerk-js
--filter=@clerk/expo
--filter=@clerk/expo-google-signin
+ --filter=@clerk/expo-native
--filter=@clerk/expo-passkeys
--filter=@clerk/express
--filter=@clerk/fastify
diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml
index bf96461fe88..609392e24a5 100644
--- a/.github/workflows/expo-native-build.yml
+++ b/.github/workflows/expo-native-build.yml
@@ -11,6 +11,7 @@ on:
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
+ - 'packages/expo-native/**'
workflow_dispatch:
permissions:
@@ -85,6 +86,7 @@ jobs:
packages/clerk-js \
packages/expo \
packages/expo-google-signin \
+ packages/expo-native \
packages/react \
packages/shared \
"$FIXTURE_DIR" |
@@ -121,10 +123,11 @@ jobs:
- name: Build and pack Clerk packages
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: |
- pnpm --filter @clerk/expo... build
+ pnpm turbo build --filter=@clerk/expo-native...
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
+ pnpm --filter @clerk/expo-native pack --pack-destination "$SDK_PACK_DIR"
- name: Install fixture dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
@@ -135,10 +138,11 @@ jobs:
run: |
cp "package.sdk-$EXPO_SDK.json" package.json
pnpm install --no-frozen-lockfile
- # [0-9] keeps this glob off the clerk-expo-google-signin tarball.
+ # [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
- pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w
+ NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-*.tgz)"
+ pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
# flow on a blank screen. Skip it on e2e jobs only.
diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json
index 58e258ea523..4ea4b82bb8f 100644
--- a/integration/templates/expo-native/app.json
+++ b/integration/templates/expo-native/app.json
@@ -12,6 +12,6 @@
"android": {
"package": "com.clerk.exponativebuildfixture"
},
- "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"]
+ "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-native", "expo-web-browser"]
}
}
From d3be19af0b3214b63dc70ef2a2562bb1b288fc06 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:31:42 -0400
Subject: [PATCH 05/15] fix(expo): clarify config plugin warnings for
@clerk/expo-native options
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/expo/app.plugin.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index fbfa61128bd..29c276c2067 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -118,7 +118,7 @@ const withClerkExpoNative = (config, props = {}, resolvePlugin = resolveClerkExp
if (config._internal?.pluginHistory?.[CLERK_EXPO_NATIVE]) {
if (nativeOptionNames) {
console.warn(
- `⚠️ Clerk: ${nativeOptionNames} passed to the "@clerk/expo" plugin will be ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`,
+ `⚠️ Clerk: The following "@clerk/expo" plugin options are ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran: ${nativeOptionNames}. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`,
);
}
return config;
@@ -128,7 +128,7 @@ const withClerkExpoNative = (config, props = {}, resolvePlugin = resolveClerkExp
if (!clerkExpoNativePlugin) {
if (nativeOptionNames) {
console.warn(
- `⚠️ Clerk: ${nativeOptionNames} configure Clerk native components, which have moved to ${CLERK_EXPO_NATIVE}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`,
+ `⚠️ Clerk: The following "@clerk/expo" plugin options require ${CLERK_EXPO_NATIVE} and are ignored: ${nativeOptionNames}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`,
);
}
return config;
From dd05e528852594db6241bbfc0ae715b510a9ec10 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:38:50 -0400
Subject: [PATCH 06/15] refactor(expo-native): drop the dependency on
@clerk/expo
@clerk/expo-native now takes useAuth from @clerk/react, so the two workspace
packages no longer form a cycle. The config plugin records the installed
@clerk/expo version for the native host SDK header on both platforms via
Info.plist and gradle.properties, and fails early if the installed @clerk/expo
still bundles the native module.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.github/workflows/expo-native-build.yml | 2 +-
packages/expo-native/android/build.gradle | 19 ++-----
packages/expo-native/app.plugin.js | 44 ++++++++++++----
packages/expo-native/package.json | 3 +-
.../src/__tests__/appPlugin.test.js | 50 +++++++++++++++++++
.../src/__tests__/useAuthViewState.test.tsx | 2 +-
packages/expo-native/src/useAuthViewState.ts | 2 +-
packages/expo/package.json | 3 +-
pnpm-lock.yaml | 12 ++---
9 files changed, 98 insertions(+), 39 deletions(-)
create mode 100644 packages/expo-native/src/__tests__/appPlugin.test.js
diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml
index 609392e24a5..375d3cc47d1 100644
--- a/.github/workflows/expo-native-build.yml
+++ b/.github/workflows/expo-native-build.yml
@@ -123,7 +123,7 @@ jobs:
- name: Build and pack Clerk packages
if: steps.native-build-cache.outputs.cache-hit != 'true'
run: |
- pnpm turbo build --filter=@clerk/expo-native...
+ pnpm --filter @clerk/expo... build
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
diff --git a/packages/expo-native/android/build.gradle b/packages/expo-native/android/build.gradle
index 5cb80b92dd8..18c81a17cbe 100644
--- a/packages/expo-native/android/build.gradle
+++ b/packages/expo-native/android/build.gradle
@@ -13,22 +13,9 @@ applyKotlinExpoModulesCorePlugin()
group = 'com.clerk.expo'
version = '1.0.0'
-// The native SDKs report the @clerk/expo version in the x-clerk-host-sdk-version header.
-def resolveClerkExpoVersion() {
- try {
- def process = new ProcessBuilder("node", "--print", "require.resolve('@clerk/expo/package.json')")
- .directory(projectDir)
- .start()
- def clerkExpoPackageJsonPath = process.inputStream.text.trim()
- if (process.waitFor() == 0 && clerkExpoPackageJsonPath) {
- return new JsonSlurper().parse(new File(clerkExpoPackageJsonPath)).version.toString()
- }
- } catch (Exception ignored) {
- }
- return new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString()
-}
-
-def clerkExpoVersion = resolveClerkExpoVersion()
+// Set by the @clerk/expo-native config plugin to the installed @clerk/expo version.
+def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toString() ?:
+ new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString()
// Dependency versions - centralized for easier updates
// See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs
diff --git a/packages/expo-native/app.plugin.js b/packages/expo-native/app.plugin.js
index f8faa236dea..98a22f4b09d 100644
--- a/packages/expo-native/app.plugin.js
+++ b/packages/expo-native/app.plugin.js
@@ -14,24 +14,29 @@ const {
withDangerousMod,
withInfoPlist,
withAppBuildGradle,
+ withGradleProperties,
} = require('@expo/config-plugins');
const path = require('path');
const fs = require('fs');
const packageJson = require('./package.json');
const CLERK_MIN_IOS_VERSION = '17.0';
+const HOST_SDK_VERSION_GRADLE_PROPERTY = 'clerkExpo.hostSdkVersion';
-// The native SDKs report the @clerk/expo version in the x-clerk-host-sdk-version header.
-const resolveHostSdkVersion = projectRoot => {
+const resolveClerkExpo = projectRoot => {
try {
- const paths = [projectRoot, process.cwd(), __dirname].filter(Boolean);
- return require(require.resolve('@clerk/expo/package.json', { paths })).version;
+ const paths = [projectRoot, process.cwd()].filter(Boolean);
+ const clerkExpoPackageJsonPath = require.resolve('@clerk/expo/package.json', { paths });
+ return {
+ dir: path.dirname(clerkExpoPackageJsonPath),
+ version: require(clerkExpoPackageJsonPath).version,
+ };
} catch {
- return packageJson.version;
+ return null;
}
};
-const withClerkIOS = config => {
+const withClerkIOS = (config, hostSdkVersion) => {
console.log('✅ Clerk iOS plugin loaded');
// IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install
@@ -89,7 +94,6 @@ const withClerkIOS = config => {
return config;
});
- const hostSdkVersion = resolveHostSdkVersion(config._internal?.projectRoot);
config = withInfoPlist(config, modConfig => {
modConfig.modResults.ClerkExpoVersion = hostSdkVersion;
return modConfig;
@@ -102,9 +106,17 @@ const withClerkIOS = config => {
* Add packaging exclusions to Android app build.gradle to resolve
* duplicate META-INF file conflicts from clerk-android dependencies.
*/
-const withClerkAndroid = config => {
+const withClerkAndroid = (config, hostSdkVersion) => {
console.log('✅ Clerk Android plugin loaded');
+ config = withGradleProperties(config, modConfig => {
+ modConfig.modResults = modConfig.modResults.filter(
+ item => !(item.type === 'property' && item.key === HOST_SDK_VERSION_GRADLE_PROPERTY),
+ );
+ modConfig.modResults.push({ type: 'property', key: HOST_SDK_VERSION_GRADLE_PROPERTY, value: hostSdkVersion });
+ return modConfig;
+ });
+
return withAppBuildGradle(config, modConfig => {
let buildGradle = modConfig.modResults.contents;
@@ -294,9 +306,18 @@ const withClerkTheme = (config, props = {}) => {
return config;
};
-const withClerkExpoNative = (config, props = {}) => {
- config = withClerkIOS(config);
- config = withClerkAndroid(config);
+const withClerkExpoNative = (config, props = {}, resolve = resolveClerkExpo) => {
+ const clerkExpo = resolve(config._internal?.projectRoot);
+ if (clerkExpo && fs.existsSync(path.join(clerkExpo.dir, 'expo-module.config.json'))) {
+ throw new Error(
+ `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native. Upgrade @clerk/expo to a version that supports @clerk/expo-native.`,
+ );
+ }
+ // Native requests report the @clerk/expo version in the x-clerk-host-sdk-version header.
+ const hostSdkVersion = clerkExpo?.version ?? packageJson.version;
+
+ config = withClerkIOS(config, hostSdkVersion);
+ config = withClerkAndroid(config, hostSdkVersion);
config = withClerkKeychainService(config, props);
config = withClerkTheme(config, props);
return config;
@@ -304,6 +325,7 @@ const withClerkExpoNative = (config, props = {}) => {
module.exports = createRunOncePlugin(withClerkExpoNative, packageJson.name, packageJson.version);
module.exports._testing = {
+ withClerkExpoNative,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
diff --git a/packages/expo-native/package.json b/packages/expo-native/package.json
index 25e1addf9d4..f732b7297da 100644
--- a/packages/expo-native/package.json
+++ b/packages/expo-native/package.json
@@ -56,15 +56,14 @@
"test:watch": "vitest watch"
},
"dependencies": {
+ "@clerk/react": "workspace:^",
"tslib": "catalog:repo"
},
"devDependencies": {
- "@clerk/expo": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"react-native": "^0.86.0"
},
"peerDependencies": {
- "@clerk/expo": "workspace:^",
"expo": "catalog:peer-expo",
"react": "^18.0.0 || ^19.0.0",
"react-native": ">=0.75"
diff --git a/packages/expo-native/src/__tests__/appPlugin.test.js b/packages/expo-native/src/__tests__/appPlugin.test.js
new file mode 100644
index 00000000000..bb611cb434b
--- /dev/null
+++ b/packages/expo-native/src/__tests__/appPlugin.test.js
@@ -0,0 +1,50 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
+const { withClerkExpoNative } = require('../../app.plugin.js')._testing;
+
+const applyMod = (config, platform, mod, modResults) =>
+ config.mods[platform][mod]({ ...config, modRequest: {}, modResults });
+
+describe('withClerkExpoNative', () => {
+ let clerkExpoDir;
+
+ beforeEach(() => {
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ clerkExpoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-'));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ fs.rmSync(clerkExpoDir, { recursive: true, force: true });
+ });
+
+ test('reports the installed @clerk/expo version on iOS and Android', async () => {
+ const config = withClerkExpoNative({ name: 'test', slug: 'test' }, {}, () => ({
+ dir: clerkExpoDir,
+ version: '4.8.0',
+ }));
+
+ const infoPlist = await applyMod(config, 'ios', 'infoPlist', {});
+ const gradleProperties = await applyMod(config, 'android', 'gradleProperties', [
+ { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.7.0' },
+ ]);
+
+ expect(infoPlist.modResults.ClerkExpoVersion).toBe('4.8.0');
+ expect(gradleProperties.modResults).toEqual([
+ { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.8.0' },
+ ]);
+ });
+
+ test('throws when the installed @clerk/expo still bundles the native module', () => {
+ fs.writeFileSync(path.join(clerkExpoDir, 'expo-module.config.json'), '{}');
+
+ expect(() =>
+ withClerkExpoNative({ name: 'test', slug: 'test' }, {}, () => ({ dir: clerkExpoDir, version: '4.7.1' })),
+ ).toThrow('@clerk/expo@4.7.1 still bundles the Clerk native module');
+ });
+});
diff --git a/packages/expo-native/src/__tests__/useAuthViewState.test.tsx b/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
index aab5496325c..7b16d1816ef 100644
--- a/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
+++ b/packages/expo-native/src/__tests__/useAuthViewState.test.tsx
@@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
}));
-vi.mock('@clerk/expo', () => ({
+vi.mock('@clerk/react', () => ({
useAuth: () => mocks.auth,
}));
diff --git a/packages/expo-native/src/useAuthViewState.ts b/packages/expo-native/src/useAuthViewState.ts
index a4c4a5f3b61..c32f8e3e637 100644
--- a/packages/expo-native/src/useAuthViewState.ts
+++ b/packages/expo-native/src/useAuthViewState.ts
@@ -1,4 +1,4 @@
-import { useAuth } from '@clerk/expo';
+import { useAuth } from '@clerk/react';
import { useEffect, useState } from 'react';
import type { NativeAuthFlowState } from './specs/NativeClerkModule.types';
diff --git a/packages/expo/package.json b/packages/expo/package.json
index dc84ba82b93..46deb108a47 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -122,6 +122,7 @@
},
"devDependencies": {
"@clerk/expo-google-signin": "workspace:*",
+ "@clerk/expo-native": "workspace:*",
"@clerk/expo-passkeys": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
@@ -137,7 +138,7 @@
},
"peerDependencies": {
"@clerk/expo-google-signin": ">=0.1.0",
- "@clerk/expo-native": "workspace:>=0.0.1",
+ "@clerk/expo-native": ">=0.1.0",
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 39ddc5fae02..0b78f04fdd7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -634,9 +634,6 @@ importers:
'@clerk/clerk-js':
specifier: workspace:^
version: link:../clerk-js
- '@clerk/expo-native':
- specifier: workspace:>=0.0.1
- version: link:../expo-native
'@clerk/react':
specifier: workspace:^
version: link:../react
@@ -665,6 +662,9 @@ importers:
'@clerk/expo-google-signin':
specifier: workspace:*
version: link:../expo-google-signin
+ '@clerk/expo-native':
+ specifier: workspace:*
+ version: link:../expo-native
'@clerk/expo-passkeys':
specifier: workspace:*
version: link:../expo-passkeys
@@ -713,6 +713,9 @@ importers:
packages/expo-native:
dependencies:
+ '@clerk/react':
+ specifier: workspace:^
+ version: link:../react
expo:
specifier: catalog:peer-expo
version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -723,9 +726,6 @@ importers:
specifier: catalog:repo
version: 2.8.1
devDependencies:
- '@clerk/expo':
- specifier: workspace:*
- version: link:../expo
'@expo/config-plugins':
specifier: ^54.0.4
version: 54.0.5
From 5ef3c7d6bf66ed751b163fd8eb0b5f1de25fd738 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:34:21 -0400
Subject: [PATCH 07/15] chore(expo): leave biometric credentials out of the
native split changeset
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-native-components-moved.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md
index 6cf1fedf844..5f4a79a356e 100644
--- a/.changeset/expo-native-components-moved.md
+++ b/.changeset/expo-native-components-moved.md
@@ -2,9 +2,9 @@
'@clerk/expo': minor
---
-The native components (`AuthView`, `UserButton`, `UserProfileView`), the native module behind biometric credentials, and native client sync have moved to the new `@clerk/expo-native` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
+The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
-If you use the native components or `useBiometricCredentials()`, install the new package:
+If you use the native components, install the new package:
```sh
npx expo install @clerk/expo-native
From 170d56f62d2b36b47b84110c1ee8cff67f47d026 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:08:47 -0400
Subject: [PATCH 08/15] refactor(expo-native-components): rename
@clerk/expo-native to @clerk/expo-native-components
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-native-components-moved.md | 10 ++--
.changeset/introduce-expo-native.md | 4 +-
.../clerk-monorepo/references/package-map.md | 54 +++++++++----------
.github/labeler.yml | 4 +-
.github/workflows/api-changes.yml | 4 +-
.github/workflows/expo-native-build.yml | 8 +--
package.json | 2 +-
.../.gitignore | 0
.../LICENSE | 0
.../README.md | 28 +++++-----
.../android/build.gradle | 4 +-
.../android/src/main/AndroidManifest.xml | 0
.../expo/modules/clerk/ClerkAuthViewModule.kt | 0
.../clerk/ClerkComposeNativeViewHost.kt | 0
.../expo/modules/clerk/ClerkExpoModule.kt | 0
.../clerk/ClerkUserButtonViewModule.kt | 0
.../clerk/ClerkUserProfileCustomPageState.kt | 0
.../clerk/ClerkUserProfileViewModule.kt | 0
.../clerk/BiometricCredentialBridgeTest.kt | 0
.../ClerkUserProfileCustomPageStateTest.kt | 0
.../expo-native-components/app.plugin.d.ts | 5 ++
.../app.plugin.js | 10 ++--
.../expo-module.config.json | 0
.../ios/ClerkAppDelegateSubscriber.swift | 0
.../ios/ClerkAuthNativeView.swift | 0
.../ios/ClerkExpo.podspec | 0
.../ios/ClerkExpoModule.swift | 0
.../ios/ClerkNativeBridge.swift | 0
.../ios/ClerkNativeViewHost.swift | 0
.../ios/ClerkUserButtonNativeView.swift | 0
.../ios/ClerkUserProfileNativeView.swift | 0
.../Tests/ClerkAuthNativeViewPaperTests.swift | 0
.../ios/Tests/ClerkNativeBridgeTests.swift | 0
.../ios/Tests/ClerkNativeViewHostTests.swift | 0
...ClerkUserProfileCustomPageStateTests.swift | 0
.../package.json | 4 +-
.../react-native.config.js | 0
.../src/AuthView.tsx | 4 +-
.../src/AuthView.types.ts | 0
.../src/EmbeddedNavigation.types.ts | 0
.../src/UserButton.tsx | 2 +-
.../src/UserProfileCustomPages.tsx | 0
.../src/UserProfileView.tsx | 4 +-
.../src/__tests__/AuthView.test.tsx | 0
.../src/__tests__/UserButton.test.tsx | 0
.../__tests__/UserProfileCustomPages.test.tsx | 0
.../src/__tests__/UserProfileView.test.tsx | 0
.../src/__tests__/appPlugin.test.js | 11 ++--
.../src/__tests__/appPlugin.theme.test.js | 0
.../src/__tests__/useAuthViewState.test.tsx | 0
.../src/index.ts | 6 +--
.../src/specs/NativeClerkAuthView.android.ts | 0
.../src/specs/NativeClerkAuthView.ts | 0
.../src/specs/NativeClerkModule.ts | 0
.../src/specs/NativeClerkModule.types.ts | 0
.../src/specs/NativeClerkModule.web.ts | 0
.../NativeClerkUserButtonView.android.ts | 0
.../src/specs/NativeClerkUserButtonView.ts | 0
.../NativeClerkUserProfileView.android.ts | 0
.../src/specs/NativeClerkUserProfileView.ts | 0
.../specs/__tests__/native-view.web.test.ts | 0
.../src/useAuthViewState.ts | 0
.../src/utils/native-module.ts | 0
.../tsconfig.declarations.json | 0
.../tsconfig.json | 0
.../tsconfig.test.json | 0
.../tsdown.config.mts | 0
.../vitest.config.mts | 0
.../vitest.setup.mts | 0
packages/expo-native/app.plugin.d.ts | 5 --
packages/expo/app.plugin.js | 12 ++---
packages/expo/native/index.d.ts | 2 +-
packages/expo/package.json | 6 +--
packages/expo/src/__tests__/appPlugin.test.js | 42 +++++++++------
.../__tests__/useBiometricCredentials.test.ts | 6 +--
.../useBiometricCredentials.shared.ts | 8 +--
.../expo/src/native/__tests__/index.test.ts | 8 +--
packages/expo/src/native/index.ts | 8 +--
.../expo/src/native/loadClerkExpoNative.ts | 4 +-
.../expo/src/provider/nativeClientSync.tsx | 2 +-
pnpm-lock.yaml | 6 +--
81 files changed, 142 insertions(+), 131 deletions(-)
rename packages/{expo-native => expo-native-components}/.gitignore (100%)
rename packages/{expo-native => expo-native-components}/LICENSE (100%)
rename packages/{expo-native => expo-native-components}/README.md (74%)
rename packages/{expo-native => expo-native-components}/android/build.gradle (96%)
rename packages/{expo-native => expo-native-components}/android/src/main/AndroidManifest.xml (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt (100%)
rename packages/{expo-native => expo-native-components}/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt (100%)
create mode 100644 packages/expo-native-components/app.plugin.d.ts
rename packages/{expo-native => expo-native-components}/app.plugin.js (96%)
rename packages/{expo-native => expo-native-components}/expo-module.config.json (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkAppDelegateSubscriber.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkAuthNativeView.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkExpo.podspec (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkExpoModule.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkNativeBridge.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkNativeViewHost.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkUserButtonNativeView.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/ClerkUserProfileNativeView.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/Tests/ClerkAuthNativeViewPaperTests.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/Tests/ClerkNativeBridgeTests.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/Tests/ClerkNativeViewHostTests.swift (100%)
rename packages/{expo-native => expo-native-components}/ios/Tests/ClerkUserProfileCustomPageStateTests.swift (100%)
rename packages/{expo-native => expo-native-components}/package.json (95%)
rename packages/{expo-native => expo-native-components}/react-native.config.js (100%)
rename packages/{expo-native => expo-native-components}/src/AuthView.tsx (92%)
rename packages/{expo-native => expo-native-components}/src/AuthView.types.ts (100%)
rename packages/{expo-native => expo-native-components}/src/EmbeddedNavigation.types.ts (100%)
rename packages/{expo-native => expo-native-components}/src/UserButton.tsx (98%)
rename packages/{expo-native => expo-native-components}/src/UserProfileCustomPages.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/UserProfileView.tsx (96%)
rename packages/{expo-native => expo-native-components}/src/__tests__/AuthView.test.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/__tests__/UserButton.test.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/__tests__/UserProfileCustomPages.test.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/__tests__/UserProfileView.test.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/__tests__/appPlugin.test.js (80%)
rename packages/{expo-native => expo-native-components}/src/__tests__/appPlugin.theme.test.js (100%)
rename packages/{expo-native => expo-native-components}/src/__tests__/useAuthViewState.test.tsx (100%)
rename packages/{expo-native => expo-native-components}/src/index.ts (87%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkAuthView.android.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkAuthView.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkModule.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkModule.types.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkModule.web.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkUserButtonView.android.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkUserButtonView.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkUserProfileView.android.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/NativeClerkUserProfileView.ts (100%)
rename packages/{expo-native => expo-native-components}/src/specs/__tests__/native-view.web.test.ts (100%)
rename packages/{expo-native => expo-native-components}/src/useAuthViewState.ts (100%)
rename packages/{expo-native => expo-native-components}/src/utils/native-module.ts (100%)
rename packages/{expo-native => expo-native-components}/tsconfig.declarations.json (100%)
rename packages/{expo-native => expo-native-components}/tsconfig.json (100%)
rename packages/{expo-native => expo-native-components}/tsconfig.test.json (100%)
rename packages/{expo-native => expo-native-components}/tsdown.config.mts (100%)
rename packages/{expo-native => expo-native-components}/vitest.config.mts (100%)
rename packages/{expo-native => expo-native-components}/vitest.setup.mts (100%)
delete mode 100644 packages/expo-native/app.plugin.d.ts
diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md
index 5f4a79a356e..6c3fd2fc364 100644
--- a/.changeset/expo-native-components-moved.md
+++ b/.changeset/expo-native-components-moved.md
@@ -2,12 +2,12 @@
'@clerk/expo': minor
---
-The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
+The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native-components` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
If you use the native components, install the new package:
```sh
-npx expo install @clerk/expo-native
+npx expo install @clerk/expo-native-components
```
add its config plugin alongside `@clerk/expo` in your app config:
@@ -15,11 +15,11 @@ add its config plugin alongside `@clerk/expo` in your app config:
```json
{
"expo": {
- "plugins": ["@clerk/expo", "@clerk/expo-native"]
+ "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
}
}
```
-then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native`, which you can also import from directly. Without `@clerk/expo-native` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it.
+then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native-components`, which you can also import from directly. Without `@clerk/expo-native-components` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it.
-The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native` is installed, and warns otherwise.
+The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native-components` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native-components` is installed, and warns otherwise.
diff --git a/.changeset/introduce-expo-native.md b/.changeset/introduce-expo-native.md
index 637d416fff9..a7a0839fb61 100644
--- a/.changeset/introduce-expo-native.md
+++ b/.changeset/introduce-expo-native.md
@@ -1,5 +1,5 @@
---
-'@clerk/expo-native': minor
+'@clerk/expo-native-components': minor
---
-Add `@clerk/expo-native`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options.
+Add `@clerk/expo-native-components`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options.
diff --git a/.claude/skills/clerk-monorepo/references/package-map.md b/.claude/skills/clerk-monorepo/references/package-map.md
index b86fb4e5a44..47abcecfa41 100644
--- a/.claude/skills/clerk-monorepo/references/package-map.md
+++ b/.claude/skills/clerk-monorepo/references/package-map.md
@@ -16,33 +16,33 @@ browser directly), **adapter** (framework SDK), **ui/i18n**, **tooling**. The "B
two packages whose runtime is pushed into apps pinned to older SDKs, so they carry the strict
backwards-compatibility contract (see `breaking-changes.md`).
-| Package | Category | BC | Purpose |
-| ----------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. |
-| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. |
-| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. |
-| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. |
-| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. |
-| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. |
-| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. |
-| `@clerk/express` | adapter | | Express middleware and server helpers. |
-| `@clerk/fastify` | adapter | | Fastify plugin. |
-| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). |
-| `@clerk/astro` | adapter | | Astro integration (components + server utilities). |
-| `@clerk/nuxt` | adapter | | Nuxt module (Vue). |
-| `@clerk/vue` | adapter | | Vue 3 composables and components. |
-| `@clerk/react-router` | adapter | | React Router v7 SDK. |
-| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. |
-| `@clerk/expo` | adapter | | React Native / Expo SDK. |
-| `@clerk/expo-native` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. |
-| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. |
-| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. |
-| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. |
-| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). |
-| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). |
-| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). |
-| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. |
-| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. |
+| Package | Category | BC | Purpose |
+| ------------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. |
+| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. |
+| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. |
+| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. |
+| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. |
+| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. |
+| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. |
+| `@clerk/express` | adapter | | Express middleware and server helpers. |
+| `@clerk/fastify` | adapter | | Fastify plugin. |
+| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). |
+| `@clerk/astro` | adapter | | Astro integration (components + server utilities). |
+| `@clerk/nuxt` | adapter | | Nuxt module (Vue). |
+| `@clerk/vue` | adapter | | Vue 3 composables and components. |
+| `@clerk/react-router` | adapter | | React Router v7 SDK. |
+| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. |
+| `@clerk/expo` | adapter | | React Native / Expo SDK. |
+| `@clerk/expo-native-components` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. |
+| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. |
+| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. |
+| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. |
+| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). |
+| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). |
+| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). |
+| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. |
+| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. |
Tests: `pnpm turbo test --filter=@clerk/` (or `pnpm --filter @clerk/ test` after a
build). Most packages use vitest; `@clerk/backend` runs a multi-runtime suite (node + edge +
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 675d48e803f..d07fbb87c3f 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -28,9 +28,9 @@ expo:
- changed-files:
- any-glob-to-any-file: packages/expo/**
-expo-native:
+expo-native-components:
- changed-files:
- - any-glob-to-any-file: packages/expo-native/**
+ - any-glob-to-any-file: packages/expo-native-components/**
express:
- changed-files:
diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml
index 4d7ca8eda83..9feadb28f55 100644
--- a/.github/workflows/api-changes.yml
+++ b/.github/workflows/api-changes.yml
@@ -19,7 +19,7 @@ on:
- 'packages/clerk-js/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
- - 'packages/expo-native/**'
+ - 'packages/expo-native-components/**'
- 'packages/expo-passkeys/**'
- 'packages/express/**'
- 'packages/fastify/**'
@@ -57,7 +57,7 @@ env:
--filter=@clerk/clerk-js
--filter=@clerk/expo
--filter=@clerk/expo-google-signin
- --filter=@clerk/expo-native
+ --filter=@clerk/expo-native-components
--filter=@clerk/expo-passkeys
--filter=@clerk/express
--filter=@clerk/fastify
diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml
index 375d3cc47d1..cd69db5c340 100644
--- a/.github/workflows/expo-native-build.yml
+++ b/.github/workflows/expo-native-build.yml
@@ -11,7 +11,7 @@ on:
- 'integration/tests/expo-native/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
- - 'packages/expo-native/**'
+ - 'packages/expo-native-components/**'
workflow_dispatch:
permissions:
@@ -86,7 +86,7 @@ jobs:
packages/clerk-js \
packages/expo \
packages/expo-google-signin \
- packages/expo-native \
+ packages/expo-native-components \
packages/react \
packages/shared \
"$FIXTURE_DIR" |
@@ -127,7 +127,7 @@ jobs:
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
- pnpm --filter @clerk/expo-native pack --pack-destination "$SDK_PACK_DIR"
+ pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR"
- name: Install fixture dependencies
if: steps.native-build-cache.outputs.cache-hit != 'true'
@@ -141,7 +141,7 @@ jobs:
# [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
- NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-*.tgz)"
+ NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)"
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
diff --git a/package.json b/package.json
index 7620564b4fd..3f4c74549d6 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"changeset": "changeset",
"changeset:empty": "pnpm changeset --empty",
"clean": "turbo run clean",
- "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
+ "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native-components --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
diff --git a/packages/expo-native/.gitignore b/packages/expo-native-components/.gitignore
similarity index 100%
rename from packages/expo-native/.gitignore
rename to packages/expo-native-components/.gitignore
diff --git a/packages/expo-native/LICENSE b/packages/expo-native-components/LICENSE
similarity index 100%
rename from packages/expo-native/LICENSE
rename to packages/expo-native-components/LICENSE
diff --git a/packages/expo-native/README.md b/packages/expo-native-components/README.md
similarity index 74%
rename from packages/expo-native/README.md
rename to packages/expo-native-components/README.md
index 35fe6587c9d..dceac483ebb 100644
--- a/packages/expo-native/README.md
+++ b/packages/expo-native-components/README.md
@@ -1,33 +1,33 @@
-
+
-
@clerk/expo-native
+ @clerk/expo-native-components
[](https://clerk.com/discord)
-[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native_components)
[](https://x.com/intent/follow?screen_name=clerk)
-[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native/CHANGELOG.md)
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/CHANGELOG.md)
·
[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
·
[Request a Feature](https://feedback.clerk.com/roadmap)
·
-[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native)
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components)
## Getting Started
-`@clerk/expo-native` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs:
+`@clerk/expo-native-components` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs:
- **iOS**: [clerk-ios](https://github.com/clerk/clerk-ios) (SwiftUI)
- **Android**: [clerk-android](https://github.com/clerk/clerk-android) (Jetpack Compose)
@@ -43,7 +43,7 @@ Install it only if you use `AuthView`, `UserProfileView`, `UserButton`, or biome
### Installation
```sh
-npx expo install @clerk/expo @clerk/expo-native
+npx expo install @clerk/expo @clerk/expo-native-components
```
Add the config plugin alongside `@clerk/expo` in your app config, then rebuild your native app:
@@ -51,7 +51,7 @@ Add the config plugin alongside `@clerk/expo` in your app config, then rebuild y
```json
{
"expo": {
- "plugins": ["@clerk/expo", "@clerk/expo-native"]
+ "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
}
}
```
@@ -65,7 +65,7 @@ The plugin accepts the following options:
```tsx
import { useAuth } from '@clerk/expo';
-import { AuthView } from '@clerk/expo-native';
+import { AuthView } from '@clerk/expo-native-components';
export default function SignInScreen() {
const { isSignedIn } = useAuth();
@@ -78,11 +78,11 @@ export default function SignInScreen() {
}
```
-For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native).
+For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native_components).
## Support
-For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native).
+For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components).
## Community
@@ -94,9 +94,9 @@ We're open to all community contributions! If you'd like to contribute in any wa
## Security
-`@clerk/expo-native` follows good practices of security, but 100% security cannot be assured.
+`@clerk/expo-native-components` follows good practices of security, but 100% security cannot be assured.
-`@clerk/expo-native` is provided **"as is"** without any **warranty**. Use at your own risk.
+`@clerk/expo-native-components` is provided **"as is"** without any **warranty**. Use at your own risk.
_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
@@ -104,4 +104,4 @@ _For more information and to report security issues, please refer to our [securi
This project is licensed under the **MIT license**.
-See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native/LICENSE) for more information.
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/LICENSE) for more information.
diff --git a/packages/expo-native/android/build.gradle b/packages/expo-native-components/android/build.gradle
similarity index 96%
rename from packages/expo-native/android/build.gradle
rename to packages/expo-native-components/android/build.gradle
index 18c81a17cbe..facf8a66952 100644
--- a/packages/expo-native/android/build.gradle
+++ b/packages/expo-native-components/android/build.gradle
@@ -13,7 +13,7 @@ applyKotlinExpoModulesCorePlugin()
group = 'com.clerk.expo'
version = '1.0.0'
-// Set by the @clerk/expo-native config plugin to the installed @clerk/expo version.
+// Set by the @clerk/expo-native-components config plugin to the installed @clerk/expo version.
def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toString() ?:
new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString()
@@ -98,7 +98,7 @@ try {
}
} catch (Exception ignored) {
// Future Gradle versions with Isolated Projects may block cross-project configuration.
- // In that case, users should add '@clerk/expo-native' to their app.json plugins array instead,
+ // In that case, users should add '@clerk/expo-native-components' to their app.json plugins array instead,
// which applies the flag via the config plugin's withClerkAndroid.
}
diff --git a/packages/expo-native/android/src/main/AndroidManifest.xml b/packages/expo-native-components/android/src/main/AndroidManifest.xml
similarity index 100%
rename from packages/expo-native/android/src/main/AndroidManifest.xml
rename to packages/expo-native-components/android/src/main/AndroidManifest.xml
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
diff --git a/packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
similarity index 100%
rename from packages/expo-native/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
diff --git a/packages/expo-native/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
similarity index 100%
rename from packages/expo-native/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
diff --git a/packages/expo-native/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
similarity index 100%
rename from packages/expo-native/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
diff --git a/packages/expo-native-components/app.plugin.d.ts b/packages/expo-native-components/app.plugin.d.ts
new file mode 100644
index 00000000000..8808449dd73
--- /dev/null
+++ b/packages/expo-native-components/app.plugin.d.ts
@@ -0,0 +1,5 @@
+import type { ConfigPlugin } from '@expo/config-plugins';
+
+declare const withClerkExpoNativeComponents: ConfigPlugin<{ keychainService?: string; theme?: string } | void>;
+
+export = withClerkExpoNativeComponents;
diff --git a/packages/expo-native/app.plugin.js b/packages/expo-native-components/app.plugin.js
similarity index 96%
rename from packages/expo-native/app.plugin.js
rename to packages/expo-native-components/app.plugin.js
index 98a22f4b09d..36e10092b7a 100644
--- a/packages/expo-native/app.plugin.js
+++ b/packages/expo-native-components/app.plugin.js
@@ -1,5 +1,5 @@
/**
- * Expo config plugin for @clerk/expo-native
+ * Expo config plugin for @clerk/expo-native-components
* Configures iOS and Android for the Clerk native SDKs (clerk-ios / clerk-android)
*
* When this plugin is used:
@@ -306,11 +306,11 @@ const withClerkTheme = (config, props = {}) => {
return config;
};
-const withClerkExpoNative = (config, props = {}, resolve = resolveClerkExpo) => {
+const withClerkExpoNativeComponents = (config, props = {}, resolve = resolveClerkExpo) => {
const clerkExpo = resolve(config._internal?.projectRoot);
if (clerkExpo && fs.existsSync(path.join(clerkExpo.dir, 'expo-module.config.json'))) {
throw new Error(
- `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native. Upgrade @clerk/expo to a version that supports @clerk/expo-native.`,
+ `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native-components. Upgrade @clerk/expo to a version that supports @clerk/expo-native-components.`,
);
}
// Native requests report the @clerk/expo version in the x-clerk-host-sdk-version header.
@@ -323,9 +323,9 @@ const withClerkExpoNative = (config, props = {}, resolve = resolveClerkExpo) =>
return config;
};
-module.exports = createRunOncePlugin(withClerkExpoNative, packageJson.name, packageJson.version);
+module.exports = createRunOncePlugin(withClerkExpoNativeComponents, packageJson.name, packageJson.version);
module.exports._testing = {
- withClerkExpoNative,
+ withClerkExpoNativeComponents,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
diff --git a/packages/expo-native/expo-module.config.json b/packages/expo-native-components/expo-module.config.json
similarity index 100%
rename from packages/expo-native/expo-module.config.json
rename to packages/expo-native-components/expo-module.config.json
diff --git a/packages/expo-native/ios/ClerkAppDelegateSubscriber.swift b/packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkAppDelegateSubscriber.swift
rename to packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift
diff --git a/packages/expo-native/ios/ClerkAuthNativeView.swift b/packages/expo-native-components/ios/ClerkAuthNativeView.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkAuthNativeView.swift
rename to packages/expo-native-components/ios/ClerkAuthNativeView.swift
diff --git a/packages/expo-native/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec
similarity index 100%
rename from packages/expo-native/ios/ClerkExpo.podspec
rename to packages/expo-native-components/ios/ClerkExpo.podspec
diff --git a/packages/expo-native/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkExpoModule.swift
rename to packages/expo-native-components/ios/ClerkExpoModule.swift
diff --git a/packages/expo-native/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkNativeBridge.swift
rename to packages/expo-native-components/ios/ClerkNativeBridge.swift
diff --git a/packages/expo-native/ios/ClerkNativeViewHost.swift b/packages/expo-native-components/ios/ClerkNativeViewHost.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkNativeViewHost.swift
rename to packages/expo-native-components/ios/ClerkNativeViewHost.swift
diff --git a/packages/expo-native/ios/ClerkUserButtonNativeView.swift b/packages/expo-native-components/ios/ClerkUserButtonNativeView.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkUserButtonNativeView.swift
rename to packages/expo-native-components/ios/ClerkUserButtonNativeView.swift
diff --git a/packages/expo-native/ios/ClerkUserProfileNativeView.swift b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift
similarity index 100%
rename from packages/expo-native/ios/ClerkUserProfileNativeView.swift
rename to packages/expo-native-components/ios/ClerkUserProfileNativeView.swift
diff --git a/packages/expo-native/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift
similarity index 100%
rename from packages/expo-native/ios/Tests/ClerkAuthNativeViewPaperTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift
diff --git a/packages/expo-native/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift
similarity index 100%
rename from packages/expo-native/ios/Tests/ClerkNativeBridgeTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift
diff --git a/packages/expo-native/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift
similarity index 100%
rename from packages/expo-native/ios/Tests/ClerkNativeViewHostTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift
diff --git a/packages/expo-native/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
similarity index 100%
rename from packages/expo-native/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
diff --git a/packages/expo-native/package.json b/packages/expo-native-components/package.json
similarity index 95%
rename from packages/expo-native/package.json
rename to packages/expo-native-components/package.json
index f732b7297da..0d6f430f012 100644
--- a/packages/expo-native/package.json
+++ b/packages/expo-native-components/package.json
@@ -1,5 +1,5 @@
{
- "name": "@clerk/expo-native",
+ "name": "@clerk/expo-native-components",
"version": "0.0.1",
"description": "Native Clerk components and native SDK integration for Expo, powered by clerk-ios and clerk-android",
"keywords": [
@@ -18,7 +18,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/clerk/javascript.git",
- "directory": "packages/expo-native"
+ "directory": "packages/expo-native-components"
},
"license": "MIT",
"author": "Clerk",
diff --git a/packages/expo-native/react-native.config.js b/packages/expo-native-components/react-native.config.js
similarity index 100%
rename from packages/expo-native/react-native.config.js
rename to packages/expo-native-components/react-native.config.js
diff --git a/packages/expo-native/src/AuthView.tsx b/packages/expo-native-components/src/AuthView.tsx
similarity index 92%
rename from packages/expo-native/src/AuthView.tsx
rename to packages/expo-native-components/src/AuthView.tsx
index 7734de4dd86..99e1d395348 100644
--- a/packages/expo-native/src/AuthView.tsx
+++ b/packages/expo-native-components/src/AuthView.tsx
@@ -24,7 +24,7 @@ type AuthNativeEvent = NativeSyntheticEvent>;
*
* @example
* ```tsx
- * import { AuthView } from '@clerk/expo-native';
+ * import { AuthView } from '@clerk/expo-native-components';
* import { useAuth } from '@clerk/expo';
*
* export default function SignInScreen() {
@@ -63,7 +63,7 @@ export function AuthView({
{!isNativeSupported
? 'Native AuthView is only available on iOS and Android'
- : 'Native AuthView requires a development build with the @clerk/expo-native config plugin. Add "@clerk/expo-native" to your app.json plugins array and rebuild your native app.'}
+ : 'Native AuthView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo-native/src/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts
similarity index 100%
rename from packages/expo-native/src/AuthView.types.ts
rename to packages/expo-native-components/src/AuthView.types.ts
diff --git a/packages/expo-native/src/EmbeddedNavigation.types.ts b/packages/expo-native-components/src/EmbeddedNavigation.types.ts
similarity index 100%
rename from packages/expo-native/src/EmbeddedNavigation.types.ts
rename to packages/expo-native-components/src/EmbeddedNavigation.types.ts
diff --git a/packages/expo-native/src/UserButton.tsx b/packages/expo-native-components/src/UserButton.tsx
similarity index 98%
rename from packages/expo-native/src/UserButton.tsx
rename to packages/expo-native-components/src/UserButton.tsx
index f3038fd4efc..fc2504781b2 100644
--- a/packages/expo-native/src/UserButton.tsx
+++ b/packages/expo-native-components/src/UserButton.tsx
@@ -47,7 +47,7 @@ export interface UserButtonProps {
*
* @example
* ```tsx
- * import { UserButton } from '@clerk/expo-native';
+ * import { UserButton } from '@clerk/expo-native-components';
*
* export default function Home() {
* return (
diff --git a/packages/expo-native/src/UserProfileCustomPages.tsx b/packages/expo-native-components/src/UserProfileCustomPages.tsx
similarity index 100%
rename from packages/expo-native/src/UserProfileCustomPages.tsx
rename to packages/expo-native-components/src/UserProfileCustomPages.tsx
diff --git a/packages/expo-native/src/UserProfileView.tsx b/packages/expo-native-components/src/UserProfileView.tsx
similarity index 96%
rename from packages/expo-native/src/UserProfileView.tsx
rename to packages/expo-native-components/src/UserProfileView.tsx
index 32aa4b5a6a9..eb3e59bee96 100644
--- a/packages/expo-native/src/UserProfileView.tsx
+++ b/packages/expo-native-components/src/UserProfileView.tsx
@@ -75,7 +75,7 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps {
*
* @example
* ```tsx
- * import { UserProfileView } from '@clerk/expo-native';
+ * import { UserProfileView } from '@clerk/expo-native-components';
* import { useAuth } from '@clerk/expo';
*
* export default function ProfileScreen() {
@@ -128,7 +128,7 @@ export function UserProfileView({
{!isNativeSupported
? 'Native UserProfileView is only available on iOS and Android'
- : 'Native UserProfileView requires a development build with the @clerk/expo-native config plugin. Add "@clerk/expo-native" to your app.json plugins array and rebuild your native app.'}
+ : 'Native UserProfileView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo-native/src/__tests__/AuthView.test.tsx b/packages/expo-native-components/src/__tests__/AuthView.test.tsx
similarity index 100%
rename from packages/expo-native/src/__tests__/AuthView.test.tsx
rename to packages/expo-native-components/src/__tests__/AuthView.test.tsx
diff --git a/packages/expo-native/src/__tests__/UserButton.test.tsx b/packages/expo-native-components/src/__tests__/UserButton.test.tsx
similarity index 100%
rename from packages/expo-native/src/__tests__/UserButton.test.tsx
rename to packages/expo-native-components/src/__tests__/UserButton.test.tsx
diff --git a/packages/expo-native/src/__tests__/UserProfileCustomPages.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx
similarity index 100%
rename from packages/expo-native/src/__tests__/UserProfileCustomPages.test.tsx
rename to packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx
diff --git a/packages/expo-native/src/__tests__/UserProfileView.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx
similarity index 100%
rename from packages/expo-native/src/__tests__/UserProfileView.test.tsx
rename to packages/expo-native-components/src/__tests__/UserProfileView.test.tsx
diff --git a/packages/expo-native/src/__tests__/appPlugin.test.js b/packages/expo-native-components/src/__tests__/appPlugin.test.js
similarity index 80%
rename from packages/expo-native/src/__tests__/appPlugin.test.js
rename to packages/expo-native-components/src/__tests__/appPlugin.test.js
index bb611cb434b..bbb2d247250 100644
--- a/packages/expo-native/src/__tests__/appPlugin.test.js
+++ b/packages/expo-native-components/src/__tests__/appPlugin.test.js
@@ -5,12 +5,12 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
-const { withClerkExpoNative } = require('../../app.plugin.js')._testing;
+const { withClerkExpoNativeComponents } = require('../../app.plugin.js')._testing;
const applyMod = (config, platform, mod, modResults) =>
config.mods[platform][mod]({ ...config, modRequest: {}, modResults });
-describe('withClerkExpoNative', () => {
+describe('withClerkExpoNativeComponents', () => {
let clerkExpoDir;
beforeEach(() => {
@@ -24,7 +24,7 @@ describe('withClerkExpoNative', () => {
});
test('reports the installed @clerk/expo version on iOS and Android', async () => {
- const config = withClerkExpoNative({ name: 'test', slug: 'test' }, {}, () => ({
+ const config = withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({
dir: clerkExpoDir,
version: '4.8.0',
}));
@@ -44,7 +44,10 @@ describe('withClerkExpoNative', () => {
fs.writeFileSync(path.join(clerkExpoDir, 'expo-module.config.json'), '{}');
expect(() =>
- withClerkExpoNative({ name: 'test', slug: 'test' }, {}, () => ({ dir: clerkExpoDir, version: '4.7.1' })),
+ withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({
+ dir: clerkExpoDir,
+ version: '4.7.1',
+ })),
).toThrow('@clerk/expo@4.7.1 still bundles the Clerk native module');
});
});
diff --git a/packages/expo-native/src/__tests__/appPlugin.theme.test.js b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js
similarity index 100%
rename from packages/expo-native/src/__tests__/appPlugin.theme.test.js
rename to packages/expo-native-components/src/__tests__/appPlugin.theme.test.js
diff --git a/packages/expo-native/src/__tests__/useAuthViewState.test.tsx b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx
similarity index 100%
rename from packages/expo-native/src/__tests__/useAuthViewState.test.tsx
rename to packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx
diff --git a/packages/expo-native/src/index.ts b/packages/expo-native-components/src/index.ts
similarity index 87%
rename from packages/expo-native/src/index.ts
rename to packages/expo-native-components/src/index.ts
index a20538f6186..c102a43da33 100644
--- a/packages/expo-native/src/index.ts
+++ b/packages/expo-native-components/src/index.ts
@@ -7,12 +7,12 @@
*
* ## Installation
*
- * Native components require the `@clerk/expo-native` config plugin in your `app.json`, alongside `@clerk/expo`:
+ * Native components require the `@clerk/expo-native-components` config plugin in your `app.json`, alongside `@clerk/expo`:
*
* ```json
* {
* "expo": {
- * "plugins": ["@clerk/expo", "@clerk/expo-native"]
+ * "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
* }
* }
* ```
@@ -25,7 +25,7 @@
* - {@link UserProfileView} - User profile and account management, renders inline
* - {@link UserButton} - Avatar button that opens the native user profile
*
- * @module @clerk/expo-native
+ * @module @clerk/expo-native-components
*/
export { AuthView } from './AuthView';
diff --git a/packages/expo-native/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkAuthView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts
diff --git a/packages/expo-native/src/specs/NativeClerkAuthView.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkAuthView.ts
rename to packages/expo-native-components/src/specs/NativeClerkAuthView.ts
diff --git a/packages/expo-native/src/specs/NativeClerkModule.ts b/packages/expo-native-components/src/specs/NativeClerkModule.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkModule.ts
rename to packages/expo-native-components/src/specs/NativeClerkModule.ts
diff --git a/packages/expo-native/src/specs/NativeClerkModule.types.ts b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkModule.types.ts
rename to packages/expo-native-components/src/specs/NativeClerkModule.types.ts
diff --git a/packages/expo-native/src/specs/NativeClerkModule.web.ts b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkModule.web.ts
rename to packages/expo-native-components/src/specs/NativeClerkModule.web.ts
diff --git a/packages/expo-native/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkUserButtonView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts
diff --git a/packages/expo-native/src/specs/NativeClerkUserButtonView.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkUserButtonView.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts
diff --git a/packages/expo-native/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkUserProfileView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts
diff --git a/packages/expo-native/src/specs/NativeClerkUserProfileView.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts
similarity index 100%
rename from packages/expo-native/src/specs/NativeClerkUserProfileView.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts
diff --git a/packages/expo-native/src/specs/__tests__/native-view.web.test.ts b/packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts
similarity index 100%
rename from packages/expo-native/src/specs/__tests__/native-view.web.test.ts
rename to packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts
diff --git a/packages/expo-native/src/useAuthViewState.ts b/packages/expo-native-components/src/useAuthViewState.ts
similarity index 100%
rename from packages/expo-native/src/useAuthViewState.ts
rename to packages/expo-native-components/src/useAuthViewState.ts
diff --git a/packages/expo-native/src/utils/native-module.ts b/packages/expo-native-components/src/utils/native-module.ts
similarity index 100%
rename from packages/expo-native/src/utils/native-module.ts
rename to packages/expo-native-components/src/utils/native-module.ts
diff --git a/packages/expo-native/tsconfig.declarations.json b/packages/expo-native-components/tsconfig.declarations.json
similarity index 100%
rename from packages/expo-native/tsconfig.declarations.json
rename to packages/expo-native-components/tsconfig.declarations.json
diff --git a/packages/expo-native/tsconfig.json b/packages/expo-native-components/tsconfig.json
similarity index 100%
rename from packages/expo-native/tsconfig.json
rename to packages/expo-native-components/tsconfig.json
diff --git a/packages/expo-native/tsconfig.test.json b/packages/expo-native-components/tsconfig.test.json
similarity index 100%
rename from packages/expo-native/tsconfig.test.json
rename to packages/expo-native-components/tsconfig.test.json
diff --git a/packages/expo-native/tsdown.config.mts b/packages/expo-native-components/tsdown.config.mts
similarity index 100%
rename from packages/expo-native/tsdown.config.mts
rename to packages/expo-native-components/tsdown.config.mts
diff --git a/packages/expo-native/vitest.config.mts b/packages/expo-native-components/vitest.config.mts
similarity index 100%
rename from packages/expo-native/vitest.config.mts
rename to packages/expo-native-components/vitest.config.mts
diff --git a/packages/expo-native/vitest.setup.mts b/packages/expo-native-components/vitest.setup.mts
similarity index 100%
rename from packages/expo-native/vitest.setup.mts
rename to packages/expo-native-components/vitest.setup.mts
diff --git a/packages/expo-native/app.plugin.d.ts b/packages/expo-native/app.plugin.d.ts
deleted file mode 100644
index 76cfe1c1acd..00000000000
--- a/packages/expo-native/app.plugin.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { ConfigPlugin } from '@expo/config-plugins';
-
-declare const withClerkExpoNative: ConfigPlugin<{ keychainService?: string; theme?: string } | void>;
-
-export = withClerkExpoNative;
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index 29c276c2067..ecba866b513 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -4,11 +4,11 @@
* When this plugin is used:
* 1. Android registers the hosted auth callback intent filter
* 2. iOS gets the Sign in with Apple entitlement and, when configured, the Face ID usage description
- * 3. If @clerk/expo-native is installed, its config plugin is applied for the Clerk native SDKs
+ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs
*/
const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins');
-const CLERK_EXPO_NATIVE = '@clerk/expo-native';
+const CLERK_EXPO_NATIVE = '@clerk/expo-native-components';
const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme'];
const addHostedAuthIntentFilter = (mainActivity, packageName) => {
@@ -104,10 +104,10 @@ const getListedPluginProps = (config, name) => {
};
/**
- * Apply the @clerk/expo-native config plugin when it is installed, so apps that only list
+ * Apply the @clerk/expo-native-components config plugin when it is installed, so apps that only list
* "@clerk/expo" keep the iOS deployment target and native SDK configuration they need.
*/
-const withClerkExpoNative = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => {
+const withClerkExpoNativeComponents = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => {
const nativeProps = Object.fromEntries(
CLERK_EXPO_NATIVE_OPTIONS.filter(option => props[option] !== undefined).map(option => [option, props[option]]),
);
@@ -144,13 +144,13 @@ const withClerkExpo = (config, props = {}) => {
}
config = withClerkHostedAuthCallback(config);
config = withClerkFaceIDPermission(config, props);
- config = withClerkExpoNative(config, props);
+ config = withClerkExpoNativeComponents(config, props);
return config;
};
module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
- withClerkExpoNative,
+ withClerkExpoNativeComponents,
withClerkFaceIDPermission,
};
diff --git a/packages/expo/native/index.d.ts b/packages/expo/native/index.d.ts
index 0c04017d415..9d8fb781ea9 100644
--- a/packages/expo/native/index.d.ts
+++ b/packages/expo/native/index.d.ts
@@ -1 +1 @@
-export * from '@clerk/expo-native';
+export * from '@clerk/expo-native-components';
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 46deb108a47..095cc4ddba5 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -122,7 +122,7 @@
},
"devDependencies": {
"@clerk/expo-google-signin": "workspace:*",
- "@clerk/expo-native": "workspace:*",
+ "@clerk/expo-native-components": "workspace:*",
"@clerk/expo-passkeys": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
@@ -138,7 +138,7 @@
},
"peerDependencies": {
"@clerk/expo-google-signin": ">=0.1.0",
- "@clerk/expo-native": ">=0.1.0",
+ "@clerk/expo-native-components": ">=0.1.0",
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
@@ -156,7 +156,7 @@
"@clerk/expo-google-signin": {
"optional": true
},
- "@clerk/expo-native": {
+ "@clerk/expo-native-components": {
"optional": true
},
"@clerk/expo-passkeys": {
diff --git a/packages/expo/src/__tests__/appPlugin.test.js b/packages/expo/src/__tests__/appPlugin.test.js
index 78e46629533..55f9e5abdc3 100644
--- a/packages/expo/src/__tests__/appPlugin.test.js
+++ b/packages/expo/src/__tests__/appPlugin.test.js
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
-const { withClerkExpoNative, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing;
+const { withClerkExpoNativeComponents, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing;
function applyInfoPlistMod(config, modResults) {
return config.mods.ios.infoPlist({
@@ -50,16 +50,16 @@ describe('withClerkFaceIDPermission', () => {
});
});
-describe('withClerkExpoNative', () => {
+describe('withClerkExpoNativeComponents', () => {
afterEach(() => {
vi.restoreAllMocks();
});
- test('applies the @clerk/expo-native plugin with its options when it is installed', () => {
+ test('applies the @clerk/expo-native-components plugin with its options when it is installed', () => {
const nativePlugin = vi.fn(config => ({ ...config, applied: true }));
const config = { name: 'test', slug: 'test' };
- const result = withClerkExpoNative(
+ const result = withClerkExpoNativeComponents(
config,
{ keychainService: 'com.example.shared', theme: './theme.json', appleSignIn: false },
() => nativePlugin,
@@ -69,15 +69,19 @@ describe('withClerkExpoNative', () => {
expect(nativePlugin).toHaveBeenCalledWith(config, { keychainService: 'com.example.shared', theme: './theme.json' });
});
- test('prefers options passed to an explicitly listed @clerk/expo-native plugin', () => {
+ test('prefers options passed to an explicitly listed @clerk/expo-native-components plugin', () => {
const nativePlugin = vi.fn(config => config);
const config = {
name: 'test',
slug: 'test',
- plugins: ['@clerk/expo', ['@clerk/expo-native', { theme: './native-theme.json' }]],
+ plugins: ['@clerk/expo', ['@clerk/expo-native-components', { theme: './native-theme.json' }]],
};
- withClerkExpoNative(config, { keychainService: 'com.example.shared', theme: './theme.json' }, () => nativePlugin);
+ withClerkExpoNativeComponents(
+ config,
+ { keychainService: 'com.example.shared', theme: './theme.json' },
+ () => nativePlugin,
+ );
expect(nativePlugin).toHaveBeenCalledWith(config, {
keychainService: 'com.example.shared',
@@ -85,36 +89,40 @@ describe('withClerkExpoNative', () => {
});
});
- test('does not apply the @clerk/expo-native plugin twice', () => {
+ test('does not apply the @clerk/expo-native-components plugin twice', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const nativePlugin = vi.fn(config => config);
const config = {
name: 'test',
slug: 'test',
- _internal: { pluginHistory: { '@clerk/expo-native': { name: '@clerk/expo-native', version: '0.0.1' } } },
+ _internal: {
+ pluginHistory: { '@clerk/expo-native-components': { name: '@clerk/expo-native-components', version: '0.0.1' } },
+ },
};
- expect(withClerkExpoNative(config, {}, () => nativePlugin)).toBe(config);
+ expect(withClerkExpoNativeComponents(config, {}, () => nativePlugin)).toBe(config);
expect(nativePlugin).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
- withClerkExpoNative(config, { theme: './theme.json' }, () => nativePlugin);
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('Pass them to the "@clerk/expo-native" plugin instead'));
+ withClerkExpoNativeComponents(config, { theme: './theme.json' }, () => nativePlugin);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Pass them to the "@clerk/expo-native-components" plugin instead'),
+ );
});
- test('leaves the config untouched when @clerk/expo-native is not installed', () => {
+ test('leaves the config untouched when @clerk/expo-native-components is not installed', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = { name: 'test', slug: 'test' };
- expect(withClerkExpoNative(config, {}, () => null)).toBe(config);
+ expect(withClerkExpoNativeComponents(config, {}, () => null)).toBe(config);
expect(warn).not.toHaveBeenCalled();
});
- test('warns with install instructions when native-only options are passed without @clerk/expo-native', () => {
+ test('warns with install instructions when native-only options are passed without @clerk/expo-native-components', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = { name: 'test', slug: 'test' };
- expect(withClerkExpoNative(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config);
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native'));
+ expect(withClerkExpoNativeComponents(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native-components'));
});
});
diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
index 1b7a7c46c5d..0c15d195c86 100644
--- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
+++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
@@ -542,12 +542,12 @@ describe('useBiometricCredentials on iOS', () => {
});
});
- test('explains how to install @clerk/expo-native when the native module is missing', async () => {
+ test('explains how to install @clerk/expo-native-components when the native module is missing', async () => {
mocks.isNativeModuleInstalled = false;
try {
await expect(renderBiometricCredentials().signIn()).rejects.toThrow(
- 'Biometric credentials require the @clerk/expo-native package in a development build. Install it with `npx expo install @clerk/expo-native`',
+ 'Biometric credentials require the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`',
);
} finally {
mocks.isNativeModuleInstalled = true;
@@ -560,7 +560,7 @@ describe('useBiometricCredentials on iOS', () => {
try {
await expect(renderBiometricCredentials().signIn()).rejects.toThrow(
- 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native.',
+ 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.',
);
} finally {
Object.assign(mocks.nativeModule, { signInWithTrustedDevice });
diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
index 49f611363f1..65af8878503 100644
--- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
+++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
@@ -15,7 +15,7 @@ import type {
const DEFAULT_POLICY = 'biometry_current_set';
const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS =
- 'Install it with `npx expo install @clerk/expo-native`, add "@clerk/expo-native" to the plugins array in your app config, then rebuild your development build.';
+ 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.';
function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform {
return platform === 'ios' || platform === 'android' ? platform : 'unknown';
@@ -30,7 +30,7 @@ function getNativeModule(): NativeBiometricCredentialModule {
if (!nativeModule) {
return errorThrower.throw(
- `Biometric credentials require the @clerk/expo-native package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`,
+ `Biometric credentials require the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`,
);
}
@@ -42,7 +42,7 @@ function getNativeModule(): NativeBiometricCredentialModule {
!nativeModule.signInWithTrustedDevice
) {
return errorThrower.throw(
- 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native.',
+ 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.',
);
}
@@ -95,7 +95,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
const nativeModule = getNativeModule();
if (typeof nativeModule.reverifyWithBiometrics !== 'function') {
return errorThrower.throw(
- 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native.',
+ 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.',
);
}
const level = params?.level ?? 'first_factor';
diff --git a/packages/expo/src/native/__tests__/index.test.ts b/packages/expo/src/native/__tests__/index.test.ts
index 6d936310043..f7904bb2a8b 100644
--- a/packages/expo/src/native/__tests__/index.test.ts
+++ b/packages/expo/src/native/__tests__/index.test.ts
@@ -9,7 +9,7 @@ describe('@clerk/expo/native', () => {
vi.restoreAllMocks();
});
- describe('when @clerk/expo-native is not installed', () => {
+ describe('when @clerk/expo-native-components is not installed', () => {
beforeEach(() => {
vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => null }));
});
@@ -20,7 +20,7 @@ describe('@clerk/expo/native', () => {
const Component = (await importNativeEntry())[name] as (props: object) => unknown;
expect(() => Component({})).toThrow(
- /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native package\. Install it with `npx expo install @clerk\/expo-native`/,
+ /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native-components package\. Install it with `npx expo install @clerk\/expo-native-components`/,
);
},
);
@@ -31,12 +31,12 @@ describe('@clerk/expo/native', () => {
const hook = (await importNativeEntry())[name] as () => unknown;
expect(() => hook()).toThrow(`\`${name}\` is unavailable.`);
- expect(() => hook()).toThrow('add "@clerk/expo-native" to the plugins array in your app config');
+ expect(() => hook()).toThrow('add "@clerk/expo-native-components" to the plugins array in your app config');
},
);
});
- test('re-exports @clerk/expo-native when it is installed', async () => {
+ test('re-exports @clerk/expo-native-components when it is installed', async () => {
const clerkExpoNative = {
AuthView: vi.fn(() => null),
UserButton: vi.fn(() => null),
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
index 34cc5902993..67acaa9ab68 100644
--- a/packages/expo/src/native/index.ts
+++ b/packages/expo/src/native/index.ts
@@ -2,12 +2,12 @@ import { errorThrower } from '../errorThrower';
import type { ClerkExpoNativeModule } from './loadClerkExpoNative';
import { loadClerkExpoNative } from './loadClerkExpoNative';
-// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native.
+// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native-components.
const CLERK_EXPO_NATIVE_MISSING_MESSAGE =
- 'Native components have moved to the @clerk/expo-native package. ' +
- 'Install it with `npx expo install @clerk/expo-native`, add "@clerk/expo-native" to the plugins array in your app config, ' +
- 'then rebuild your native app. You can then import them from "@clerk/expo-native".';
+ 'Native components have moved to the @clerk/expo-native-components package. ' +
+ 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, ' +
+ 'then rebuild your native app. You can then import them from "@clerk/expo-native-components".';
const clerkExpoNative = loadClerkExpoNative();
diff --git a/packages/expo/src/native/loadClerkExpoNative.ts b/packages/expo/src/native/loadClerkExpoNative.ts
index 06f8e66b9dc..194b0bed1d9 100644
--- a/packages/expo/src/native/loadClerkExpoNative.ts
+++ b/packages/expo/src/native/loadClerkExpoNative.ts
@@ -9,9 +9,9 @@ export type ClerkExpoNativeModule = Partial<
export function loadClerkExpoNative(): ClerkExpoNativeModule | null {
try {
- // Synchronous require() in try/catch so Metro treats @clerk/expo-native as an optional dependency.
+ // Synchronous require() in try/catch so Metro treats @clerk/expo-native-components as an optional dependency.
// eslint-disable-next-line @typescript-eslint/no-require-imports
- return require('@clerk/expo-native') as ClerkExpoNativeModule;
+ return require('@clerk/expo-native-components') as ClerkExpoNativeModule;
} catch {
return null;
}
diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx
index 99adbcd6e38..5cdb87dc6ae 100644
--- a/packages/expo/src/provider/nativeClientSync.tsx
+++ b/packages/expo/src/provider/nativeClientSync.tsx
@@ -1163,7 +1163,7 @@ export function useNativeClientBootstrap({
if (__DEV__) {
console.debug(
`[ClerkProvider] Native Clerk module not available. ` +
- `To enable native features, install @clerk/expo-native and add "@clerk/expo-native" to your app.json plugins array.`,
+ `To enable native features, install @clerk/expo-native-components and add "@clerk/expo-native-components" to your app.json plugins array.`,
);
}
} else if (__DEV__) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b78f04fdd7..d37b3a454fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -662,9 +662,9 @@ importers:
'@clerk/expo-google-signin':
specifier: workspace:*
version: link:../expo-google-signin
- '@clerk/expo-native':
+ '@clerk/expo-native-components':
specifier: workspace:*
- version: link:../expo-native
+ version: link:../expo-native-components
'@clerk/expo-passkeys':
specifier: workspace:*
version: link:../expo-passkeys
@@ -711,7 +711,7 @@ importers:
specifier: ~54.0.36
version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
- packages/expo-native:
+ packages/expo-native-components:
dependencies:
'@clerk/react':
specifier: workspace:^
From c22bd378d1a305855b7fb888c2bacb3d86237efa Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:46:32 -0400
Subject: [PATCH 09/15] feat(expo-native): add single-token client sync bridge
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../expo-native-single-token-sync-bridge.md | 5 +
.../modules/clerk/ClerkClientInvalidation.kt | 87 +++++++++
.../expo/modules/clerk/ClerkExpoModule.kt | 165 +++++++++++++++++-
.../clerk/ClerkClientInvalidationTest.kt | 127 ++++++++++++++
.../ios/ClerkClientInvalidation.swift | 105 +++++++++++
.../ios/ClerkExpo.podspec | 5 +-
.../ios/ClerkExpoModule.swift | 62 ++++++-
.../ios/ClerkNativeBridge.swift | 161 +++++++++++++++--
.../Tests/ClerkClientInvalidationTests.swift | 143 +++++++++++++++
.../src/specs/NativeClerkModule.android.ts | 8 +-
packages/expo/src/specs/NativeClerkModule.ts | 8 +-
.../expo/src/specs/NativeClerkModule.types.ts | 11 ++
packages/expo/src/utils/native-module.ts | 8 +-
13 files changed, 871 insertions(+), 24 deletions(-)
create mode 100644 .changeset/expo-native-single-token-sync-bridge.md
create mode 100644 packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt
create mode 100644 packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt
create mode 100644 packages/expo-native-components/ios/ClerkClientInvalidation.swift
create mode 100644 packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift
diff --git a/.changeset/expo-native-single-token-sync-bridge.md b/.changeset/expo-native-single-token-sync-bridge.md
new file mode 100644
index 00000000000..e5749427457
--- /dev/null
+++ b/.changeset/expo-native-single-token-sync-bridge.md
@@ -0,0 +1,5 @@
+---
+'@clerk/expo-native': minor
+---
+
+Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it.
diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt
new file mode 100644
index 00000000000..8903f4424c8
--- /dev/null
+++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt
@@ -0,0 +1,87 @@
+package expo.modules.clerk
+
+import com.clerk.api.network.model.client.Client
+
+/** The native client state that JS must refetch its own client for when it changes. */
+internal data class ClerkClientFingerprint(
+ val clientId: String?,
+ val lastActiveSessionId: String?,
+ val sessions: List,
+ val activeUserId: String?,
+ val activeUserUpdatedAt: Long?,
+ val deviceToken: String?
+) {
+ data class SessionState(val id: String, val status: String)
+
+ companion object {
+ fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint {
+ val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user
+ return ClerkClientFingerprint(
+ clientId = client?.id,
+ lastActiveSessionId = client?.lastActiveSessionId,
+ sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(),
+ activeUserId = activeUser?.id,
+ activeUserUpdatedAt = activeUser?.updatedAt,
+ deviceToken = deviceToken
+ )
+ }
+ }
+}
+
+/**
+ * Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from
+ * the last state JS was told about. Not thread-safe: call it from the main thread only.
+ */
+internal class ClerkClientInvalidationTracker(
+ private val schedule: (() -> Unit) -> Unit,
+ private val emit: () -> Unit
+) {
+ private var baseline: ClerkClientFingerprint? = null
+ private var latest: ClerkClientFingerprint? = null
+ private var isFlushScheduled = false
+
+ /** Sets the state JS already knows about without emitting. */
+ fun reset(fingerprint: ClerkClientFingerprint?) {
+ baseline = fingerprint
+ latest = fingerprint
+ }
+
+ fun observe(fingerprint: ClerkClientFingerprint) {
+ if (baseline == null) return
+ latest = fingerprint
+ if (fingerprint == baseline || isFlushScheduled) return
+ isFlushScheduled = true
+ schedule(::flush)
+ }
+
+ /** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */
+ fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) {
+ baseline = baseline?.copy(deviceToken = token)
+ observe(current)
+ }
+
+ private fun flush() {
+ isFlushScheduled = false
+ val current = latest ?: return
+ if (current == baseline) return
+ baseline = current
+ emit()
+ }
+}
+
+internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String)
+
+internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) {
+ is IllegalArgumentException -> ClerkDeviceTokenBridgeError(
+ "E_INVALID_DEVICE_TOKEN",
+ throwable.message ?: "Device token must not be blank"
+ )
+ is IllegalStateException -> ClerkDeviceTokenBridgeError(
+ "E_NOT_CONFIGURED",
+ throwable.message ?: "Clerk must be configured with configureNative before syncing client state."
+ )
+ else -> ClerkDeviceTokenBridgeError(
+ "E_SET_DEVICE_TOKEN_FAILED",
+ throwable.message ?: "Unable to set the device token"
+ )
+}
diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
index c329174372d..84ec5128587 100644
--- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
+++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -3,6 +3,8 @@
package expo.modules.clerk
import android.content.Context
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@@ -37,12 +39,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
private const val TAG = "ClerkExpoModule"
private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
+private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated"
private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
private const val HOST_SDK = "expo"
@@ -204,6 +208,12 @@ class ClerkExpoModule : Module() {
private var lastObservedClientState: ClientStateSnapshot? = null
private var jsOriginatedClientSyncDepth = 0
private var configuredPublishableKey: String? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var clientInvalidationObserverJob: Job? = null
+ private val clientInvalidationTracker = ClerkClientInvalidationTracker(
+ schedule = { flush -> mainHandler.post(flush) },
+ emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap()) }
+ )
private data class AuthFlowStateSnapshot(
val isLoaded: Boolean,
@@ -238,7 +248,7 @@ class ClerkExpoModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkExpo")
- Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT)
+ Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)
OnCreate {
sharedInstance = this@ClerkExpoModule
@@ -253,6 +263,9 @@ class ClerkExpoModule : Module() {
authFlowStateObserverJob = null
clientStateObserverJob?.cancel()
clientStateObserverJob = null
+ clientInvalidationObserverJob?.cancel()
+ clientInvalidationObserverJob = null
+ mainHandler.removeCallbacksAndMessages(null)
}
AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
@@ -263,6 +276,22 @@ class ClerkExpoModule : Module() {
getClientToken(promise)
}
+ AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise ->
+ configureNative(pubKey, seedDeviceToken, promise)
+ }
+
+ AsyncFunction("getDeviceToken") { promise: Promise ->
+ getDeviceToken(promise)
+ }
+
+ AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise ->
+ setDeviceToken(token, expected, promise)
+ }
+
+ AsyncFunction("refreshClient") { promise: Promise ->
+ refreshClient(promise)
+ }
+
AsyncFunction("getAuthFlowState") { promise: Promise ->
promise.resolve(authFlowStatePayload())
}
@@ -615,6 +644,140 @@ class ClerkExpoModule : Module() {
}
}
+ // MARK: - single-token client sync
+
+ private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) {
+ val context = reactContext ?: run {
+ promise.reject("E_CONFIGURE_FAILED", "React context is not available", null)
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
+ val didConfigure = when {
+ activePublishableKey == null -> {
+ Clerk.initialize(context, pubKey, clerkConfigurationOptions())
+ true
+ }
+ activePublishableKey != pubKey -> {
+ Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions())
+ true
+ }
+ else -> false
+ }
+ if (didConfigure) {
+ configuredPublishableKey = pubKey
+ appContext.currentActivity?.let { Clerk.attachActivity(it) }
+ // Must follow initialize(), which resets customTheme.
+ loadThemeFromAssets(context)
+ }
+ startClientStateObserver()
+
+ val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
+ startClientInvalidationObserver()
+ lastObservedClientState = clientStateSnapshot()
+ if (didAdoptSeed) {
+ // An initialization refresh started before the seed was stored is fenced off by the token change.
+ launch {
+ val result = Clerk.refreshClient()
+ if (result is ClerkResult.Failure) {
+ debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}")
+ }
+ clientInvalidationTracker.observe(clientFingerprint())
+ }
+ }
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e)
+ }
+ }
+ }
+
+ private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean {
+ val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false
+ return withContext(Dispatchers.IO) {
+ Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null)
+ }
+ }
+
+ private fun startClientInvalidationObserver() {
+ clientInvalidationTracker.reset(clientFingerprint())
+ if (clientInvalidationObserverJob != null) {
+ return
+ }
+
+ clientInvalidationObserverJob = coroutineScope.launch {
+ Clerk.clientFlow.collect { client ->
+ clientInvalidationTracker.observe(clientFingerprint(client))
+ }
+ }
+ }
+
+ private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint {
+ return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken)
+ }
+
+ private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null
+
+ private fun getDeviceToken(promise: Promise) {
+ if (!isClerkConfigured()) {
+ promise.resolve(null)
+ return
+ }
+ try {
+ promise.resolve(Clerk.getDeviceToken())
+ } catch (e: Exception) {
+ promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e)
+ }
+ }
+
+ private fun setDeviceToken(token: String?, expected: String?, promise: Promise) {
+ coroutineScope.launch {
+ try {
+ val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) }
+ val fingerprint = clientFingerprint()
+ if (didSet) {
+ clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint)
+ } else {
+ clientInvalidationTracker.observe(fingerprint)
+ }
+ promise.resolve(didSet)
+ } catch (e: Exception) {
+ val error = clerkSetDeviceTokenBridgeError(e)
+ promise.reject(error.code, error.message, e)
+ }
+ }
+ }
+
+ private fun refreshClient(promise: Promise) {
+ if (!isClerkConfigured()) {
+ promise.reject(
+ "E_NOT_CONFIGURED",
+ "Clerk must be configured with configureNative before syncing client state.",
+ null
+ )
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ val result = Clerk.refreshClient()
+ clientInvalidationTracker.observe(clientFingerprint())
+ when (result) {
+ is ClerkResult.Success -> promise.resolve(null)
+ is ClerkResult.Failure -> promise.reject(
+ "E_REFRESH_CLIENT_FAILED",
+ result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
+ result.throwable
+ )
+ }
+ } catch (e: Exception) {
+ promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e)
+ }
+ }
+ }
+
// MARK: - getClientToken
private fun getClientToken(promise: Promise) {
diff --git a/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt
new file mode 100644
index 00000000000..76f75c810b1
--- /dev/null
+++ b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt
@@ -0,0 +1,127 @@
+package expo.modules.clerk
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ClerkClientInvalidationTest {
+ private val scheduled = mutableListOf<() -> Unit>()
+ private var emitCount = 0
+
+ private fun tracker() = ClerkClientInvalidationTracker(
+ schedule = { scheduled.add(it) },
+ emit = { emitCount += 1 }
+ )
+
+ private fun runLoopTurn() {
+ val work = scheduled.toList()
+ scheduled.clear()
+ work.forEach { it() }
+ }
+
+ private fun fingerprint(
+ clientId: String? = "client_1",
+ sessions: List = listOf(ClerkClientFingerprint.SessionState("sess_1", "ACTIVE")),
+ userUpdatedAt: Long? = 1,
+ deviceToken: String? = "token_1"
+ ) = ClerkClientFingerprint(
+ clientId = clientId,
+ lastActiveSessionId = sessions.firstOrNull()?.id,
+ sessions = sessions,
+ activeUserId = if (sessions.isEmpty()) null else "user_1",
+ activeUserUpdatedAt = if (sessions.isEmpty()) null else userUpdatedAt,
+ deviceToken = deviceToken
+ )
+
+ @Before
+ fun setUp() {
+ scheduled.clear()
+ emitCount = 0
+ }
+
+ @Test
+ fun `does not emit before a baseline or for unchanged state`() {
+ val tracker = tracker()
+ tracker.observe(fingerprint())
+ assertTrue(scheduled.isEmpty())
+
+ tracker.reset(fingerprint())
+ tracker.observe(fingerprint())
+ runLoopTurn()
+ assertEquals(0, emitCount)
+ }
+
+ @Test
+ fun `emits for each fingerprint field`() {
+ val changes = listOf(
+ fingerprint(clientId = "client_2"),
+ fingerprint(sessions = listOf(ClerkClientFingerprint.SessionState("sess_1", "ENDED"))),
+ fingerprint(sessions = emptyList()),
+ fingerprint(userUpdatedAt = 2),
+ fingerprint(deviceToken = "token_2")
+ )
+
+ for (change in changes) {
+ val tracker = tracker()
+ tracker.reset(fingerprint())
+ emitCount = 0
+ tracker.observe(change)
+ runLoopTurn()
+ assertEquals(change.toString(), 1, emitCount)
+ }
+ }
+
+ @Test
+ fun `coalesces changes within one loop turn`() {
+ val tracker = tracker()
+ tracker.reset(fingerprint())
+
+ tracker.observe(fingerprint(deviceToken = "token_2"))
+ tracker.observe(fingerprint(clientId = "client_2", deviceToken = "token_2"))
+ tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3"))
+ assertEquals(1, scheduled.size)
+ runLoopTurn()
+ assertEquals(1, emitCount)
+
+ tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3"))
+ runLoopTurn()
+ assertEquals(1, emitCount)
+
+ tracker.observe(fingerprint(clientId = "client_4", deviceToken = "token_3"))
+ runLoopTurn()
+ assertEquals(2, emitCount)
+ }
+
+ @Test
+ fun `a change reverted within one loop turn does not emit`() {
+ val tracker = tracker()
+ tracker.reset(fingerprint())
+
+ tracker.observe(fingerprint(clientId = "client_2"))
+ tracker.observe(fingerprint())
+ runLoopTurn()
+ assertEquals(0, emitCount)
+ }
+
+ @Test
+ fun `an acknowledged device token does not echo`() {
+ val tracker = tracker()
+ tracker.reset(fingerprint())
+
+ tracker.acknowledgeDeviceToken("token_2", fingerprint(deviceToken = "token_2"))
+ runLoopTurn()
+ assertEquals(0, emitCount)
+
+ tracker.acknowledgeDeviceToken("token_3", fingerprint(clientId = "client_2", deviceToken = "token_3"))
+ runLoopTurn()
+ assertEquals(1, emitCount)
+ }
+
+ @Test
+ fun `maps setDeviceToken exceptions to stable codes`() {
+ assertEquals("E_INVALID_DEVICE_TOKEN", clerkSetDeviceTokenBridgeError(IllegalArgumentException("blank")).code)
+ assertEquals("E_NOT_CONFIGURED", clerkSetDeviceTokenBridgeError(IllegalStateException("not initialized")).code)
+ assertEquals("E_SET_DEVICE_TOKEN_FAILED", clerkSetDeviceTokenBridgeError(RuntimeException("boom")).code)
+ }
+}
diff --git a/packages/expo-native-components/ios/ClerkClientInvalidation.swift b/packages/expo-native-components/ios/ClerkClientInvalidation.swift
new file mode 100644
index 00000000000..478d6da60a1
--- /dev/null
+++ b/packages/expo-native-components/ios/ClerkClientInvalidation.swift
@@ -0,0 +1,105 @@
+import Foundation
+@_spi(FrameworkIntegration) import ClerkKit
+
+/// The native client state that JS must refetch its own client for when it changes.
+struct ClerkClientFingerprint: Equatable {
+ struct SessionState: Equatable {
+ let id: String
+ let status: String
+ }
+
+ let clientId: String?
+ let lastActiveSessionId: String?
+ let sessions: [SessionState]
+ let activeUserId: String?
+ let activeUserUpdatedAt: Double?
+ var deviceToken: String?
+}
+
+extension ClerkClientFingerprint {
+ init(client: Client?, deviceToken: String?) {
+ let activeUser = client?.sessions.first { $0.id == client?.lastActiveSessionId }?.user
+ self.init(
+ clientId: client?.id,
+ lastActiveSessionId: client?.lastActiveSessionId,
+ sessions: client?.sessions.map { SessionState(id: $0.id, status: $0.status.rawValue) } ?? [],
+ activeUserId: activeUser?.id,
+ activeUserUpdatedAt: activeUser?.updatedAt.timeIntervalSince1970,
+ deviceToken: deviceToken
+ )
+ }
+}
+
+/// Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from
+/// the last state JS was told about.
+@MainActor
+final class ClerkClientInvalidationTracker {
+ typealias Scheduler = (@escaping @MainActor () -> Void) -> Void
+
+ private let schedule: Scheduler
+ private let emit: () -> Void
+ private var baseline: ClerkClientFingerprint?
+ private var latest: ClerkClientFingerprint?
+ private var isFlushScheduled = false
+
+ init(
+ schedule: @escaping Scheduler = { work in DispatchQueue.main.async { MainActor.assumeIsolated(work) } },
+ emit: @escaping () -> Void
+ ) {
+ self.schedule = schedule
+ self.emit = emit
+ }
+
+ /// Sets the state JS already knows about without emitting.
+ func reset(to fingerprint: ClerkClientFingerprint?) {
+ baseline = fingerprint
+ latest = fingerprint
+ }
+
+ func observe(_ fingerprint: ClerkClientFingerprint) {
+ guard baseline != nil else { return }
+ latest = fingerprint
+ guard fingerprint != baseline, !isFlushScheduled else { return }
+ isFlushScheduled = true
+ schedule { [weak self] in self?.flush() }
+ }
+
+ /// A token JS wrote itself is not news to JS, so it must not echo back as an invalidation.
+ func acknowledgeDeviceToken(_ token: String?, current: ClerkClientFingerprint) {
+ baseline?.deviceToken = token
+ observe(current)
+ }
+
+ private func flush() {
+ isFlushScheduled = false
+ guard let latest, latest != baseline else { return }
+ baseline = latest
+ emit()
+ }
+}
+
+struct ClerkDeviceTokenErrorDescriptor: Equatable {
+ let code: String
+ let message: String
+}
+
+enum ClerkClientSyncError: Error, LocalizedError {
+ case notConfigured
+
+ var errorDescription: String? {
+ "Clerk must be configured with configureNative before syncing client state."
+ }
+}
+
+func clerkSetDeviceTokenErrorDescriptor(_ error: Error) -> ClerkDeviceTokenErrorDescriptor {
+ switch error {
+ case ClerkClientSyncError.notConfigured:
+ return .init(code: "E_NOT_CONFIGURED", message: error.localizedDescription)
+ case Clerk.DeviceTokenError.emptyToken:
+ return .init(code: "E_INVALID_DEVICE_TOKEN", message: error.localizedDescription)
+ case is CancellationError:
+ return .init(code: "E_CANCELLED", message: "The device token update was cancelled.")
+ default:
+ return .init(code: "E_SET_DEVICE_TOKEN_FAILED", message: error.localizedDescription)
+ }
+}
diff --git a/packages/expo-native-components/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec
index 13b4510aa12..104abc556a0 100644
--- a/packages/expo-native-components/ios/ClerkExpo.podspec
+++ b/packages/expo-native-components/ios/ClerkExpo.podspec
@@ -18,7 +18,7 @@ else
end
clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git'
-clerk_ios_version = '1.5.7'
+clerk_ios_branch = 'mike/framework-set-device-token'
Pod::Spec.new do |s|
s.name = 'ClerkExpo'
@@ -44,7 +44,7 @@ Pod::Spec.new do |s|
spm_dependency(
s,
url: clerk_ios_repo,
- requirement: { :kind => 'exactVersion', :version => clerk_ios_version },
+ requirement: { :kind => 'branch', :branch => clerk_ios_branch },
products: ['ClerkKit', 'ClerkKitUI']
)
else
@@ -52,6 +52,7 @@ Pod::Spec.new do |s|
end
s.source_files = "ClerkNativeBridge.swift",
+ "ClerkClientInvalidation.swift",
"ClerkAppDelegateSubscriber.swift",
"ClerkExpoModule.swift",
"ClerkNativeViewHost.swift",
diff --git a/packages/expo-native-components/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift
index a1a12679066..fb84e7f59a0 100644
--- a/packages/expo-native-components/ios/ClerkExpoModule.swift
+++ b/packages/expo-native-components/ios/ClerkExpoModule.swift
@@ -10,13 +10,14 @@ import Foundation
public class ClerkExpoModule: Module {
private static let nativeAuthFlowChangedEvent = "clerkNativeAuthFlowChanged"
private static let nativeClientChangedEvent = "clerkNativeClientChanged"
+ private static let nativeClientInvalidatedEvent = "clerkNativeClientInvalidated"
private static weak var sharedInstance: ClerkExpoModule?
public func definition() -> ModuleDefinition {
Name("ClerkExpo")
- Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent)
+ Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent, Self.nativeClientInvalidatedEvent)
OnCreate {
Self.sharedInstance = self
@@ -26,6 +27,9 @@ public class ClerkExpoModule: Module {
ClerkNativeBridge.setClientChangedEmitter { body in
Self.emitClientChanged(body)
}
+ ClerkNativeBridge.setClientInvalidatedEmitter {
+ Self.emitClientInvalidated()
+ }
}
OnDestroy {
@@ -33,6 +37,7 @@ public class ClerkExpoModule: Module {
Self.sharedInstance = nil
ClerkNativeBridge.setAuthFlowChangedEmitter(nil)
ClerkNativeBridge.setClientChangedEmitter(nil)
+ ClerkNativeBridge.setClientInvalidatedEmitter(nil)
}
}
@@ -44,6 +49,51 @@ public class ClerkExpoModule: Module {
self.getClientToken(promise: promise)
}
+ AsyncFunction("configureNative") { (publishableKey: String, seedDeviceToken: String?, promise: Promise) in
+ Task { @MainActor in
+ do {
+ try await ClerkNativeBridge.shared.configureNative(
+ publishableKey: publishableKey,
+ seedDeviceToken: seedDeviceToken
+ )
+ promise.resolve()
+ } catch {
+ promise.reject("E_CONFIGURE_FAILED", error.localizedDescription)
+ }
+ }
+ }
+
+ AsyncFunction("getDeviceToken") { (promise: Promise) in
+ Task { @MainActor in
+ promise.resolve(ClerkNativeBridge.shared.getDeviceToken())
+ }
+ }
+
+ AsyncFunction("setDeviceToken") { (token: String?, expected: String?, promise: Promise) in
+ Task { @MainActor in
+ do {
+ let didSet = try await ClerkNativeBridge.shared.setDeviceToken(token, expected: expected)
+ promise.resolve(didSet)
+ } catch {
+ let descriptor = clerkSetDeviceTokenErrorDescriptor(error)
+ promise.reject(descriptor.code, descriptor.message)
+ }
+ }
+ }
+
+ AsyncFunction("refreshClient") { (promise: Promise) in
+ Task { @MainActor in
+ do {
+ try await ClerkNativeBridge.shared.refreshClient()
+ promise.resolve()
+ } catch ClerkClientSyncError.notConfigured {
+ promise.reject("E_NOT_CONFIGURED", ClerkClientSyncError.notConfigured.localizedDescription)
+ } catch {
+ promise.reject("E_REFRESH_CLIENT_FAILED", error.localizedDescription)
+ }
+ }
+ }
+
AsyncFunction("getAuthFlowState") { (promise: Promise) in
self.getAuthFlowState(promise: promise)
}
@@ -301,6 +351,16 @@ public class ClerkExpoModule: Module {
}
}
+ static func emitClientInvalidated() {
+ guard let instance = sharedInstance else {
+ return
+ }
+
+ DispatchQueue.main.async { [weak instance] in
+ instance?.sendEvent(Self.nativeClientInvalidatedEvent, [:])
+ }
+ }
+
static func emitAuthFlowChanged(_ body: [String: Any]? = nil) {
let eventBody = body ?? [:]
diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift
index bcb20cb8bfc..0551e609f32 100644
--- a/packages/expo-native-components/ios/ClerkNativeBridge.swift
+++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift
@@ -398,6 +398,7 @@ func userProfileCustomPageLabel(
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)?
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
+private var clerkNativeClientInvalidatedEmitter: (() -> Void)?
struct ClerkNativeErrorDescriptor {
let code: String
@@ -446,6 +447,8 @@ final class ClerkNativeBridge {
private var lastObservedAuthFlowState: AuthFlowStateSnapshot?
private var configurationDepth = 0
private var jsOriginatedClientSyncDepth = 0
+ private var clientInvalidationGeneration = 0
+ private var clientInvalidationTracker: ClerkClientInvalidationTracker?
private var pendingURL: URL?
private var shouldFlushPendingURL = false
@@ -477,23 +480,26 @@ final class ClerkNativeBridge {
return Bundle.main.bundleIdentifier
}
+ @MainActor
+ private func endConfiguration() {
+ lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
+ let authFlowState = Self.authFlowStateSnapshot()
+ lastObservedAuthFlowState = authFlowState
+ configurationDepth = max(0, configurationDepth - 1)
+ Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState))
+
+ // Overlapping calls can finish out of order, so replay once the last one settles and any
+ // of them succeeded. A batch where every call threw keeps the URL for the next attempt.
+ if configurationDepth == 0, shouldFlushPendingURL {
+ shouldFlushPendingURL = false
+ flushPendingURL()
+ }
+ }
+
@MainActor
func configure(publishableKey: String, bearerToken: String? = nil) async throws {
configurationDepth += 1
- defer {
- lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
- let authFlowState = Self.authFlowStateSnapshot()
- lastObservedAuthFlowState = authFlowState
- configurationDepth = max(0, configurationDepth - 1)
- Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState))
-
- // Overlapping calls can finish out of order, so replay once the last one settles and any
- // of them succeeded. A batch where every call threw keeps the URL for the next attempt.
- if configurationDepth == 0, shouldFlushPendingURL {
- shouldFlushPendingURL = false
- flushPendingURL()
- }
- }
+ defer { endConfiguration() }
loadThemes()
@@ -539,6 +545,120 @@ final class ClerkNativeBridge {
shouldFlushPendingURL = true
}
+ /// Configures ClerkKit without waiting for the client to load. ClerkKit's stored device token
+ /// wins; `seedDeviceToken` is only adopted when ClerkKit has none.
+ @MainActor
+ func configureNative(publishableKey: String, seedDeviceToken: String?) async throws {
+ configurationDepth += 1
+ defer { endConfiguration() }
+
+ loadThemes()
+
+ var didConfigure = true
+ if Self.shouldReconfigure(for: publishableKey) {
+ try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
+ Self.configuredPublishableKey = publishableKey
+ startClientObserver(reset: true)
+ startAuthFlowObserver(reset: true)
+ } else if Self.clerkConfigured {
+ didConfigure = false
+ startClientObserver()
+ startAuthFlowObserver()
+ } else {
+ Self.clerkConfigured = true
+ Self.configuredPublishableKey = publishableKey
+ Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
+ startClientObserver()
+ startAuthFlowObserver()
+ }
+
+ let didAdoptSeed = try await Self.adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
+ startClientInvalidationObserver(reset: didConfigure)
+ if didAdoptSeed {
+ // A client load started before the seed was stored may have been fenced off by the token change.
+ Task { @MainActor in
+ _ = try? await Clerk.shared.refreshClient()
+ }
+ }
+ if didConfigure {
+ Self.postConfiguredNotification()
+ }
+ shouldFlushPendingURL = true
+ }
+
+ @MainActor
+ private static func adoptSeedDeviceTokenIfNeeded(_ seedDeviceToken: String?) async throws -> Bool {
+ guard let seed = seedDeviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !seed.isEmpty,
+ Clerk.shared.deviceToken == nil
+ else {
+ return false
+ }
+ return try await Clerk.shared.setDeviceToken(seed, expected: nil)
+ }
+
+ @MainActor
+ func getDeviceToken() -> String? {
+ guard Self.clerkConfigured else { return nil }
+ return Clerk.shared.deviceToken
+ }
+
+ @MainActor
+ func setDeviceToken(_ token: String?, expected: String?) async throws -> Bool {
+ guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured }
+
+ let didSet = try await Clerk.shared.setDeviceToken(token, expected: expected)
+ let fingerprint = Self.clientFingerprint()
+ if didSet {
+ clientInvalidationTracker?.acknowledgeDeviceToken(fingerprint.deviceToken, current: fingerprint)
+ } else {
+ clientInvalidationTracker?.observe(fingerprint)
+ }
+ return didSet
+ }
+
+ @MainActor
+ func refreshClient() async throws {
+ guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured }
+ defer { recordClientFingerprint() }
+ _ = try await Clerk.shared.refreshClient()
+ }
+
+ @MainActor
+ private func startClientInvalidationObserver(reset: Bool) {
+ let tracker = clientInvalidationTracker ?? ClerkClientInvalidationTracker { Self.emitClientInvalidated() }
+ clientInvalidationTracker = tracker
+ tracker.reset(to: Self.clientFingerprint())
+
+ guard reset || clientInvalidationGeneration == 0 else { return }
+ clientInvalidationGeneration += 1
+ observeClientInvalidation(generation: clientInvalidationGeneration)
+ }
+
+ @MainActor
+ private func observeClientInvalidation(generation: Int) {
+ withObservationTracking {
+ _ = Self.clientFingerprint()
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ await Task.yield()
+
+ guard let self, generation == self.clientInvalidationGeneration else { return }
+ self.recordClientFingerprint()
+ self.observeClientInvalidation(generation: generation)
+ }
+ }
+ }
+
+ @MainActor
+ private func recordClientFingerprint() {
+ clientInvalidationTracker?.observe(Self.clientFingerprint())
+ }
+
+ @MainActor
+ private static func clientFingerprint() -> ClerkClientFingerprint {
+ ClerkClientFingerprint(client: Clerk.shared.client, deviceToken: Clerk.shared.deviceToken)
+ }
+
@MainActor
private func flushPendingURL() {
guard let url = pendingURL else { return }
@@ -1146,6 +1266,19 @@ final class ClerkNativeBridge {
}
}
+ static func setClientInvalidatedEmitter(_ emitter: (() -> Void)?) {
+ clerkNativeClientEventQueue.sync {
+ clerkNativeClientInvalidatedEmitter = emitter
+ }
+ }
+
+ static func emitClientInvalidated() {
+ let emitter = clerkNativeClientEventQueue.sync {
+ clerkNativeClientInvalidatedEmitter
+ }
+ emitter?()
+ }
+
static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
clerkNativeClientEventQueue.sync {
clerkNativeAuthFlowChangedEmitter = emitter
diff --git a/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift
new file mode 100644
index 00000000000..d5bc3ba9209
--- /dev/null
+++ b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift
@@ -0,0 +1,143 @@
+import XCTest
+@_spi(FrameworkIntegration) import ClerkKit
+@testable import ClerkExpo
+
+@MainActor
+final class ClerkClientInvalidationTests: XCTestCase {
+ private var scheduled: [@MainActor () -> Void] = []
+ private var emitCount = 0
+
+ private func makeTracker() -> ClerkClientInvalidationTracker {
+ ClerkClientInvalidationTracker(
+ schedule: { [unowned self] work in self.scheduled.append(work) },
+ emit: { [unowned self] in self.emitCount += 1 }
+ )
+ }
+
+ private func runLoopTurn() {
+ let work = scheduled
+ scheduled.removeAll()
+ work.forEach { $0() }
+ }
+
+ private func fingerprint(
+ clientId: String? = "client_1",
+ sessions: [ClerkClientFingerprint.SessionState] = [.init(id: "sess_1", status: "active")],
+ userUpdatedAt: Double? = 1,
+ deviceToken: String? = "token_1"
+ ) -> ClerkClientFingerprint {
+ ClerkClientFingerprint(
+ clientId: clientId,
+ lastActiveSessionId: sessions.first?.id,
+ sessions: sessions,
+ activeUserId: sessions.isEmpty ? nil : "user_1",
+ activeUserUpdatedAt: sessions.isEmpty ? nil : userUpdatedAt,
+ deviceToken: deviceToken
+ )
+ }
+
+ override func setUp() {
+ super.setUp()
+ scheduled = []
+ emitCount = 0
+ }
+
+ func testDoesNotEmitBeforeBaselineOrForUnchangedState() {
+ let tracker = makeTracker()
+ tracker.observe(fingerprint())
+ XCTAssertTrue(scheduled.isEmpty)
+
+ tracker.reset(to: fingerprint())
+ tracker.observe(fingerprint())
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 0)
+ }
+
+ func testEmitsForEachFingerprintField() {
+ let changes = [
+ fingerprint(clientId: "client_2"),
+ fingerprint(sessions: [.init(id: "sess_1", status: "ended")]),
+ fingerprint(sessions: []),
+ fingerprint(userUpdatedAt: 2),
+ fingerprint(deviceToken: "token_2"),
+ ]
+
+ for change in changes {
+ let tracker = makeTracker()
+ tracker.reset(to: fingerprint())
+ emitCount = 0
+ tracker.observe(change)
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 1, "\(change)")
+ }
+ }
+
+ func testCoalescesChangesWithinOneLoopTurn() {
+ let tracker = makeTracker()
+ tracker.reset(to: fingerprint())
+
+ tracker.observe(fingerprint(deviceToken: "token_2"))
+ tracker.observe(fingerprint(clientId: "client_2", deviceToken: "token_2"))
+ tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3"))
+ XCTAssertEqual(scheduled.count, 1)
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 1)
+
+ tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3"))
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 1)
+
+ tracker.observe(fingerprint(clientId: "client_4", deviceToken: "token_3"))
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 2)
+ }
+
+ func testChangeRevertedWithinOneLoopTurnDoesNotEmit() {
+ let tracker = makeTracker()
+ tracker.reset(to: fingerprint())
+
+ tracker.observe(fingerprint(clientId: "client_2"))
+ tracker.observe(fingerprint())
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 0)
+ }
+
+ func testAcknowledgedDeviceTokenDoesNotEcho() {
+ let tracker = makeTracker()
+ tracker.reset(to: fingerprint())
+
+ tracker.acknowledgeDeviceToken("token_2", current: fingerprint(deviceToken: "token_2"))
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 0)
+
+ tracker.acknowledgeDeviceToken("token_3", current: fingerprint(clientId: "client_2", deviceToken: "token_3"))
+ runLoopTurn()
+ XCTAssertEqual(emitCount, 1)
+ }
+
+ func testSetDeviceTokenErrorCodes() {
+ XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(ClerkClientSyncError.notConfigured).code, "E_NOT_CONFIGURED")
+ XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.emptyToken).code, "E_INVALID_DEVICE_TOKEN")
+ XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(CancellationError()).code, "E_CANCELLED")
+ XCTAssertEqual(
+ clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.updateRejected).code,
+ "E_SET_DEVICE_TOKEN_FAILED"
+ )
+ }
+
+ func testSyncFunctionsRejectOrReturnNilBeforeConfiguration() async {
+ XCTAssertNil(ClerkNativeBridge.shared.getDeviceToken())
+ do {
+ _ = try await ClerkNativeBridge.shared.setDeviceToken("token", expected: nil)
+ XCTFail("Expected setDeviceToken to throw")
+ } catch {
+ XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(error).code, "E_NOT_CONFIGURED")
+ }
+ do {
+ try await ClerkNativeBridge.shared.refreshClient()
+ XCTFail("Expected refreshClient to throw")
+ } catch {
+ XCTAssertTrue(error is ClerkClientSyncError)
+ }
+ }
+}
diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts
index d5c59ceb1c9..51d214fc53c 100644
--- a/packages/expo/src/specs/NativeClerkModule.android.ts
+++ b/packages/expo/src/specs/NativeClerkModule.android.ts
@@ -1,8 +1,12 @@
import { requireOptionalNativeModule } from 'expo';
-import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types';
+import type {
+ NativeAuthFlowModule,
+ NativeBiometricCredentialModule,
+ NativeClientSyncModule,
+} from './NativeClerkModule.types';
-interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule {
+interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial {
// Exposed by Expo Modules EventEmitter for internal native client change events.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts
index 7e71393a9be..c999c57d6c4 100644
--- a/packages/expo/src/specs/NativeClerkModule.ts
+++ b/packages/expo/src/specs/NativeClerkModule.ts
@@ -1,8 +1,12 @@
import { requireOptionalNativeModule } from 'expo';
-import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types';
+import type {
+ NativeAuthFlowModule,
+ NativeBiometricCredentialModule,
+ NativeClientSyncModule,
+} from './NativeClerkModule.types';
-export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule {
+export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial {
// Exposed by Expo Modules EventEmitter for internal native client change events.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts
index fa3aa35b6e2..fb99d305e33 100644
--- a/packages/expo/src/specs/NativeClerkModule.types.ts
+++ b/packages/expo/src/specs/NativeClerkModule.types.ts
@@ -11,6 +11,17 @@ export type NativeAuthFlowModule = {
getAuthFlowState(): Promise;
};
+/**
+ * Native storage owns the device token. JS reads it before each FAPI request, writes rotated tokens
+ * back with compare-and-set, and refetches its own client on `clerkNativeClientInvalidated`.
+ */
+export type NativeClientSyncModule = {
+ configureNative(publishableKey: string, seedDeviceToken: string | null): Promise;
+ getDeviceToken(): Promise;
+ setDeviceToken(token: string | null, expected: string | null): Promise;
+ refreshClient(): Promise;
+};
+
export type NativeBiometricCredential = {
id: string;
object: 'trusted_device';
diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts
index 11751ec4114..e81c3aabee9 100644
--- a/packages/expo/src/utils/native-module.ts
+++ b/packages/expo/src/utils/native-module.ts
@@ -1,7 +1,11 @@
import { Platform } from 'react-native';
import NativeClerkModule from '../specs/NativeClerkModule';
-import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types';
+import type {
+ NativeAuthFlowModule,
+ NativeBiometricCredentialModule,
+ NativeClientSyncModule,
+} from '../specs/NativeClerkModule.types';
export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
@@ -15,7 +19,7 @@ export type ClerkExpoNativeModule = {
didChangeClient: boolean,
didChangeDeviceToken: boolean,
): Promise;
-} & Partial;
+} & Partial;
function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule {
if (!module || typeof module !== 'object') {
From fa311e940f67f426168201f1029e1267bc42225f Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:09:30 -0400
Subject: [PATCH 10/15] refactor(expo-native-components): rename
@clerk/expo-native references
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-native-single-token-sync-bridge.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/expo-native-single-token-sync-bridge.md b/.changeset/expo-native-single-token-sync-bridge.md
index e5749427457..0163480dc37 100644
--- a/.changeset/expo-native-single-token-sync-bridge.md
+++ b/.changeset/expo-native-single-token-sync-bridge.md
@@ -1,5 +1,5 @@
---
-'@clerk/expo-native': minor
+'@clerk/expo-native-components': minor
---
Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it.
From 9e19e6db3cd3e326a3c4a1d26be58fed709bc7c3 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:15:52 -0400
Subject: [PATCH 11/15] test(expo): add behavioral native client sync suite
Adds an engine-agnostic behavior suite for JS <-> native client sync,
backed by a fake Frontend API, a fake native SDK, and a clerk-js
stand-in, with a thin adapter that drives today's ClerkProvider engine.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/expo-native-sync-behavior-tests.md | 2 +
.../currentEngineAdapter.tsx | 169 ++++++++
.../nativeClientSyncBehavior/environment.tsx | 65 +++
.../nativeClientSyncBehavior/fakeClerk.ts | 261 ++++++++++++
.../fakeNativeClerk.ts | 162 +++++++
.../nativeClientSyncBehavior/fakeServer.ts | 214 ++++++++++
.../nativeClientSyncBehavior/harness.ts | 49 +++
.../nativeClientSyncBehavior/index.ts | 4 +
.../nativeClientSync.behavior.test.tsx | 398 ++++++++++++++++++
9 files changed, 1324 insertions(+)
create mode 100644 .changeset/expo-native-sync-behavior-tests.md
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
diff --git a/.changeset/expo-native-sync-behavior-tests.md b/.changeset/expo-native-sync-behavior-tests.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/expo-native-sync-behavior-tests.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
new file mode 100644
index 00000000000..5671ed55ce7
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
@@ -0,0 +1,169 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
+
+import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
+import type { NativeClientSnapshot } from '../../../hooks/useNativeClientEvents';
+import type { ClerkExpoNativeModule } from '../../../utils/native-module';
+import { FakePersistentTokenCache, setActiveEnvironment } from './environment';
+import { FakeClerk } from './fakeClerk';
+import { FakeNativeClerk } from './fakeNativeClerk';
+import { FakeClerkServer } from './fakeServer';
+import type { NativeSyncHarness, RenderWithNativeSync } from './harness';
+
+const nativeClientChangedEvent = 'clerkNativeClientChanged';
+
+function serializeClient(native: FakeNativeClerk): string {
+ return JSON.stringify(native.client);
+}
+
+/**
+ * Today's `ClerkExpo` native module contract, modeled on the iOS bridge: `configure` adopts a bearer
+ * token, `syncClientStateFromJs` adopts/refreshes and echoes a change tagged with the JS `sourceId`, and
+ * native-originated changes are emitted as `clerkNativeClientChanged` snapshots.
+ */
+function createCurrentNativeModule(native: FakeNativeClerk, calls: string[]): ClerkExpoNativeModule {
+ const listeners = new Set<(snapshot: NativeClientSnapshot) => void>();
+
+ native.onChange((change, origin) => {
+ const snapshot: NativeClientSnapshot = {
+ changed: { client: change.client, deviceToken: change.deviceToken },
+ deviceToken: native.token,
+ ...(origin.kind === 'js' && origin.tag ? { sourceId: origin.tag } : {}),
+ };
+ for (const listener of [...listeners]) {
+ listener(snapshot);
+ }
+ });
+
+ return {
+ addListener: (eventName: string, listener?: (...args: unknown[]) => void) => {
+ calls.push('addListener');
+ if (eventName !== nativeClientChangedEvent || !listener) {
+ return { remove: () => undefined };
+ }
+ const typedListener = listener as (snapshot: NativeClientSnapshot) => void;
+ listeners.add(typedListener);
+ return { remove: () => listeners.delete(typedListener) };
+ },
+ configure: async (_publishableKey: string, bearerToken: string | null) => {
+ calls.push('configure');
+ await native.waitForStartup();
+ if (bearerToken && (bearerToken !== native.token || !native.client)) {
+ await native.adoptToken(bearerToken);
+ } else {
+ await native.refreshClient();
+ }
+ native.isConfigured = true;
+ },
+ getClientToken: () => {
+ calls.push('getClientToken');
+ return Promise.resolve(native.token);
+ },
+ syncClientStateFromJs: async (
+ deviceToken: string | null,
+ sourceId: string | null,
+ didChangeClient: boolean,
+ didChangeDeviceToken: boolean,
+ ) => {
+ calls.push('syncClientStateFromJs');
+ if (!native.isConfigured) {
+ return;
+ }
+ const previousToken = native.token;
+ const previousClient = serializeClient(native);
+ const changeSincePrevious = () => ({
+ client: serializeClient(native) !== previousClient,
+ deviceToken: native.token !== previousToken,
+ });
+ try {
+ if (didChangeDeviceToken && deviceToken && deviceToken !== native.token) {
+ await native.adoptToken(deviceToken);
+ } else if (didChangeClient || didChangeDeviceToken) {
+ await native.refreshClient();
+ }
+ } catch (error) {
+ const change = changeSincePrevious();
+ if (change.client || change.deviceToken) {
+ native.emitChange(change, { kind: 'native' });
+ }
+ throw error;
+ }
+ native.emitChange(changeSincePrevious(), { kind: 'js', tag: sourceId });
+ },
+ };
+}
+
+async function flushMacrotasks(count: number): Promise {
+ for (let i = 0; i < count; i++) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+}
+
+export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => {
+ const server = scenario.server ?? new FakeClerkServer();
+ const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null);
+ const js = new FakeClerk(server);
+ const persistentTokenCache =
+ scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined
+ ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {})
+ : null;
+ const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined;
+ const nativeModuleCalls: string[] = [];
+
+ setActiveEnvironment({
+ js,
+ nativeModule: scenario.nativeModule === 'absent' ? null : createCurrentNativeModule(native, nativeModuleCalls),
+ });
+
+ // Fresh engine modules per scenario: the native module is resolved at import time and the
+ // coordinator and default in-memory token cache are module singletons.
+ vi.resetModules();
+ const { ClerkProvider } = await import('../../ClerkProvider');
+ const { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } =
+ await import('../../nativeClientSyncCoordinator');
+
+ const view = render(
+ ,
+ );
+
+ const settle = async () => {
+ for (let round = 0; round < 8; round++) {
+ await act(async () => {
+ await flushMacrotasks(10);
+ });
+ }
+ };
+
+ await waitFor(() => {
+ if (!js.loaded) {
+ throw new Error('Clerk has not loaded yet');
+ }
+ });
+ await settle();
+
+ const harness: NativeSyncHarness = {
+ server,
+ native,
+ js,
+ persistentTokenCache,
+ jsDeviceToken: () => js.currentDeviceToken(),
+ nativeModuleCalls,
+ run: async action => {
+ let result!: Awaited>;
+ await act(async () => {
+ result = await action();
+ });
+ return result;
+ },
+ settle,
+ awaitJsToNativeSync: () => waitForPendingJsToNativeSync(),
+ pullNativeToJs: () => synchronizeNativeClientToJs(),
+ releaseNativeStartup,
+ unmount: () => view.unmount(),
+ };
+ return harness;
+};
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx
new file mode 100644
index 00000000000..5e2b0c7e261
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx
@@ -0,0 +1,65 @@
+import { type ReactNode, useEffect } from 'react';
+
+import type { TokenCache } from '../../../cache/types';
+import type { FakeClerk } from './fakeClerk';
+
+type BehaviorEnvironment = {
+ js: FakeClerk | null;
+ nativeModule: unknown;
+};
+
+// Lives on globalThis so module-level mock factories see the same state after `vi.resetModules()`.
+const registryKey = Symbol.for('@clerk/expo/nativeClientSyncBehavior');
+const globalRegistry = globalThis as typeof globalThis & { [registryKey]?: BehaviorEnvironment };
+
+export function activeEnvironment(): BehaviorEnvironment {
+ globalRegistry[registryKey] ??= { js: null, nativeModule: null };
+ return globalRegistry[registryKey];
+}
+
+export function setActiveEnvironment(environment: BehaviorEnvironment): void {
+ globalRegistry[registryKey] = environment;
+}
+
+export function getClerkInstanceForActiveEnvironment(options?: { tokenCache?: TokenCache }): FakeClerk {
+ const { js } = activeEnvironment();
+ if (!js) {
+ throw new Error('No native client sync behavior environment is active.');
+ }
+ js.tokenCache = options?.tokenCache;
+ return js;
+}
+
+/** Stands in for `InternalClerkProvider`, which loads the Clerk instance after mounting. */
+export function LoadingClerkProvider({ children, Clerk }: { children?: ReactNode; Clerk?: FakeClerk | null }) {
+ useEffect(() => {
+ void Clerk?.load();
+ }, [Clerk]);
+ return <>{children}>;
+}
+
+/** A persistent token cache such as one backed by `expo-secure-store`. */
+export class FakePersistentTokenCache implements TokenCache {
+ #values = new Map();
+
+ constructor(initial: Record = {}) {
+ for (const [key, value] of Object.entries(initial)) {
+ this.#values.set(key, value);
+ }
+ }
+
+ read(key: string): string | null {
+ return this.#values.get(key) ?? null;
+ }
+
+ getToken = (key: string): Promise => Promise.resolve(this.#values.get(key) ?? null);
+
+ saveToken = (key: string, token: string): Promise => {
+ this.#values.set(key, token);
+ return Promise.resolve();
+ };
+
+ clearToken = (key: string): void => {
+ this.#values.delete(key);
+ };
+}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
new file mode 100644
index 00000000000..9af865fa942
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
@@ -0,0 +1,261 @@
+import type { TokenCache } from '../../../cache/types';
+import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
+import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer';
+
+export type FakeSessionResource = {
+ id: string;
+ status: 'active';
+ user: { id: string; profileVersion: number };
+};
+
+export type JsEmission = {
+ sessionId: string | null;
+ signedInSessionIds: string[];
+};
+
+type ListenerCallback = (resources: { client: FakeClientResource; session: FakeSessionResource | null }) => void;
+type StatusListener = (status: string) => void;
+
+function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource {
+ return { id: session.id, status: 'active', user: { id: session.userId, profileVersion: session.profileVersion } };
+}
+
+/**
+ * Mirrors clerk-js' `Client` resource: `fetch` mutates the instance in place and returns it.
+ */
+export class FakeClientResource {
+ id = '';
+ sessions: FakeSessionResource[] = [];
+ lastActiveSessionId: string | null = null;
+ signIn = { id: '', status: null as string | null, createdSessionId: null as string | null };
+
+ constructor(private readonly clerk: FakeClerk) {}
+
+ get signedInSessions(): FakeSessionResource[] {
+ return this.sessions.filter(session => session.status === 'active');
+ }
+
+ fetch = async (_options?: { fetchMaxTries?: number }): Promise => {
+ const response = await this.clerk.fapi(token => this.clerk.server.getClient('js', token));
+ return this.fromJSON(response.client);
+ };
+
+ fromJSON(data: FakeServerClientJSON | null): this {
+ this.id = data?.id ?? '';
+ this.sessions = data?.sessions.map(toSessionResource) ?? [];
+ this.lastActiveSessionId = data?.lastActiveSessionId ?? null;
+ return this;
+ }
+
+ __internal_toSnapshot(): FakeServerClientJSON {
+ return {
+ id: this.id,
+ lastActiveSessionId: this.lastActiveSessionId,
+ sessions: this.sessions.map(session => ({
+ id: session.id,
+ userId: session.user.id,
+ profileVersion: session.user.profileVersion,
+ })),
+ };
+ }
+}
+
+/**
+ * Stand-in for the clerk-js instance returned by `getClerkInstance`, with the same surface the existing
+ * ClerkProvider tests mock. It talks to the fake Frontend API through the token cache it was built with,
+ * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`.
+ */
+export class FakeClerk {
+ loaded = false;
+ status = 'loading';
+ client: FakeClientResource | undefined = undefined;
+ session: FakeSessionResource | null = null;
+ __internal_setActiveInProgress = false;
+ /** Every state emitted to Clerk listeners (what `useAuth` consumers observe). */
+ readonly emissions: JsEmission[] = [];
+ tokenCache: TokenCache | undefined;
+ #listeners: ListenerCallback[] = [];
+ #statusListeners = new Set();
+ #onLoadedListeners: Array<() => void> = [];
+ #loading: Promise | null = null;
+
+ constructor(readonly server: FakeClerkServer) {}
+
+ async fapi(request: (token: string | null) => Promise): Promise {
+ const token = (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
+ const response = await request(token);
+ if (response.token) {
+ await this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token);
+ }
+ return response;
+ }
+
+ async currentDeviceToken(): Promise {
+ return (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
+ }
+
+ load = (): Promise => {
+ this.#loading ??= (async () => {
+ const client = await new FakeClientResource(this).fetch();
+ this.updateClient(client);
+ this.loaded = true;
+ this.status = 'ready';
+ for (const listener of [...this.#statusListeners]) {
+ listener('ready');
+ }
+ for (const listener of this.#onLoadedListeners.splice(0)) {
+ listener();
+ }
+ })();
+ return this.#loading;
+ };
+
+ addListener = (listener: ListenerCallback, options?: { skipInitialEmit?: boolean }): (() => void) => {
+ this.#listeners.push(listener);
+ if (this.client && !options?.skipInitialEmit) {
+ listener({ client: this.client, session: this.session });
+ }
+ return () => {
+ this.#listeners = this.#listeners.filter(candidate => candidate !== listener);
+ };
+ };
+
+ addOnLoaded = (listener: () => void): void => {
+ if (this.loaded) {
+ listener();
+ return;
+ }
+ this.#onLoadedListeners.push(listener);
+ };
+
+ on = (event: 'status', listener: StatusListener): void => {
+ if (event === 'status') {
+ this.#statusListeners.add(listener);
+ }
+ };
+
+ off = (event: 'status', listener: StatusListener): void => {
+ if (event === 'status') {
+ this.#statusListeners.delete(listener);
+ }
+ };
+
+ updateClient = (newClient: FakeClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }): void => {
+ if (!this.client) {
+ this.session = this.#defaultSession(newClient);
+ }
+ this.client = newClient;
+ if (this.session) {
+ const sessionId = this.session.id;
+ this.session = newClient.signedInSessions.find(session => session.id === sessionId) ?? null;
+ }
+ if (!options?.__internal_dangerouslySkipEmit) {
+ this.#emit();
+ }
+ };
+
+ setActive = async ({ session }: { session: FakeSessionResource | string | null }): Promise => {
+ this.__internal_setActiveInProgress = true;
+ try {
+ const sessionId = typeof session === 'string' ? session : (session?.id ?? null);
+ if (sessionId && this.client) {
+ const response = await this.fapi(token => this.server.touchSession('js', token, sessionId));
+ this.#applyPiggybackedClient(response);
+ }
+ this.session = sessionId ? (this.client?.signedInSessions.find(s => s.id === sessionId) ?? null) : null;
+ this.#emit();
+ } finally {
+ this.__internal_setActiveInProgress = false;
+ }
+ };
+
+ handleUnauthenticated = async (_options?: { broadcast?: boolean }): Promise => {
+ if (!this.client || !this.session) {
+ return;
+ }
+ const newClient = await this.client.fetch();
+ this.updateClient(newClient);
+ if (this.session) {
+ return;
+ }
+ return this.setActive({ session: null });
+ };
+
+ __internal_reloadInitialResources = async (): Promise => {
+ const client = this.client ?? new FakeClientResource(this);
+ await client.fetch({ fetchMaxTries: 1 });
+ this.updateClient(client);
+ this.#emit();
+ };
+
+ // JS user actions.
+
+ async signIn(userId: string): Promise {
+ const response = await this.fapi(token => this.server.signIn('js', token, userId));
+ this.#applyPiggybackedClient(response);
+ await this.setActive({ session: response.sessionId });
+ return response.sessionId;
+ }
+
+ async signOut(): Promise {
+ const response = await this.fapi(token => this.server.removeAllSessions('js', token));
+ this.#applyPiggybackedClient(response);
+ await this.setActive({ session: null });
+ }
+
+ async updateProfile(): Promise {
+ const sessionId = this.session?.id;
+ if (!sessionId) {
+ throw new Error('updateProfile requires an active session');
+ }
+ const response = await this.fapi(token => this.server.updateUser('js', token, sessionId));
+ if (response.status === 401) {
+ await this.handleUnauthenticated();
+ return;
+ }
+ this.#applyPiggybackedClient(response);
+ }
+
+ /** A session token request; a 401 goes through `handleUnauthenticated` like the clerk-js fetcher. */
+ async getSessionToken(): Promise {
+ const sessionId = this.session?.id;
+ if (!sessionId) {
+ return false;
+ }
+ const response = await this.fapi(token => this.server.createSessionToken('js', token, sessionId));
+ if (response.status === 401) {
+ await this.handleUnauthenticated();
+ return false;
+ }
+ return true;
+ }
+
+ #applyPiggybackedClient(response: FapiResponse): void {
+ if (response.status !== 200) {
+ return;
+ }
+ const client = this.client ?? new FakeClientResource(this);
+ this.updateClient(client.fromJSON(response.client));
+ }
+
+ #defaultSession(client: FakeClientResource): FakeSessionResource | null {
+ return (
+ client.signedInSessions.find(session => session.id === client.lastActiveSessionId) ??
+ client.signedInSessions[0] ??
+ null
+ );
+ }
+
+ #emit(): void {
+ if (!this.client) {
+ return;
+ }
+ this.emissions.push({
+ sessionId: this.session?.id ?? null,
+ signedInSessionIds: this.client.signedInSessions.map(session => session.id),
+ });
+ for (const listener of [...this.#listeners]) {
+ listener({ client: this.client, session: this.session });
+ }
+ }
+}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts
new file mode 100644
index 00000000000..47708f092e1
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts
@@ -0,0 +1,162 @@
+import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer';
+
+export type NativeClientChange = {
+ client: boolean;
+ deviceToken: boolean;
+};
+
+export type NativeClientChangeListener = (change: NativeClientChange, origin: NativeChangeOrigin) => void;
+
+/** `native` for changes the native SDK made on its own, `js` for changes JS asked native to make. */
+export type NativeChangeOrigin = { kind: 'native' } | { kind: 'js'; tag: string | null };
+
+function sameClient(a: FakeServerClientJSON | null, b: FakeServerClientJSON | null): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+/**
+ * The native Clerk SDK: it persists its own device token (keychain / keystore), keeps its own view of
+ * the client, and notifies listeners when either changes. The native module surface that exposes it to
+ * JS is engine specific and lives in the adapter.
+ */
+export class FakeNativeClerk {
+ token: string | null;
+ client: FakeServerClientJSON | null = null;
+ isConfigured = false;
+ /** Times native fetched its client from the Frontend API. */
+ clientRefreshCount = 0;
+ #listeners = new Set();
+ #heldChanges: Array<[NativeClientChange, NativeChangeOrigin]> | null = null;
+ #startupGate: Promise | null = null;
+ #nextRefreshError: Error | null = null;
+
+ constructor(
+ private readonly server: FakeClerkServer,
+ initialToken: string | null = null,
+ ) {
+ this.token = initialToken;
+ }
+
+ get activeSessionId(): string | null {
+ return this.client?.lastActiveSessionId ?? null;
+ }
+
+ get sessionIds(): string[] {
+ return this.client?.sessions.map(session => session.id) ?? [];
+ }
+
+ onChange(listener: NativeClientChangeListener): () => void {
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ /** Native keeps its change notifications queued until `releaseChangeNotifications`. */
+ holdChangeNotifications(): void {
+ this.#heldChanges ??= [];
+ }
+
+ releaseChangeNotifications(): void {
+ const held = this.#heldChanges ?? [];
+ this.#heldChanges = null;
+ for (const [change, origin] of held) {
+ this.emitChange(change, origin);
+ }
+ }
+
+ /** Native stays in its startup phase until the returned release function is called. */
+ delayStartup(): () => void {
+ let release!: () => void;
+ this.#startupGate = new Promise(resolve => {
+ release = resolve;
+ });
+ return release;
+ }
+
+ failNextClientRefresh(error: Error): void {
+ this.#nextRefreshError = error;
+ }
+
+ // Primitives used by engine adapters.
+
+ async waitForStartup(): Promise {
+ await this.#startupGate;
+ }
+
+ async refreshClient(): Promise {
+ this.clientRefreshCount += 1;
+ if (this.#nextRefreshError) {
+ const error = this.#nextRefreshError;
+ this.#nextRefreshError = null;
+ throw error;
+ }
+ this.#apply(await this.server.getClient('native', this.token));
+ }
+
+ async adoptToken(token: string | null): Promise {
+ this.token = token;
+ await this.refreshClient();
+ }
+
+ emitChange(change: NativeClientChange, origin: NativeChangeOrigin): void {
+ if (this.#heldChanges) {
+ this.#heldChanges.push([change, origin]);
+ return;
+ }
+ for (const listener of this.#listeners) {
+ listener(change, origin);
+ }
+ }
+
+ // Native user actions (native UI components, native-only flows).
+
+ async signIn(userId: string): Promise {
+ const response = await this.#observe(() => this.server.signIn('native', this.token, userId));
+ return response.sessionId;
+ }
+
+ async setActiveSession(sessionId: string): Promise {
+ await this.#observe(() => this.server.touchSession('native', this.token, sessionId));
+ }
+
+ async signOutSession(sessionId: string): Promise {
+ await this.#observe(() => this.server.removeSession('native', this.token, sessionId));
+ }
+
+ async signOut(): Promise {
+ await this.#observe(() => this.server.removeAllSessions('native', this.token));
+ }
+
+ async rotateDeviceToken(): Promise {
+ if (this.token) {
+ this.server.rotateTokenOnNextResponse(this.token);
+ }
+ await this.#observe(() => this.server.getClient('native', this.token));
+ }
+
+ /** Native ends up on a brand new client with no sessions, e.g. after losing its keychain entry. */
+ async switchToForeignSessionlessClient(): Promise {
+ await this.#observe(() => this.server.createSessionlessClient('native'));
+ }
+
+ #apply(response: FapiResponse): void {
+ if (response.token) {
+ this.token = response.token;
+ }
+ this.client = response.client;
+ }
+
+ async #observe(request: () => Promise): Promise {
+ const previousToken = this.token;
+ const previousClient = this.client;
+ const response = await request();
+ this.#apply(response);
+ const change = {
+ client: !sameClient(previousClient, this.client),
+ deviceToken: previousToken !== this.token,
+ };
+ if (change.client || change.deviceToken) {
+ this.emitChange(change, { kind: 'native' });
+ }
+ return response;
+ }
+}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
new file mode 100644
index 00000000000..637a7d84d51
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
@@ -0,0 +1,214 @@
+export type FakeServerSessionJSON = {
+ id: string;
+ userId: string;
+ profileVersion: number;
+};
+
+export type FakeServerClientJSON = {
+ id: string;
+ sessions: FakeServerSessionJSON[];
+ lastActiveSessionId: string | null;
+};
+
+export type FapiCaller = 'js' | 'native';
+
+export type FapiResponse = {
+ status: 200 | 401;
+ client: FakeServerClientJSON | null;
+ /** The device token the response hands back, like the `authorization` response header. */
+ token: string | null;
+};
+
+export type FapiRequestLogEntry = {
+ caller: FapiCaller;
+ operation: string;
+ token: string | null;
+};
+
+function networkRoundTrip(): Promise {
+ return new Promise(resolve => setTimeout(resolve, 0));
+}
+
+function cloneClient(client: FakeServerClientJSON): FakeServerClientJSON {
+ return {
+ id: client.id,
+ lastActiveSessionId: client.lastActiveSessionId,
+ sessions: client.sessions.map(session => ({ ...session })),
+ };
+}
+
+/**
+ * A single Frontend API shared by the JS and native SDKs. Clients are keyed by device token, and
+ * both SDKs only ever see server state through these requests.
+ */
+export class FakeClerkServer {
+ readonly requests: FapiRequestLogEntry[] = [];
+ #clients = new Map();
+ #clientIdByToken = new Map();
+ #pendingRotations = new Map();
+ #sequence = 0;
+
+ #nextId(prefix: string): string {
+ this.#sequence += 1;
+ return `${prefix}_${this.#sequence}`;
+ }
+
+ #clientFor(token: string | null): FakeServerClientJSON | null {
+ if (!token) {
+ return null;
+ }
+ const clientId = this.#clientIdByToken.get(token);
+ return clientId ? (this.#clients.get(clientId) ?? null) : null;
+ }
+
+ #createClient(): { client: FakeServerClientJSON; token: string } {
+ const client: FakeServerClientJSON = { id: this.#nextId('client'), sessions: [], lastActiveSessionId: null };
+ const token = this.#nextId(`device_token_${client.id}`);
+ this.#clients.set(client.id, client);
+ this.#clientIdByToken.set(token, client.id);
+ return { client, token };
+ }
+
+ #respond(token: string | null, client: FakeServerClientJSON | null, status: 200 | 401 = 200): FapiResponse {
+ let responseToken = client ? token : null;
+ const pendingRotations = client ? (this.#pendingRotations.get(client.id) ?? 0) : 0;
+ if (client && pendingRotations > 0) {
+ this.#pendingRotations.set(client.id, pendingRotations - 1);
+ responseToken = this.#nextId(`device_token_${client.id}`);
+ this.#clientIdByToken.set(responseToken, client.id);
+ }
+ return { status, client: client ? cloneClient(client) : null, token: responseToken };
+ }
+
+ async #request(caller: FapiCaller, operation: string, token: string | null): Promise {
+ this.requests.push({ caller, operation, token });
+ await networkRoundTrip();
+ }
+
+ // Seeding helpers (no request is logged).
+
+ seedClient({ users = [], activeUser }: { users?: string[]; activeUser?: string } = {}): {
+ token: string;
+ clientId: string;
+ sessionIds: Record;
+ } {
+ const { client, token } = this.#createClient();
+ const sessionIds: Record = {};
+ for (const userId of users) {
+ const session = { id: this.#nextId('sess'), userId, profileVersion: 0 };
+ client.sessions.push(session);
+ sessionIds[userId] = session.id;
+ }
+ const activeUserId = activeUser ?? users[0];
+ client.lastActiveSessionId = activeUserId ? sessionIds[activeUserId] : null;
+ return { token, clientId: client.id, sessionIds };
+ }
+
+ rotateTokenOnNextResponse(token: string): void {
+ const client = this.#clientFor(token);
+ if (client) {
+ this.#pendingRotations.set(client.id, (this.#pendingRotations.get(client.id) ?? 0) + 1);
+ }
+ }
+
+ inspectClient(token: string | null): FakeServerClientJSON | null {
+ const client = this.#clientFor(token);
+ return client ? cloneClient(client) : null;
+ }
+
+ clientIdForToken(token: string | null): string | null {
+ return this.#clientFor(token)?.id ?? null;
+ }
+
+ requestCount(caller: FapiCaller, operation?: string): number {
+ return this.requests.filter(request => request.caller === caller && (!operation || request.operation === operation))
+ .length;
+ }
+
+ // Frontend API
+
+ async getClient(caller: FapiCaller, token: string | null): Promise {
+ await this.#request(caller, 'GET /client', token);
+ return this.#respond(token, this.#clientFor(token));
+ }
+
+ async createSessionlessClient(caller: FapiCaller): Promise {
+ await this.#request(caller, 'POST /client', null);
+ const { client, token } = this.#createClient();
+ return this.#respond(token, client);
+ }
+
+ async signIn(
+ caller: FapiCaller,
+ token: string | null,
+ userId: string,
+ ): Promise {
+ await this.#request(caller, 'POST /client/sign_ins', token);
+ let client = this.#clientFor(token);
+ let responseToken = token;
+ if (!client) {
+ const created = this.#createClient();
+ client = created.client;
+ responseToken = created.token;
+ }
+ const session = { id: this.#nextId('sess'), userId, profileVersion: 0 };
+ client.sessions.push(session);
+ client.lastActiveSessionId = session.id;
+ return { ...this.#respond(responseToken, client), sessionId: session.id };
+ }
+
+ async touchSession(caller: FapiCaller, token: string | null, sessionId: string): Promise {
+ await this.#request(caller, 'POST /client/sessions/:id/touch', token);
+ const client = this.#clientFor(token);
+ if (!client?.sessions.some(session => session.id === sessionId)) {
+ return this.#respond(token, client, 401);
+ }
+ client.lastActiveSessionId = sessionId;
+ return this.#respond(token, client);
+ }
+
+ async removeSession(caller: FapiCaller, token: string | null, sessionId: string): Promise {
+ await this.#request(caller, 'POST /client/sessions/:id/remove', token);
+ const client = this.#clientFor(token);
+ if (!client) {
+ return this.#respond(token, null, 401);
+ }
+ client.sessions = client.sessions.filter(session => session.id !== sessionId);
+ if (client.lastActiveSessionId === sessionId) {
+ client.lastActiveSessionId = client.sessions[0]?.id ?? null;
+ }
+ return this.#respond(token, client);
+ }
+
+ async removeAllSessions(caller: FapiCaller, token: string | null): Promise {
+ await this.#request(caller, 'DELETE /client/sessions', token);
+ const client = this.#clientFor(token);
+ if (client) {
+ client.sessions = [];
+ client.lastActiveSessionId = null;
+ }
+ return this.#respond(token, client);
+ }
+
+ async createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise {
+ await this.#request(caller, 'POST /client/sessions/:id/tokens', token);
+ const client = this.#clientFor(token);
+ const status = client?.sessions.some(session => session.id === sessionId) ? 200 : 401;
+ return this.#respond(token, client, status);
+ }
+
+ async updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise {
+ await this.#request(caller, 'PATCH /me', token);
+ const client = this.#clientFor(token);
+ const session = client?.sessions.find(candidate => candidate.id === sessionId);
+ if (!client || !session) {
+ return this.#respond(token, client, 401);
+ }
+ for (const candidate of client.sessions) {
+ if (candidate.userId === session.userId) {
+ candidate.profileVersion += 1;
+ }
+ }
+ return this.#respond(token, client);
+ }
+}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
new file mode 100644
index 00000000000..0eedec3ac2c
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
@@ -0,0 +1,49 @@
+import type { FakePersistentTokenCache } from './environment';
+import type { FakeClerk } from './fakeClerk';
+import type { FakeNativeClerk } from './fakeNativeClerk';
+import type { FakeClerkServer } from './fakeServer';
+
+export type NativeSyncScenario = {
+ /** Shared Frontend API; seed it before rendering to start from existing clients. */
+ server?: FakeClerkServer;
+ /**
+ * Device token in an app-provided persistent token cache. When omitted (and `persistentTokenCache`
+ * is not set), ClerkProvider runs with its default in-memory token cache.
+ */
+ jsDeviceToken?: string | null;
+ persistentTokenCache?: boolean;
+ /** Device token the native SDK has persisted from a previous launch. */
+ nativeDeviceToken?: string | null;
+ nativeModule?: 'present' | 'absent';
+ disableNativeClientSync?: boolean;
+ /** Keep the native SDK in its startup phase until `releaseNativeStartup` is called. */
+ delayNativeStartup?: boolean;
+};
+
+/**
+ * Engine-agnostic handle on a rendered ClerkProvider wired to a fake Frontend API, a fake native SDK,
+ * and a clerk-js stand-in. Behavior tests only talk to this; engine specifics stay in the adapter.
+ */
+export type NativeSyncHarness = {
+ server: FakeClerkServer;
+ native: FakeNativeClerk;
+ js: FakeClerk;
+ /** The app-provided persistent token cache, when the scenario uses one. */
+ persistentTokenCache: FakePersistentTokenCache | null;
+ /** The device token the next JS Frontend API request authenticates with. */
+ jsDeviceToken: () => Promise;
+ /** Names of native module methods the engine has called. */
+ nativeModuleCalls: string[];
+ /** Runs a world action (JS or native) inside `act`. */
+ run: (action: () => T | Promise) => Promise;
+ /** Lets in-flight requests, notifications, and renders drain. */
+ settle: () => Promise;
+ /** What biometric flows do before a native operation: wait until native reflects JS. */
+ awaitJsToNativeSync: () => Promise;
+ /** What biometric flows do after a native operation: bring JS up to date with native. */
+ pullNativeToJs: () => Promise;
+ releaseNativeStartup: () => void;
+ unmount: () => void;
+};
+
+export type RenderWithNativeSync = (scenario?: NativeSyncScenario) => Promise;
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
new file mode 100644
index 00000000000..146c41f6751
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
@@ -0,0 +1,4 @@
+// Swap this export to run the behavior suite against a different sync engine.
+export { renderWithNativeSync } from './currentEngineAdapter';
+export { FakeClerkServer } from './fakeServer';
+export type { NativeSyncHarness, NativeSyncScenario } from './harness';
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
new file mode 100644
index 00000000000..8518d6d5ed0
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
@@ -0,0 +1,398 @@
+import { waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
+import { FakeClerkServer, type NativeSyncHarness, renderWithNativeSync } from './index';
+
+vi.mock('../../../polyfills', () => ({}));
+
+vi.mock('react-native', () => ({
+ NativeModules: { BlobModule: {} },
+ Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } },
+}));
+
+vi.mock('expo-secure-store', () => ({
+ AFTER_FIRST_UNLOCK: 0,
+ deleteItemAsync: vi.fn(),
+ getItemAsync: vi.fn(),
+ setItemAsync: vi.fn(),
+}));
+
+vi.mock('../../../utils/runtime', () => ({
+ isNative: () => true,
+ isWeb: () => false,
+}));
+
+vi.mock('@clerk/react/internal', async () => {
+ const { LoadingClerkProvider } = await import('./environment');
+ return { InternalClerkProvider: LoadingClerkProvider };
+});
+
+vi.mock('../../singleton', async () => {
+ const { getClerkInstanceForActiveEnvironment } = await import('./environment');
+ return { getClerkInstance: getClerkInstanceForActiveEnvironment };
+});
+
+vi.mock('../../../specs/NativeClerkModule', async () => {
+ const { activeEnvironment } = await import('./environment');
+ return {
+ get default() {
+ return activeEnvironment().nativeModule;
+ },
+ };
+});
+
+let harness: NativeSyncHarness | undefined;
+
+async function start(...args: Parameters): Promise {
+ harness = await renderWithNativeSync(...args);
+ return harness;
+}
+
+afterEach(() => {
+ harness?.unmount();
+ harness = undefined;
+});
+
+function seedDevice(users: string[] = ['user_1'], activeUser?: string) {
+ const server = new FakeClerkServer();
+ return { server, ...server.seedClient({ users, activeUser }) };
+}
+
+function signedOutEmissionsSince(h: NativeSyncHarness, emissionIndex: number) {
+ return h.js.emissions.slice(emissionIndex).filter(emission => emission.sessionId === null);
+}
+
+/** Both SDKs authenticate with the same device token and agree on the client's sessions. */
+async function expectConverged(h: NativeSyncHarness): Promise {
+ await waitFor(async () => {
+ const jsDeviceToken = await h.jsDeviceToken();
+ expect(h.native.token).toBe(jsDeviceToken);
+ expect(h.server.clientIdForToken(h.native.token)).toBe(h.js.client?.id || null);
+ expect(h.native.sessionIds).toEqual(h.js.client?.signedInSessions.map(session => session.id) ?? []);
+ expect(h.native.activeSessionId).toBe(h.js.session?.id ?? null);
+ });
+}
+
+describe('native client sync behavior', () => {
+ describe('native-originated changes', () => {
+ it('signs JS in with the session a native sign-in created', async () => {
+ const h = await start();
+
+ const sessionId = await h.run(() => h.native.signIn('user_1'));
+
+ await waitFor(() => expect(h.js.session?.id).toBe(sessionId));
+ await expectConverged(h);
+ });
+
+ it('switches the JS active session when native switches sessions, without a signed-out emission', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ const emissionIndex = h.js.emissions.length;
+
+ await h.run(() => h.native.setActiveSession(sessionIds.user_2));
+
+ await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2));
+ await h.settle();
+ expect(h.js.session?.id).toBe(sessionIds.user_2);
+ expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
+ await expectConverged(h);
+ });
+
+ it('keeps the remaining session active in JS when native signs out of the active one', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const emissionIndex = h.js.emissions.length;
+
+ await h.run(() => h.native.signOutSession(sessionIds.user_1));
+
+ await waitFor(() => {
+ expect(h.js.session?.id).toBe(sessionIds.user_2);
+ expect(h.js.client?.signedInSessions.map(session => session.id)).toEqual([sessionIds.user_2]);
+ });
+ await h.settle();
+ expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
+ await expectConverged(h);
+ });
+
+ it('keeps the remaining session when a JS request is rejected for the removed session before native reports the change', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const emissionIndex = h.js.emissions.length;
+
+ h.native.holdChangeNotifications();
+ await h.run(() => h.native.signOutSession(sessionIds.user_1));
+ await h.run(() => h.js.getSessionToken());
+
+ await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2));
+
+ await h.run(() => h.native.releaseChangeNotifications());
+ await h.settle();
+ expect(h.js.session?.id).toBe(sessionIds.user_2);
+ expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
+ await expectConverged(h);
+ });
+
+ it('signs JS out when native signs out of the only session', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ await h.run(() => h.native.signOut());
+
+ await waitFor(() => expect(h.js.session).toBeNull());
+ expect(await h.jsDeviceToken()).toBe(token);
+ await expectConverged(h);
+ });
+
+ it('never replaces a signed-in JS client with a foreign sessionless native client', async () => {
+ const { server, token, clientId, sessionIds } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const emissionIndex = h.js.emissions.length;
+
+ await h.run(() => h.native.switchToForeignSessionlessClient());
+ await h.settle();
+
+ expect(h.js.client?.id).toBe(clientId);
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
+ expect(await h.jsDeviceToken()).toBe(token);
+ await expectConverged(h);
+ });
+
+ it('moves JS to the rotated device token when native rotates it', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const emissionIndex = h.js.emissions.length;
+
+ await h.run(() => h.native.rotateDeviceToken());
+ expect(h.native.token).not.toBe(token);
+
+ await waitFor(async () => expect(await h.jsDeviceToken()).toBe(h.native.token));
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
+ await expectConverged(h);
+ });
+
+ it('settles a native change without an echo loop', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const jsClientFetchesBefore = server.requestCount('js', 'GET /client');
+ const nativeRefreshesBefore = h.native.clientRefreshCount;
+
+ await h.run(() => h.native.setActiveSession(sessionIds.user_2));
+ await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_2));
+ await h.settle();
+
+ await expectConverged(h);
+ expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2);
+ expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(1);
+ });
+ });
+
+ describe('JS-originated changes', () => {
+ it('refreshes native onto the same token and signed-in client after a JS sign-in', async () => {
+ const h = await start();
+
+ const sessionId = await h.run(() => h.js.signIn('user_1'));
+
+ await waitFor(() => expect(h.native.activeSessionId).toBe(sessionId));
+ await expectConverged(h);
+ });
+
+ it('refreshes native to the signed-out client after a JS sign-out', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ await h.run(() => h.js.signOut());
+
+ await waitFor(() => expect(h.native.sessionIds).toEqual([]));
+ expect(h.js.session).toBeNull();
+ expect(h.native.token).toBe(token);
+ await expectConverged(h);
+ });
+
+ it('moves the native active session after JS switches sessions with setActive', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ await h.run(() => h.js.setActive({ session: sessionIds.user_2 }));
+
+ await waitFor(() => expect(h.native.activeSessionId).toBe(sessionIds.user_2));
+ await expectConverged(h);
+ });
+
+ it('moves native to the rotated device token when a JS response rotates it', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ server.rotateTokenOnNextResponse(token);
+ await h.run(() => h.js.updateProfile());
+ const rotatedToken = await h.jsDeviceToken();
+ expect(rotatedToken).not.toBe(token);
+
+ await waitFor(() => expect(h.native.token).toBe(rotatedToken));
+ await expectConverged(h);
+ });
+
+ it('coalesces a burst of JS client changes into a bounded number of native refreshes', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const refreshesBefore = h.native.clientRefreshCount;
+
+ await h.run(() => Promise.all(Array.from({ length: 10 }, () => h.js.updateProfile())));
+ await h.settle();
+
+ await waitFor(() => expect(h.native.client?.sessions[0]?.profileVersion).toBe(10));
+ expect(h.native.clientRefreshCount - refreshesBefore).toBeLessThanOrEqual(3);
+ await expectConverged(h);
+ });
+
+ it('surfaces a failed native refresh through the sync barrier until a later refresh succeeds', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const failure = new Error('native refresh failed');
+
+ h.native.failNextClientRefresh(failure);
+ await h.run(() => h.js.updateProfile());
+ await expect(h.run(() => h.awaitJsToNativeSync())).rejects.toBe(failure);
+
+ await h.run(() => h.js.updateProfile());
+ await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
+ await expectConverged(h);
+ });
+
+ it('settles a JS change without an echo loop', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const jsClientFetchesBefore = server.requestCount('js', 'GET /client');
+ const nativeRefreshesBefore = h.native.clientRefreshCount;
+
+ await h.run(() => h.js.setActive({ session: sessionIds.user_2 }));
+ await h.settle();
+
+ await expectConverged(h);
+ expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(1);
+ expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(2);
+ });
+ });
+
+ describe('startup', () => {
+ it('signs JS in from the native device token when the default in-memory JS cache is empty', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+
+ const h = await start({ server, nativeDeviceToken: token });
+
+ await waitFor(() => expect(h.js.session?.id).toBe(sessionIds.user_1));
+ expect(await h.jsDeviceToken()).toBe(token);
+ await expectConverged(h);
+ });
+
+ it('moves native onto the JS device token when native has none', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+
+ const h = await start({ server, jsDeviceToken: token });
+
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ await waitFor(() => expect(h.native.token).toBe(token));
+ await waitFor(() => expect(h.native.activeSessionId).toBe(sessionIds.user_1));
+ await expectConverged(h);
+ });
+
+ it('delivers a JS sign-in that happens while native is still starting up', async () => {
+ const h = await start({ delayNativeStartup: true });
+
+ const sessionId = await h.run(() => h.js.signIn('user_1'));
+ await h.run(() => h.releaseNativeStartup());
+
+ await waitFor(() => expect(h.native.activeSessionId).toBe(sessionId));
+ await expectConverged(h);
+ });
+ });
+
+ describe('explicit synchronization barrier (biometric flows)', () => {
+ it('lets a native operation observe the latest JS state and JS observe the native result', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ await h.run(async () => {
+ await h.js.setActive({ session: sessionIds.user_2 });
+ await h.awaitJsToNativeSync();
+ });
+ expect(h.native.activeSessionId).toBe(sessionIds.user_2);
+
+ h.native.holdChangeNotifications();
+ await h.run(async () => {
+ await h.native.setActiveSession(sessionIds.user_1);
+ await h.pullNativeToJs();
+ });
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+
+ await h.run(() => h.native.releaseChangeNotifications());
+ await h.settle();
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ await expectConverged(h);
+ });
+
+ it('lets JS observe a session created by a native sign-in once the pull resolves', async () => {
+ const h = await start();
+
+ await h.run(() => h.awaitJsToNativeSync());
+ h.native.holdChangeNotifications();
+ const sessionId = await h.run(async () => {
+ const createdSessionId = await h.native.signIn('user_1');
+ await h.pullNativeToJs();
+ return createdSessionId;
+ });
+
+ expect(h.js.client?.signedInSessions.map(session => session.id)).toContain(sessionId);
+ expect(h.js.session?.id).toBe(sessionId);
+ });
+ });
+
+ describe('without native client sync', () => {
+ it.each([
+ ['sync is disabled', { disableNativeClientSync: true }],
+ ['the native module is absent', { nativeModule: 'absent' as const }],
+ ])('makes no native calls and persists the JS device token in the token cache when %s', async (_, scenario) => {
+ const h = await start({ persistentTokenCache: true, ...scenario });
+
+ const sessionId = await h.run(() => h.js.signIn('user_1'));
+ await h.settle();
+
+ expect(h.js.session?.id).toBe(sessionId);
+ const cachedToken = h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY) ?? null;
+ expect(h.server.clientIdForToken(cachedToken)).toBe(h.js.client?.id);
+ expect(h.nativeModuleCalls).toEqual([]);
+ expect(h.native.token).toBeNull();
+ });
+ });
+
+ describe('known gaps in the current engine', () => {
+ // Native adopts whatever token JS passes to `configure`, so a stale JS token replaces native's signed-in one.
+ it.fails('keeps the native signed-in session when JS starts with a stale device token', async () => {
+ const server = new FakeClerkServer();
+ const stale = server.seedClient();
+ const signedIn = server.seedClient({ users: ['user_1'] });
+
+ const h = await start({ server, jsDeviceToken: stale.token, nativeDeviceToken: signedIn.token });
+
+ await waitFor(() => expect(h.js.session?.id).toBe(signedIn.sessionIds.user_1));
+ await expectConverged(h);
+ });
+
+ // Without compare-and-set, each side keeps its own rotation and they end on different tokens.
+ it.fails('converges on one device token when JS and native rotate it at the same time', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ server.rotateTokenOnNextResponse(token);
+ await h.run(() => Promise.all([h.native.rotateDeviceToken(), h.js.updateProfile()]));
+ await h.settle();
+
+ expect(await h.jsDeviceToken()).not.toBe(token);
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ await expectConverged(h);
+ });
+ });
+});
From e984e8bc3069b9fd8774ccac2710fe2337dd80c2 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:37:26 -0400
Subject: [PATCH 12/15] feat(expo): simplify native client sync engine
Replace the JS <-> native client sync engine with one where native storage
owns the single shared device token and each side refetches its own client
on a payload-free "client changed" signal. Codes against the new ClerkExpo
module contract (configureNative, getDeviceToken, setDeviceToken CAS,
refreshClient, clerkNativeClientInvalidated).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../simplify-expo-native-client-sync.md | 5 +
.../__tests__/reverification.test.ts | 22 +-
.../__tests__/useBiometricCredentials.test.ts | 50 +-
.../useBiometricCredentials.shared.ts | 18 +-
.../__tests__/useNativeClientEvents.test.ts | 103 -
.../__tests__/useSignInWithGoogle.test.ts | 7 +-
.../expo/src/hooks/useNativeClientEvents.ts | 100 -
packages/expo/src/provider/ClerkProvider.tsx | 66 +-
.../ClerkProvider.nativeClientSync.test.tsx | 2397 -----------------
.../currentEngineAdapter.tsx | 169 --
.../nativeClientSyncBehavior/fakeClerk.ts | 21 +-
.../nativeClientSyncBehavior/harness.ts | 3 +
.../nativeClientSyncBehavior/index.ts | 2 +-
.../nativeClientSync.behavior.test.tsx | 113 +-
.../newEngineAdapter.tsx | 162 ++
.../nativeClientSyncCoordinator.test.ts | 266 --
.../expo/src/provider/nativeClientSync.ts | 336 +++
.../expo/src/provider/nativeClientSync.tsx | 1296 ---------
.../provider/nativeClientSyncCoordinator.ts | 195 --
.../__tests__/createClerkInstance.test.ts | 27 +
.../provider/singleton/createClerkInstance.ts | 14 +-
.../src/specs/NativeClerkModule.android.ts | 12 +-
packages/expo/src/specs/NativeClerkModule.ts | 12 +-
.../src/utils/__tests__/native-module.test.ts | 9 +-
packages/expo/src/utils/native-module.ts | 18 +-
25 files changed, 732 insertions(+), 4691 deletions(-)
create mode 100644 .changeset/simplify-expo-native-client-sync.md
delete mode 100644 packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts
delete mode 100644 packages/expo/src/hooks/useNativeClientEvents.ts
delete mode 100644 packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
delete mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx
delete mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts
create mode 100644 packages/expo/src/provider/nativeClientSync.ts
delete mode 100644 packages/expo/src/provider/nativeClientSync.tsx
delete mode 100644 packages/expo/src/provider/nativeClientSyncCoordinator.ts
diff --git a/.changeset/simplify-expo-native-client-sync.md b/.changeset/simplify-expo-native-client-sync.md
new file mode 100644
index 00000000000..bb85e34b5db
--- /dev/null
+++ b/.changeset/simplify-expo-native-client-sync.md
@@ -0,0 +1,5 @@
+---
+'@clerk/expo': minor
+---
+
+Rewrite the sync between the Clerk JS client and the native Clerk SDK. Both now share a single device token stored by the native SDK, and startup makes fewer requests. This fixes a stale token in the app's `tokenCache` signing out a session that was signed in natively, and the two SDKs ending up on different tokens when both rotated the device token at the same time. Requires the matching native module that ships with the next native SDK versions.
diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
index 75978520dd1..759b2583cb7 100644
--- a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
+++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
@@ -1,17 +1,14 @@
import { renderHook } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
-import {
- __internal_resetNativeClientSyncCoordinator,
- registerNativeToJsSyncHandler,
- trackPendingJsToNativeSync,
-} from '../../provider/nativeClientSyncCoordinator';
import { useBiometricCredentials as useUnsupportedBiometrics } from '../useBiometricCredentials';
import { useBiometricCredentials as useAndroidBiometrics } from '../useBiometricCredentials.android';
import { useBiometricCredentials as useIosBiometrics } from '../useBiometricCredentials.ios';
const mocks = vi.hoisted(() => ({
useClerk: vi.fn(),
+ idle: vi.fn(),
+ synchronize: vi.fn(),
nativeModule: {
getTrustedDeviceAvailability: vi.fn(),
listTrustedDevices: vi.fn(),
@@ -24,6 +21,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk }));
vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule }));
+vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.synchronize }));
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }));
const session = {
@@ -32,13 +30,11 @@ const session = {
getToken: vi.fn(),
};
const clerk = { session: session as typeof session | null, setActive: vi.fn() };
-const synchronize = vi.fn();
-let unregister: () => void;
+const synchronize = mocks.synchronize;
beforeEach(() => {
vi.resetAllMocks();
- __internal_resetNativeClientSyncCoordinator();
- unregister = registerNativeToJsSyncHandler(synchronize);
+ mocks.idle.mockResolvedValue(undefined);
clerk.session = session;
mocks.useClerk.mockReturnValue(clerk);
session.getToken.mockResolvedValue('fresh-token');
@@ -50,8 +46,6 @@ beforeEach(() => {
});
});
-afterEach(() => unregister());
-
describe.each([
['iOS', useIosBiometrics],
['Android', useAndroidBiometrics],
@@ -115,7 +109,7 @@ test('incomplete verification returns the synchronized session without refreshin
test('waits for pending JS-to-native synchronization', async () => {
let finish!: () => void;
- trackPendingJsToNativeSync(
+ mocks.idle.mockReturnValueOnce(
new Promise(resolve => {
finish = resolve;
}),
@@ -130,7 +124,7 @@ test('waits for pending JS-to-native synchronization', async () => {
test('does not start a biometric operation after the active session changes during synchronization', async () => {
let finish!: () => void;
- trackPendingJsToNativeSync(
+ mocks.idle.mockReturnValueOnce(
new Promise(resolve => {
finish = resolve;
}),
diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
index 0c15d195c86..d5f8ddbaa89 100644
--- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
+++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts
@@ -1,11 +1,6 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-import {
- __internal_resetNativeClientSyncCoordinator,
- registerNativeToJsSyncHandler,
- trackPendingJsToNativeSync,
-} from '../../provider/nativeClientSyncCoordinator';
import { isBiometricCredentialError } from '../errors';
import { useBiometricCredentials as useBiometricCredentialsOnUnsupportedPlatform } from '../useBiometricCredentials';
import { useBiometricCredentials as useBiometricCredentialsOnAndroid } from '../useBiometricCredentials.android';
@@ -23,7 +18,8 @@ const mocks = vi.hoisted(() => ({
jsSignedInSessions: [{ id: 'sess_123' }],
useClerk: vi.fn(),
setActive: vi.fn(),
- synchronizeNativeClientToJs: vi.fn(),
+ idle: vi.fn(),
+ pullFromNative: vi.fn(),
isNativeModuleInstalled: true,
nativeModule: {
getTrustedDeviceAvailability: vi.fn(),
@@ -38,6 +34,11 @@ vi.mock('@clerk/react', () => ({
useClerk: mocks.useClerk,
}));
+vi.mock('../../provider/nativeClientSync', () => ({
+ idle: mocks.idle,
+ pullFromNative: mocks.pullFromNative,
+}));
+
vi.mock('../../utils/native-module', () => ({
get ClerkExpoModule() {
return mocks.isNativeModuleInstalled ? mocks.nativeModule : null;
@@ -68,12 +69,9 @@ function renderBiometricCredentials(useHook = useBiometricCredentialsOnIos) {
return renderHook(() => useHook()).result.current;
}
-let unregisterNativeToJsSyncHandler: (() => void) | undefined;
-
beforeEach(() => {
- __internal_resetNativeClientSyncCoordinator();
- unregisterNativeToJsSyncHandler = registerNativeToJsSyncHandler(mocks.synchronizeNativeClientToJs);
- mocks.synchronizeNativeClientToJs.mockResolvedValue(undefined);
+ mocks.idle.mockResolvedValue(undefined);
+ mocks.pullFromNative.mockResolvedValue(undefined);
mocks.useClerk.mockReturnValue({
client: { signIn: mocks.jsSignIn, signedInSessions: mocks.jsSignedInSessions },
setActive: mocks.setActive,
@@ -88,7 +86,6 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
- unregisterNativeToJsSyncHandler?.();
});
describe('useBiometricCredentials on iOS', () => {
@@ -117,7 +114,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise(resolve => {
finishNativeSync = resolve;
});
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({
isAvailable: true,
unavailableReason: null,
@@ -134,21 +131,12 @@ describe('useBiometricCredentials on iOS', () => {
});
test('rejects availability when native client synchronization times out', async () => {
- vi.useFakeTimers();
- let finishNativeSync!: () => void;
- const nativeSync = new Promise(resolve => {
- finishNativeSync = resolve;
- });
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockRejectedValueOnce(Object.assign(new Error('timed out'), { code: 'environment_unavailable' }));
- const availability = expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({
+ await expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({
code: 'environment_unavailable',
});
-
- await vi.advanceTimersByTimeAsync(5_000);
- await availability;
expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled();
- finishNativeSync();
});
test('lists biometric credentials and converts native timestamps to dates', async () => {
@@ -185,7 +173,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise(resolve => {
finishNativeSync = resolve;
});
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeBiometricCredential]);
const listing = renderBiometricCredentials().list();
@@ -230,7 +218,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise(resolve => {
finishNativeSync = resolve;
});
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeBiometricCredential);
const enrollment = renderBiometricCredentials().enroll();
@@ -262,7 +250,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise(resolve => {
finishNativeSync = resolve;
});
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({
...nativeBiometricCredential,
status: 'revoked',
@@ -362,7 +350,7 @@ describe('useBiometricCredentials on iOS', () => {
const nativeSync = new Promise(resolve => {
finishNativeSync = resolve;
});
- trackPendingJsToNativeSync(nativeSync);
+ mocks.idle.mockReturnValueOnce(nativeSync);
mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({
id: 'sia_123',
status: 'complete',
@@ -389,7 +377,7 @@ describe('useBiometricCredentials on iOS', () => {
status,
createdSessionId: null,
});
- mocks.synchronizeNativeClientToJs.mockImplementation(() => {
+ mocks.pullFromNative.mockImplementation(() => {
Object.assign(mocks.jsSignIn, {
id: 'sia_mfa',
status,
@@ -415,7 +403,7 @@ describe('useBiometricCredentials on iOS', () => {
status: 'complete',
createdSessionId: 'sess_123',
});
- mocks.synchronizeNativeClientToJs.mockReturnValue(
+ mocks.pullFromNative.mockReturnValue(
new Promise(resolve => {
finishSync = resolve;
}),
@@ -428,7 +416,7 @@ describe('useBiometricCredentials on iOS', () => {
didResolve = true;
return result;
});
- await vi.waitFor(() => expect(mocks.synchronizeNativeClientToJs).toHaveBeenCalled());
+ await vi.waitFor(() => expect(mocks.pullFromNative).toHaveBeenCalled());
expect(didResolve).toBe(false);
finishSync();
diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
index 65af8878503..213011066bf 100644
--- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
+++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
@@ -1,7 +1,7 @@
import { useClerk } from '@clerk/react';
import { useMemo } from 'react';
-import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../provider/nativeClientSyncCoordinator';
+import { idle, pullFromNative } from '../provider/nativeClientSync';
import type { NativeBiometricCredential, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types';
import { errorThrower } from '../utils/errors';
import { ClerkExpoModule } from '../utils/native-module';
@@ -65,18 +65,18 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
return {
getAvailability: async params => {
const nativeModule = getNativeModule();
- await waitForPendingJsToNativeSync();
+ await idle();
return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null);
},
list: async () => {
const nativeModule = getNativeModule();
- await waitForPendingJsToNativeSync();
+ await idle();
const credentials = await nativeModule.listTrustedDevices();
return credentials.map(toBiometricCredential);
},
enroll: async params => {
const nativeModule = getNativeModule();
- await waitForPendingJsToNativeSync();
+ await idle();
const credential = await nativeModule.enrollTrustedDevice(
params?.name ?? null,
params?.identifierHint ?? null,
@@ -87,7 +87,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
},
revoke: async id => {
const nativeModule = getNativeModule();
- await waitForPendingJsToNativeSync();
+ await idle();
const credential = await nativeModule.revokeTrustedDevice(id);
return toBiometricCredential(credential);
},
@@ -108,7 +108,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
if (!session) {
return errorThrower.throw('Biometric reverification requires an active session.');
}
- await waitForPendingJsToNativeSync();
+ await idle();
if (clerk.session?.id !== session.id) {
return errorThrower.throw('The active session changed before biometric reverification started.');
}
@@ -119,7 +119,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
if (verification.status === 'complete') {
session.clearCache();
}
- await synchronizeNativeClientToJs();
+ await pullFromNative();
const synchronizedSession = clerk.session;
if (synchronizedSession?.id !== session.id) {
return errorThrower.throw('The active session changed during biometric reverification.');
@@ -143,13 +143,13 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom
},
signIn: async params => {
const nativeModule = getNativeModule();
- await waitForPendingJsToNativeSync();
+ await idle();
const nativeSignIn = await nativeModule.signInWithTrustedDevice(
params?.id ?? null,
params?.identifierHint ?? null,
params?.reason ?? null,
);
- await synchronizeNativeClientToJs();
+ await pullFromNative();
const client = clerk.client;
const signIn = client?.signIn;
diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts
deleted file mode 100644
index fb8eaba5cae..00000000000
--- a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-
-import { type NativeClientSnapshot, useNativeClientEvents } from '../useNativeClientEvents';
-
-const mocks = vi.hoisted(() => {
- return {
- moduleAddListener: vi.fn(),
- nativeModule: {} as unknown,
- nativeListener: undefined as ((snapshot?: NativeClientSnapshot) => void) | undefined,
- remove: vi.fn(),
- };
-});
-
-vi.mock('../../utils/native-module', () => {
- return {
- get ClerkExpoModule() {
- return mocks.nativeModule;
- },
- isNativeSupported: true,
- };
-});
-
-describe('useNativeClientEvents', () => {
- beforeEach(() => {
- mocks.nativeModule = {
- addListener: mocks.moduleAddListener,
- };
- mocks.nativeListener = undefined;
- mocks.remove.mockReset();
- mocks.moduleAddListener.mockReset();
- mocks.moduleAddListener.mockImplementation((_eventName, listener) => {
- mocks.nativeListener = listener;
- return { remove: mocks.remove };
- });
- });
-
- afterEach(() => {
- cleanup();
- });
-
- test('stores native client change payloads', async () => {
- const { result, unmount } = renderHook(() => useNativeClientEvents());
-
- expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeClientChanged', expect.any(Function));
-
- act(() => {
- mocks.nativeListener?.({
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: 'device-token',
- sourceId: 'native-source',
- });
- });
-
- await waitFor(() => {
- expect(result.current.nativeClientEvent?.deviceToken).toBe('device-token');
- expect(result.current.nativeClientEvent?.changed).toEqual({
- client: false,
- deviceToken: true,
- });
- expect(result.current.nativeClientEvent?.sourceId).toBe('native-source');
- });
-
- unmount();
- });
-
- test('subscribes only while native client events are enabled', () => {
- const { rerender, unmount } = renderHook(({ enabled }) => useNativeClientEvents(enabled), {
- initialProps: { enabled: false },
- });
-
- expect(mocks.moduleAddListener).not.toHaveBeenCalled();
-
- rerender({ enabled: true });
- expect(mocks.moduleAddListener).toHaveBeenCalledTimes(1);
-
- rerender({ enabled: false });
- expect(mocks.remove).toHaveBeenCalledTimes(1);
-
- unmount();
- });
-
- test('does not subscribe modules without an Expo event emitter', () => {
- mocks.nativeModule = {
- configure: vi.fn(),
- getClientToken: vi.fn(),
- syncClientStateFromJs: vi.fn(),
- };
-
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
-
- const { unmount } = renderHook(() => useNativeClientEvents());
-
- expect(mocks.moduleAddListener).not.toHaveBeenCalled();
- expect(consoleError).not.toHaveBeenCalled();
-
- consoleError.mockRestore();
- unmount();
- });
-});
diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
index 36c38445505..88b941f4a04 100644
--- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
+++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
@@ -49,9 +49,10 @@ vi.mock('react-native', () => {
vi.mock('../../specs/NativeClerkModule', () => {
return {
default: {
- configure: vi.fn(),
- getClientToken: vi.fn(),
- syncClientStateFromJs: vi.fn(),
+ configureNative: vi.fn(),
+ getDeviceToken: vi.fn(),
+ setDeviceToken: vi.fn(),
+ refreshClient: vi.fn(),
},
};
});
diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts
deleted file mode 100644
index a48a2ee316c..00000000000
--- a/packages/expo/src/hooks/useNativeClientEvents.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module';
-
-const nativeClientChangedEvent = 'clerkNativeClientChanged';
-
-export interface NativeClientSnapshot {
- changed: {
- client: boolean;
- deviceToken: boolean;
- };
- deviceToken: string | null;
- sourceId?: string | null;
-}
-
-/**
- * Local marker for a native client event.
- */
-export interface NativeClientEvent extends NativeClientSnapshot {
- issuedAt: number;
-}
-
-interface UseNativeClientEventsReturn {
- nativeClientEvent: NativeClientEvent | null;
-}
-
-type RefreshClientEventSubscription = {
- remove: () => void;
-};
-
-type RefreshClientEventEmitter = {
- addListener: (
- eventName: typeof nativeClientChangedEvent,
- listener: (snapshot?: NativeClientSnapshot) => void,
- ) => RefreshClientEventSubscription;
-};
-
-function getNativeClientEventEmitter(): RefreshClientEventEmitter | null {
- if (ClerkExpo && typeof ClerkExpo.addListener === 'function') {
- return ClerkExpo as RefreshClientEventEmitter;
- }
-
- return null;
-}
-
-function isNativeClientSnapshot(snapshot: NativeClientSnapshot | undefined): snapshot is NativeClientSnapshot {
- return (
- typeof snapshot?.changed?.client === 'boolean' &&
- typeof snapshot.changed.deviceToken === 'boolean' &&
- (typeof snapshot.deviceToken === 'string' || snapshot.deviceToken === null)
- );
-}
-
-/**
- * Listens for native client events that should sync JS client state.
- */
-export function useNativeClientEvents(enabled = true): UseNativeClientEventsReturn {
- const [nativeClientEvent, setNativeClientEvent] = useState(null);
-
- useEffect(() => {
- if (!enabled) {
- setNativeClientEvent(null);
- return;
- }
-
- if (!isNativeSupported || !ClerkExpo) {
- return;
- }
-
- let subscription: { remove: () => void } | null = null;
-
- try {
- const eventEmitter = getNativeClientEventEmitter();
-
- if (!eventEmitter) {
- return;
- }
-
- subscription = eventEmitter.addListener(nativeClientChangedEvent, snapshot => {
- if (!isNativeClientSnapshot(snapshot)) {
- return;
- }
-
- setNativeClientEvent({ issuedAt: Date.now(), ...snapshot });
- });
- } catch (error) {
- if (__DEV__) {
- console.error('[useNativeClientEvents] Failed to set up event listener:', error);
- }
- }
-
- return () => {
- subscription?.remove();
- };
- }, [enabled]);
-
- return {
- nativeClientEvent,
- };
-}
diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx
index 8eb900d80f0..7c4fc4e72a5 100644
--- a/packages/expo/src/provider/ClerkProvider.tsx
+++ b/packages/expo/src/provider/ClerkProvider.tsx
@@ -2,19 +2,13 @@ import '../polyfills';
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal';
-import { useRef } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
import type { TokenCache } from '../cache/types';
+import { ClerkExpoModule } from '../utils/native-module';
import { isNative, isWeb } from '../utils/runtime';
import { maybeCompleteAuthSession } from './maybeCompleteAuthSession';
-import {
- type DeviceTokenCacheListener,
- NativeClientSync,
- type NativeRefreshFromJsController,
- useNativeClientBootstrap,
- useNativeClientEventSync,
- useSyncableTokenCache,
-} from './nativeClientSync';
+import { createNativeClientSync, type SyncableClerk } from './nativeClientSync';
import { getClerkInstance } from './singleton';
import type { BuildClerkOptions } from './singleton/types';
@@ -77,20 +71,18 @@ export function ClerkProvider(props: ClerkProviderProps>(new Set());
- const suppressTokenCacheNotificationsRef = useRef(0);
- const nativeRefreshFromJsControllerRef = useRef(null);
- const syncableTokenCache = useSyncableTokenCache({
- suppressTokenCacheNotificationsRef,
- tokenCache,
- tokenCacheListenersRef,
- });
+ const nativeModule = isNative() && !__experimental_disableNativeClientSync ? ClerkExpoModule : null;
+ const tokenCacheRef = useRef(tokenCache);
+ tokenCacheRef.current = tokenCache;
+ const nativeClientSync = useMemo(
+ () => (nativeModule ? createNativeClientSync(nativeModule, pk, () => tokenCacheRef.current) : null),
+ [nativeModule, pk],
+ );
const clerkInstance = isNative()
? getClerkInstance({
publishableKey: pk,
- tokenCache: syncableTokenCache,
+ tokenCache: nativeClientSync?.tokenCache ?? tokenCache,
proxyUrl,
domain,
__experimental_passkeys,
@@ -98,24 +90,13 @@ export function ClerkProvider(props: ClerkProviderProps
+ nativeClientSync && clerkInstance
+ ? nativeClientSync.attach(clerkInstance as unknown as SyncableClerk)
+ : undefined,
+ [nativeClientSync, clerkInstance],
+ );
// Needed for `useOAuth` / `useSSO` to work correctly on web — must stay synchronous during render
// so the redirect URL is caught before children mount. Resolves to a no-op on native via the
@@ -144,17 +125,6 @@ export function ClerkProvider(props: ClerkProviderProps
- {nativeClientSyncEnabled && (
-
- )}
{children}
);
diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
deleted file mode 100644
index ee2ff2a4d60..00000000000
--- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
+++ /dev/null
@@ -1,2397 +0,0 @@
-import { act, render, waitFor } from '@testing-library/react';
-import React, { type ReactNode, useEffect } from 'react';
-import { beforeEach, describe, expect, test, vi } from 'vitest';
-
-import { CLERK_CLIENT_JWT_KEY } from '../../constants';
-import { ClerkProvider } from '../ClerkProvider';
-import {
- __internal_resetNativeClientSyncCoordinator,
- synchronizeNativeClientToJs,
- waitForPendingJsToNativeSync,
-} from '../nativeClientSyncCoordinator';
-
-const mocks = vi.hoisted(() => {
- return {
- configure: vi.fn(),
- getClientToken: vi.fn(),
- nativeClientEvent: null as unknown,
- syncClientStateFromJs: vi.fn(),
- tokenCache: {
- clearToken: vi.fn(),
- getToken: vi.fn(),
- saveToken: vi.fn(),
- },
- clerkOptions: undefined as
- | {
- tokenCache?: {
- clearToken: (key: string) => void | Promise;
- getToken: (key: string) => Promise;
- saveToken: (key: string, token: string) => Promise;
- };
- }
- | undefined,
- clerkInstance: {
- __internal_setActiveInProgress: false,
- __internal_reloadInitialResources: vi.fn(),
- addListener: vi.fn(),
- addOnLoaded: vi.fn(),
- client: undefined as unknown,
- handleUnauthenticated: vi.fn(),
- loaded: false,
- off: vi.fn(),
- on: vi.fn(),
- session: undefined as unknown,
- setActive: vi.fn(),
- status: 'loading',
- updateClient: vi.fn(),
- },
- clerkListener: undefined as (() => void) | undefined,
- clerkOnLoaded: undefined as (() => void) | undefined,
- clerkStatusListener: undefined as ((status: string) => void) | undefined,
- };
-});
-
-vi.mock('../../polyfills', () => ({}));
-
-vi.mock('@clerk/react/internal', () => {
- return {
- InternalClerkProvider: ({ children }: { children: ReactNode }) =>
- React.createElement(React.Fragment, null, children),
- };
-});
-
-vi.mock('react-native', () => {
- return {
- NativeModules: {
- BlobModule: {},
- },
- Platform: {
- OS: 'ios',
- constants: {
- reactNativeVersion: {
- major: 0,
- minor: 81,
- patch: 0,
- },
- },
- },
- };
-});
-
-vi.mock('expo-secure-store', () => {
- return {
- AFTER_FIRST_UNLOCK: 0,
- deleteItemAsync: vi.fn(),
- getItemAsync: vi.fn(),
- setItemAsync: vi.fn(),
- };
-});
-
-vi.mock('../../hooks/useNativeClientEvents', () => {
- return {
- useNativeClientEvents: () => ({
- nativeClientEvent: mocks.nativeClientEvent,
- }),
- };
-});
-
-vi.mock('../../specs/NativeClerkModule', () => {
- return {
- default: {
- addListener: vi.fn(),
- configure: mocks.configure,
- getClientToken: mocks.getClientToken,
- syncClientStateFromJs: mocks.syncClientStateFromJs,
- },
- };
-});
-
-vi.mock('../../utils/runtime', () => {
- return {
- isNative: () => true,
- isWeb: () => false,
- };
-});
-
-vi.mock('../singleton', () => {
- return {
- getClerkInstance: (options?: { tokenCache?: typeof mocks.tokenCache }) => {
- mocks.clerkOptions = options;
- return mocks.clerkInstance;
- },
- };
-});
-
-function deferred(): { promise: Promise; resolve: () => void } {
- let resolve!: () => void;
- const promise = new Promise(innerResolve => {
- resolve = innerResolve;
- });
- return { promise, resolve };
-}
-
-function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } {
- let reject!: (error: Error) => void;
- const promise = new Promise((_resolve, innerReject) => {
- reject = innerReject;
- });
- return { promise, reject };
-}
-
-describe('ClerkProvider native client sync', () => {
- beforeEach(() => {
- __internal_resetNativeClientSyncCoordinator();
- vi.clearAllMocks();
- mocks.nativeClientEvent = null;
- mocks.configure.mockResolvedValue(undefined);
- mocks.getClientToken.mockResolvedValue(null);
- mocks.syncClientStateFromJs.mockResolvedValue(undefined);
- mocks.tokenCache.getToken.mockResolvedValue(null);
- mocks.tokenCache.saveToken.mockResolvedValue(undefined);
- mocks.tokenCache.clearToken.mockResolvedValue(undefined);
- mocks.clerkOptions = undefined;
- mocks.clerkInstance.__internal_setActiveInProgress = false;
- mocks.clerkInstance.__internal_reloadInitialResources.mockResolvedValue(undefined);
- mocks.clerkInstance.addOnLoaded = vi.fn();
- mocks.clerkInstance.client = undefined;
- mocks.clerkInstance.handleUnauthenticated = vi.fn().mockResolvedValue(undefined);
- mocks.clerkInstance.loaded = true;
- mocks.clerkInstance.off.mockReset();
- mocks.clerkInstance.on.mockReset();
- mocks.clerkInstance.session = undefined;
- mocks.clerkInstance.setActive.mockResolvedValue(undefined);
- mocks.clerkInstance.status = 'ready';
- mocks.clerkInstance.updateClient = vi.fn();
- mocks.clerkInstance.updateClient.mockImplementation(client => {
- mocks.clerkInstance.client = client;
- const currentSession = mocks.clerkInstance.session as { id?: string } | null | undefined;
- mocks.clerkInstance.session = currentSession
- ? client.signedInSessions.find((session: { id: string }) => session.id === currentSession.id) || null
- : currentSession;
- });
- mocks.clerkListener = undefined;
- mocks.clerkOnLoaded = undefined;
- mocks.clerkStatusListener = undefined;
- mocks.clerkInstance.addOnLoaded.mockImplementation(listener => {
- mocks.clerkOnLoaded = listener;
- });
- mocks.clerkInstance.addListener.mockImplementation(listener => {
- mocks.clerkListener = listener;
- return vi.fn();
- });
- mocks.clerkInstance.on.mockImplementation((event, listener) => {
- if (event === 'status') {
- mocks.clerkStatusListener = listener;
- }
- });
- });
-
- test('does not start native client synchronization when disabled', async () => {
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
-
- render(
- ,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(mocks.configure).not.toHaveBeenCalled();
- expect(mocks.getClientToken).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.addListener).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.handleUnauthenticated).toBe(originalHandleUnauthenticated);
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- });
-
- test('configures native once with the cached device token during StrictMode bootstrap', async () => {
- const configure = deferred();
- mocks.tokenCache.getToken.mockResolvedValue('client-token');
- mocks.getClientToken.mockResolvedValue('client-token');
- mocks.configure.mockReturnValue(configure.promise);
-
- render(
-
-
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token');
- });
- expect(mocks.configure).toHaveBeenCalledTimes(1);
- let didFinishWaiting = false;
- const waiting = waitForPendingJsToNativeSync().then(() => {
- didFinishWaiting = true;
- });
- await Promise.resolve();
- expect(didFinishWaiting).toBe(false);
-
- act(() => {
- configure.resolve();
- });
- await waiting;
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- });
-
- test('registers native bootstrap before child effects can await synchronization', async () => {
- const configure = deferred();
- mocks.configure.mockReturnValue(configure.promise);
- let didFinishWaiting = false;
-
- function Child() {
- useEffect(() => {
- void waitForPendingJsToNativeSync().then(() => {
- didFinishWaiting = true;
- });
- }, []);
- return null;
- }
-
- render(
-
-
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
- await Promise.resolve();
- expect(didFinishWaiting).toBe(false);
-
- act(() => {
- configure.resolve();
- });
- await waitFor(() => {
- expect(didFinishWaiting).toBe(true);
- });
- });
-
- test('syncs the native device token to JS after Clerk loads during bootstrap', async () => {
- mocks.clerkInstance.loaded = false;
- mocks.clerkInstance.status = 'loading';
- mocks.getClientToken.mockResolvedValue('native-client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.on).toHaveBeenCalledWith('status', expect.any(Function));
- });
- expect(mocks.configure).not.toHaveBeenCalled();
- expect(mocks.getClientToken).not.toHaveBeenCalled();
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- act(() => {
- mocks.clerkListener?.();
- });
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- await act(async () => {
- mocks.clerkInstance.loaded = true;
- mocks.clerkInstance.status = 'ready';
- mocks.clerkStatusListener?.('ready');
- });
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- });
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- expect(mocks.clerkInstance.off).toHaveBeenCalledWith('status', expect.any(Function));
- });
-
- test('syncs a JS token rotated during bootstrap to native exactly once', async () => {
- const configure = deferred();
- mocks.configure.mockReturnValue(configure.promise);
- mocks.tokenCache.getToken.mockResolvedValueOnce('cached-client-token').mockResolvedValue('rotated-client-token');
- mocks.getClientToken.mockResolvedValue('native-client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token');
- });
- expect(mocks.configure).toHaveBeenCalledTimes(1);
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- act(() => {
- configure.resolve();
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(
- 'rotated-client-token',
- 'clerk-expo-js-sync-bootstrap',
- true,
- true,
- );
- });
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1);
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- });
-
- test('flushes one JS client change that occurs after JS loads but before native is ready', async () => {
- const configure = deferred();
- mocks.configure.mockReturnValue(configure.promise);
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledTimes(1);
- expect(mocks.clerkInstance.addListener).toHaveBeenCalled();
- });
-
- act(() => {
- mocks.clerkListener?.();
- });
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- act(() => {
- configure.resolve();
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false);
- });
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1);
- });
-
- test('preserves native configure failures and keeps synchronization disabled', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- const configureError = new Error('native refresh failed');
- const firstConfigure = rejectableDeferred();
- mocks.configure.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledTimes(1);
- expect(mocks.clerkInstance.addListener).toHaveBeenCalled();
- });
-
- act(() => {
- mocks.clerkListener?.();
- firstConfigure.reject(configureError);
- });
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError);
- expect(mocks.configure).toHaveBeenCalledTimes(2);
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: true },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- consoleError.mockRestore();
- });
-
- test('does not wait for an obsolete native bootstrap after switching publishable keys', async () => {
- const obsoleteConfigure = deferred();
- mocks.configure.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null));
-
- rerender(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null));
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- expect(mocks.configure).toHaveBeenCalledTimes(2);
- });
-
- test('does not wait for an active native refresh after switching publishable keys', async () => {
- const obsoleteRefresh = rejectableDeferred();
- const obsoleteRefreshError = new Error('obsolete native refresh failed');
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null));
- await waitForPendingJsToNativeSync();
-
- mocks.syncClientStateFromJs.mockReturnValueOnce(obsoleteRefresh.promise);
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'obsolete-client-token');
- });
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(
- 'obsolete-client-token',
- expect.any(String),
- false,
- true,
- );
- });
-
- rerender(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null));
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
-
- obsoleteRefresh.reject(obsoleteRefreshError);
- await Promise.resolve();
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- });
-
- test('retries a transient native configure failure', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- const configureError = new Error('transient native refresh failure');
- mocks.configure.mockRejectedValueOnce(configureError);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2));
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: true },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() =>
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'),
- );
- consoleError.mockRestore();
- });
-
- test('disables synchronization when switching publishable keys fails to configure native', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- const configureError = new Error('native key switch failed');
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(1));
- await waitForPendingJsToNativeSync();
- mocks.configure.mockRejectedValue(configureError);
- mocks.tokenCache.saveToken.mockClear();
- mocks.syncClientStateFromJs.mockClear();
-
- rerender(
- ,
- );
-
- await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2));
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError);
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: true },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- expect(mocks.configure).toHaveBeenLastCalledWith('pk_test_456', null);
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- consoleError.mockRestore();
- });
-
- test('keeps native recovery authoritative when JS creates a client from an empty cache', async () => {
- mocks.tokenCache.getToken.mockResolvedValueOnce(null).mockResolvedValue('anonymous-js-token');
- mocks.getClientToken.mockResolvedValue('native-client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- });
- expect(mocks.configure).toHaveBeenCalledTimes(1);
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1);
- });
-
- test('does not notify native when the token cache writes the current token again', async () => {
- mocks.tokenCache.getToken.mockResolvedValue('client-token');
- mocks.getClientToken.mockResolvedValue('client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.addListener).toHaveBeenCalled();
- });
-
- mocks.syncClientStateFromJs.mockClear();
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- });
-
- test('syncs JS token cache changes when ClerkProvider uses the default token cache', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- render();
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockClear();
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true);
- });
- });
-
- test('reloads JS resources after native emits a device token change', async () => {
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- });
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- });
-
- test('reloads JS resources after native clears the device token', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
- mocks.tokenCache.saveToken.mockClear();
- mocks.tokenCache.clearToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: null,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- });
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything());
- expect(mocks.tokenCache.clearToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY);
- });
-
- test('reloads JS resources after a native client-only change without rewriting the token cache', async () => {
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
- mocks.tokenCache.saveToken.mockClear();
- mocks.tokenCache.clearToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: false,
- },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- });
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, expect.anything());
- expect(mocks.tokenCache.clearToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY);
- });
-
- test('does not bounce a JS client listener event while applying a native client change', async () => {
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => {
- mocks.clerkListener?.();
- });
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- });
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- });
-
- test('serializes native token writes while keeping cache notifications suppressed', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- const firstSave = deferred();
- const secondSave = deferred();
- mocks.tokenCache.saveToken
- .mockImplementationOnce(() => firstSave.promise)
- .mockImplementationOnce(() => secondSave.promise);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: 'native-client-token-1',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-1');
- });
-
- mocks.nativeClientEvent = {
- issuedAt: 2,
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: 'native-client-token-2',
- };
- rerender(
- ,
- );
-
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2');
-
- await act(async () => {
- firstSave.resolve();
- await Promise.resolve();
- await Promise.resolve();
- });
-
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2');
- });
-
- await act(async () => {
- secondSave.resolve();
- await Promise.resolve();
- await Promise.resolve();
- });
-
- await waitFor(() => {
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(2);
- });
- expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled();
- });
-
- test('emits the refreshed JS client after a native client update keeps the active session', async () => {
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1', lastName: 'Before' },
- };
- const updatedActiveSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1', lastName: 'After' },
- };
- const refreshedClient = {
- signedInSessions: [updatedActiveSession],
- lastActiveSessionId: 'session_1',
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [activeSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockResolvedValue(refreshedClient),
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: false,
- },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(originalUpdateClient).toHaveBeenCalledWith(refreshedClient);
- });
- expect(originalUpdateClient).not.toHaveBeenCalledWith(refreshedClient, {
- __internal_dangerouslySkipEmit: true,
- });
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled();
- });
-
- test('sets the refreshed native last active session without emitting a stale signed-out JS state', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const remainingSession = {
- id: 'session_2',
- status: 'active',
- user: { id: 'user_2' },
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockResolvedValue({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- }),
- };
- mocks.clerkInstance.session = removedSession;
- mocks.clerkInstance.setActive.mockImplementation(({ session }) => {
- mocks.clerkInstance.session = session;
- return Promise.resolve();
- });
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.clerkInstance.setActive.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession });
- });
- expect(originalUpdateClient).toHaveBeenCalledWith(
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- });
-
- test('does not explicitly sign JS out when a native client change leaves no signed-in sessions', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockResolvedValue({
- signedInSessions: [],
- lastActiveSessionId: null,
- }),
- };
- mocks.clerkInstance.session = removedSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- mocks.clerkInstance.setActive.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: null,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(originalUpdateClient).toHaveBeenCalledWith({
- signedInSessions: [],
- lastActiveSessionId: null,
- });
- });
- expect(originalUpdateClient).not.toHaveBeenCalledWith(
- {
- signedInSessions: [],
- lastActiveSessionId: null,
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled();
- });
-
- test('rejects a foreign sessionless client when refreshing mutates the JS client in place', async () => {
- const signedInSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- const client = {
- id: 'client_1',
- sessions: [signedInSession],
- lastActiveSessionId: 'session_1' as string | null,
- get signedInSessions() {
- return this.sessions;
- },
- __internal_toSnapshot() {
- return {
- id: this.id,
- sessions: this.sessions,
- last_active_session_id: this.lastActiveSessionId,
- };
- },
- fromJSON(snapshot: { id: string; sessions: (typeof signedInSession)[]; last_active_session_id: string | null }) {
- this.id = snapshot.id;
- this.sessions = snapshot.sessions;
- this.lastActiveSessionId = snapshot.last_active_session_id;
- return this;
- },
- fetch() {
- this.id = 'client_2';
- this.sessions = [];
- this.lastActiveSessionId = null;
- return Promise.resolve(this);
- },
- };
- const restoreClient = vi.spyOn(client, 'fromJSON');
-
- mocks.clerkInstance.client = client;
- mocks.clerkInstance.session = signedInSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
-
- originalUpdateClient.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: null,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(restoreClient).toHaveBeenCalled();
- });
-
- expect(client.id).toBe('client_1');
- expect(client.sessions).toEqual([signedInSession]);
- expect(client.signedInSessions).toEqual([signedInSession]);
- expect(client.lastActiveSessionId).toBe('session_1');
- expect(originalUpdateClient).toHaveBeenCalledWith(client);
- expect(mocks.clerkInstance.session).toBe(signedInSession);
- });
-
- test('keeps the remaining JS session when the old active session becomes unauthenticated', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const remainingSession = {
- id: 'session_2',
- status: 'active',
- user: { id: 'user_2' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockResolvedValue({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- }),
- };
- mocks.clerkInstance.session = removedSession;
- mocks.clerkInstance.setActive.mockImplementation(({ session }) => {
- mocks.clerkInstance.session = session;
- return Promise.resolve();
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(originalHandleUnauthenticated).not.toHaveBeenCalled();
- expect(originalUpdateClient).toHaveBeenCalledWith(
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession });
- });
-
- test('treats client payloads that remove the active session as a session switch when another session remains', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const remainingSession = {
- id: 'session_2',
- status: 'active',
- user: { id: 'user_2' },
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- };
- mocks.clerkInstance.session = removedSession;
- mocks.clerkInstance.setActive.mockImplementation(({ session }) => {
- mocks.clerkInstance.session = session;
- return Promise.resolve();
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient);
- });
-
- originalUpdateClient.mockClear();
-
- act(() => {
- mocks.clerkInstance.updateClient({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- });
- });
-
- await waitFor(() => {
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession });
- });
- expect(originalUpdateClient).toHaveBeenCalledWith(
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(originalUpdateClient).not.toHaveBeenCalledWith({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- });
- });
-
- test('does not start fallback activation during an explicit session transition', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const replacementSession = {
- id: 'session_2',
- status: 'active',
- user: { id: 'user_2' },
- };
- const replacementClient = {
- signedInSessions: [replacementSession],
- lastActiveSessionId: 'session_2',
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- };
- mocks.clerkInstance.session = removedSession;
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient);
- });
-
- originalUpdateClient.mockClear();
- mocks.clerkInstance.setActive.mockClear();
- mocks.clerkInstance.__internal_setActiveInProgress = true;
-
- act(() => {
- mocks.clerkInstance.updateClient(replacementClient);
- });
-
- expect(originalUpdateClient).toHaveBeenCalledOnce();
- expect(originalUpdateClient).toHaveBeenCalledWith(replacementClient, {
- __internal_dangerouslySkipEmit: true,
- });
- expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled();
- });
-
- test('keeps follow-up client updates suppressed while reconciling a removed active session', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const remainingSession = {
- id: 'session_2',
- status: 'active',
- user: { id: 'user_2' },
- };
- const originalUpdateClient = mocks.clerkInstance.updateClient;
- let resolveSetActive: (() => void) | undefined;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession, remainingSession],
- lastActiveSessionId: 'session_1',
- };
- mocks.clerkInstance.session = removedSession;
- mocks.clerkInstance.setActive.mockImplementation(({ session }) => {
- return new Promise(resolve => {
- resolveSetActive = () => {
- mocks.clerkInstance.session = session;
- resolve();
- };
- });
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient);
- });
-
- originalUpdateClient.mockClear();
-
- act(() => {
- mocks.clerkInstance.updateClient(
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- mocks.clerkInstance.updateClient({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- });
- });
-
- expect(originalUpdateClient).toHaveBeenNthCalledWith(
- 1,
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(originalUpdateClient).toHaveBeenNthCalledWith(
- 2,
- {
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- },
- { __internal_dangerouslySkipEmit: true },
- );
- expect(originalUpdateClient).not.toHaveBeenCalledWith({
- signedInSessions: [remainingSession],
- lastActiveSessionId: 'session_2',
- });
-
- await act(async () => {
- resolveSetActive?.();
- });
-
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledTimes(1);
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: remainingSession });
- });
-
- test('does not fall back to JS sign-out when stale unauthenticated recovery still has a native device token', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
- let reentersUnauthenticated = false;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockImplementation(async () => {
- if (reentersUnauthenticated) {
- await mocks.clerkInstance.handleUnauthenticated();
- }
- throw new Error('stale session 401');
- }),
- };
- mocks.clerkInstance.session = removedSession;
- mocks.getClientToken.mockResolvedValue('native-client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- reentersUnauthenticated = true;
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- expect(originalHandleUnauthenticated).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.setActive).not.toHaveBeenCalledWith({ session: null });
- });
-
- test('falls back to JS unauthenticated handling when native token recovery has no signed-in sessions', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: vi.fn().mockRejectedValue(new Error('stale session 401')),
- };
- mocks.clerkInstance.session = removedSession;
- mocks.getClientToken.mockResolvedValue('native-client-token');
- mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => {
- mocks.clerkInstance.client = {
- signedInSessions: [],
- lastActiveSessionId: null,
- };
- mocks.clerkInstance.session = null;
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token');
- expect(originalHandleUnauthenticated).toHaveBeenCalled();
- });
-
- test('runs native recovery once for a burst of unauthenticated responses', async () => {
- const removedSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
- const fetchClient = vi.fn().mockResolvedValue(null);
-
- mocks.clerkInstance.client = {
- signedInSessions: [removedSession],
- lastActiveSessionId: 'session_1',
- fetch: fetchClient,
- };
- mocks.clerkInstance.session = removedSession;
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(fetchClient).toHaveBeenCalledTimes(1);
- expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(2);
- });
-
- test('recovers again inside the cooldown window once native pushes a new device token', async () => {
- const session = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
- const fetchClient = vi.fn();
- const client = {
- id: 'client_1',
- signedInSessions: [session],
- lastActiveSessionId: 'session_1',
- fetch: fetchClient,
- };
- fetchClient.mockResolvedValue(client);
-
- mocks.clerkInstance.client = client;
- mocks.clerkInstance.session = session;
- mocks.getClientToken.mockResolvedValue('native-client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- // Bootstrap's suppressed device token write would swallow the rotation below.
- await act(async () => {
- await waitForPendingJsToNativeSync();
- });
-
- // Drop the client fetches the bootstrap already made; only the 401 handling matters here.
- fetchClient.mockClear();
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
- expect(fetchClient).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'rotated-native-client-token');
- });
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(fetchClient).toHaveBeenCalledTimes(2);
- expect(originalHandleUnauthenticated).not.toHaveBeenCalled();
- });
-
- test('keeps the cooldown when a failed recovery rolls the device token back', async () => {
- const session = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
- const fetchClient = vi.fn().mockRejectedValue(new Error('stale session 401'));
-
- mocks.clerkInstance.client = {
- id: 'client_1',
- signedInSessions: [session],
- lastActiveSessionId: 'session_1',
- fetch: fetchClient,
- };
- mocks.clerkInstance.session = session;
- // Cached token A differs from native token B, so the rollback write changes the cached value.
- mocks.tokenCache.getToken.mockResolvedValue('cached-token-A');
- mocks.getClientToken.mockResolvedValue('native-token-B');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalled();
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- // Bootstrap fetches the client too, and that fetch fails here, so drain its retries first.
- await act(async () => {
- await expect(waitForPendingJsToNativeSync()).rejects.toThrow('stale session 401');
- });
-
- fetchClient.mockClear();
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
- expect(fetchClient).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- // The rollback is internal recovery, not an external rotation, so the second 401 delegates to core.
- expect(fetchClient).toHaveBeenCalledTimes(1);
- expect(originalHandleUnauthenticated).toHaveBeenCalledTimes(1);
- });
-
- test('refreshes native from the server after the JS client changes', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.tokenCache.getToken.mockResolvedValue('client-token');
- act(() => {
- mocks.clerkListener?.();
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false);
- });
- });
-
- test('retries failed native state while processing a queued sync', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
- let rejectFirstSync: ((error: Error) => void) | undefined;
- mocks.syncClientStateFromJs.mockImplementationOnce(() => {
- return new Promise((_resolve, reject) => {
- rejectFirstSync = reject;
- });
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- act(() => {
- mocks.clerkListener?.();
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false);
- });
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- rejectFirstSync?.(new Error('native sync failed'));
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), true, true);
- });
- });
-
- test('keeps a pending native client refresh while a token sync is in flight', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
- let resolveFirstSync: (() => void) | undefined;
- mocks.syncClientStateFromJs.mockImplementationOnce(() => {
- return new Promise(resolve => {
- resolveFirstSync = resolve;
- });
- });
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true);
- });
-
- act(() => {
- mocks.clerkListener?.();
- });
-
- await act(async () => {
- resolveFirstSync?.();
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false);
- });
- });
-
- test('refreshes native with the saved token after the JS token cache changes', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockClear();
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true);
- });
- });
-
- test('tracks an in-flight device-token sync until native reconciliation completes', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- const nativeSync = deferred();
- mocks.syncClientStateFromJs.mockReturnValueOnce(nativeSync.promise);
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true);
- });
-
- let didFinishWaiting = false;
- const waiting = waitForPendingJsToNativeSync().then(() => {
- didFinishWaiting = true;
- });
- await Promise.resolve();
- expect(didFinishWaiting).toBe(false);
-
- nativeSync.resolve();
- await waiting;
- expect(didFinishWaiting).toBe(true);
- });
-
- test('preserves a failed native refresh until a later refresh succeeds', async () => {
- const error = new Error('native refresh failed');
- const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockRejectedValueOnce(error);
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'failed-client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('failed-client-token', expect.any(String), false, true);
- });
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(error);
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(error);
-
- mocks.syncClientStateFromJs.mockResolvedValueOnce(undefined);
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'recovered-client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(
- 'recovered-client-token',
- expect.any(String),
- false,
- true,
- );
- });
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- consoleWarn.mockRestore();
- });
-
- test('ignores a canceled native refresh that later rejects', async () => {
- const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
- await waitForPendingJsToNativeSync();
-
- const staleRefresh = rejectableDeferred();
- const staleError = new Error('canceled native refresh failed');
- mocks.syncClientStateFromJs.mockReturnValueOnce(staleRefresh.promise);
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'stale-client-token');
- });
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('stale-client-token', expect.any(String), false, true);
- });
-
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: true },
- deviceToken: 'native-client-token',
- };
- rerender(
- ,
- );
- await waitFor(() => {
- expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled();
- });
-
- staleRefresh.reject(staleError);
- await Promise.resolve();
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- consoleWarn.mockRestore();
- });
-
- test('awaits JS session activation during explicit native-to-JS synchronization', async () => {
- const activeSession = {
- id: 'sess_native',
- status: 'active',
- user: { id: 'user_native' },
- };
- const refreshedClient = {
- id: 'client_1',
- signIn: {
- id: 'sia_native',
- status: 'complete',
- createdSessionId: activeSession.id,
- },
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- };
- const fetchClient = vi.fn().mockResolvedValue(refreshedClient);
- mocks.tokenCache.getToken.mockResolvedValue('native-client-token');
- mocks.getClientToken.mockResolvedValue('native-client-token');
- mocks.clerkInstance.client = {
- id: 'client_1',
- signIn: { id: '', status: null, createdSessionId: null },
- signedInSessions: [],
- lastActiveSessionId: null,
- fetch: fetchClient,
- };
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'native-client-token');
- });
- fetchClient.mockClear();
-
- const activation = deferred();
- mocks.clerkInstance.setActive.mockReturnValueOnce(activation.promise);
- let didFinishSync = false;
- const sync = synchronizeNativeClientToJs().then(() => {
- didFinishSync = true;
- });
-
- await waitFor(() => {
- expect(fetchClient).toHaveBeenCalledTimes(1);
- expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: activeSession });
- });
- expect(didFinishSync).toBe(false);
-
- activation.resolve();
- await sync;
- expect(didFinishSync).toBe(true);
- });
-
- test('ignores native client events that echo a JS-originated sync', async () => {
- mocks.tokenCache.getToken.mockResolvedValue(null);
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null);
- });
-
- mocks.syncClientStateFromJs.mockClear();
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token');
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true);
- });
-
- const sourceId = mocks.syncClientStateFromJs.mock.calls[0]?.[1];
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: false,
- deviceToken: true,
- },
- deviceToken: 'client-token',
- sourceId,
- };
- rerender(
- ,
- );
-
- await act(async () => {});
-
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- });
-
- test('refreshes native from the server after the JS token cache is cleared', async () => {
- mocks.tokenCache.getToken.mockResolvedValue('client-token');
- mocks.getClientToken.mockResolvedValue('client-token');
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.clerkInstance.addListener).toHaveBeenCalled();
- });
-
- mocks.syncClientStateFromJs.mockClear();
-
- await act(async () => {
- await mocks.clerkOptions?.tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY);
- });
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), false, true);
- });
- });
-
- test('rejects a foreign session-less native client and restores the signed-in JS token', async () => {
- const jsDeviceToken = 'js-device-token';
- const nativeDeviceToken = 'native-device-token';
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const foreignClient = {
- id: 'client_foreign',
- signedInSessions: [],
- lastActiveSessionId: null,
- };
- const updateClient = mocks.clerkInstance.updateClient;
-
- mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken);
- mocks.getClientToken.mockResolvedValue(jsDeviceToken);
- mocks.clerkInstance.client = {
- id: 'client_js',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- fetch: vi.fn().mockResolvedValue(foreignClient),
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken);
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.tokenCache.saveToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: nativeDeviceToken,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken);
- });
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken);
- expect(updateClient).not.toHaveBeenCalledWith(foreignClient);
- expect(mocks.clerkInstance.session).toBe(activeSession);
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true);
- });
- });
-
- test('restores the signed-in JS token when native client verification fails', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
- const jsDeviceToken = 'js-device-token';
- const nativeDeviceToken = 'native-device-token';
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
-
- mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken);
- mocks.getClientToken.mockResolvedValue(jsDeviceToken);
- mocks.clerkInstance.client = {
- id: 'client_js',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- fetch: vi.fn().mockRejectedValue(new Error('verification failed')),
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken);
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.tokenCache.saveToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: nativeDeviceToken,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken);
- });
- expect(mocks.clerkInstance.session).toBe(activeSession);
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true);
- });
-
- consoleError.mockRestore();
- });
-
- test('does not replace a signed-in JS token when the native client cannot be verified', async () => {
- const jsDeviceToken = 'js-device-token';
- const nativeDeviceToken = 'native-device-token';
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
-
- mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken);
- mocks.getClientToken.mockResolvedValue(jsDeviceToken);
- mocks.clerkInstance.client = {
- id: 'client_js',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken);
- });
-
- mocks.syncClientStateFromJs.mockClear();
- mocks.tokenCache.saveToken.mockClear();
- mocks.clerkInstance.__internal_reloadInitialResources.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: nativeDeviceToken,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(jsDeviceToken, expect.any(String), false, true);
- });
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, nativeDeviceToken);
- expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.session).toBe(activeSession);
- });
-
- test('applies a session-less native response when it belongs to the current JS client', async () => {
- const jsDeviceToken = 'js-device-token';
- const nativeDeviceToken = 'native-device-token';
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const signedOutClient = {
- id: 'client_shared',
- signedInSessions: [],
- lastActiveSessionId: null,
- };
- const updateClient = mocks.clerkInstance.updateClient;
-
- mocks.tokenCache.getToken.mockResolvedValue(jsDeviceToken);
- mocks.getClientToken.mockResolvedValue(jsDeviceToken);
- mocks.clerkInstance.client = {
- id: 'client_shared',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- fetch: vi.fn().mockResolvedValue(signedOutClient),
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken);
- });
-
- mocks.tokenCache.saveToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: nativeDeviceToken,
- };
- rerender(
- ,
- );
-
- await waitFor(() => {
- expect(updateClient).toHaveBeenCalledWith(signedOutClient);
- });
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, jsDeviceToken);
- expect(mocks.clerkInstance.session).toBeNull();
- });
-
- test('rejects a foreign session-less native client during unauthenticated recovery', async () => {
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const foreignClient = {
- id: 'client_foreign',
- signedInSessions: [],
- lastActiveSessionId: null,
- };
- const updateClient = mocks.clerkInstance.updateClient;
- const originalHandleUnauthenticated = mocks.clerkInstance.handleUnauthenticated;
-
- mocks.tokenCache.getToken.mockResolvedValue('js-device-token');
- mocks.getClientToken.mockResolvedValue('js-device-token');
- mocks.clerkInstance.client = {
- id: 'client_js',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- fetch: vi.fn().mockResolvedValue(foreignClient),
- };
- mocks.clerkInstance.session = activeSession;
-
- render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token');
- });
- await waitFor(() => {
- expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated);
- });
-
- mocks.getClientToken.mockResolvedValue('ghost-device-token');
- mocks.tokenCache.saveToken.mockClear();
- mocks.syncClientStateFromJs.mockClear();
-
- await act(async () => {
- await mocks.clerkInstance.handleUnauthenticated();
- });
-
- expect(updateClient).not.toHaveBeenCalledWith(foreignClient);
- expect(mocks.clerkInstance.session).toBe(activeSession);
- expect(originalHandleUnauthenticated).not.toHaveBeenCalled();
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'ghost-device-token');
- expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'js-device-token');
- // The rollback write is notification-suppressed, so the restored token reaches native via a direct push.
- await waitFor(() => {
- expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('js-device-token', expect.any(String), false, true);
- });
- });
-
- test('skips native adoption when the cached device token read times out while signed in', async () => {
- const activeSession = {
- id: 'session_1',
- status: 'active',
- user: { id: 'user_1' },
- };
- const fetchClient = vi.fn().mockResolvedValue({
- id: 'client_foreign',
- signedInSessions: [],
- lastActiveSessionId: null,
- });
-
- mocks.tokenCache.getToken.mockResolvedValue('js-device-token');
- mocks.getClientToken.mockResolvedValue('js-device-token');
- mocks.clerkInstance.client = {
- id: 'client_js',
- signedInSessions: [activeSession],
- lastActiveSessionId: activeSession.id,
- fetch: fetchClient,
- };
- mocks.clerkInstance.session = activeSession;
-
- const { rerender } = render(
- ,
- );
-
- await waitFor(() => {
- expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token');
- });
-
- mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {}));
- mocks.tokenCache.saveToken.mockClear();
- mocks.tokenCache.clearToken.mockClear();
-
- mocks.nativeClientEvent = {
- issuedAt: 1,
- changed: {
- client: true,
- deviceToken: true,
- },
- deviceToken: 'native-device-token',
- };
- rerender(
- ,
- );
-
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 1_200));
- });
-
- expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-device-token');
- expect(mocks.tokenCache.clearToken).not.toHaveBeenCalled();
- expect(fetchClient).not.toHaveBeenCalled();
- expect(mocks.clerkInstance.session).toBe(activeSession);
- });
-});
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
deleted file mode 100644
index 5671ed55ce7..00000000000
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/currentEngineAdapter.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { act, render, waitFor } from '@testing-library/react';
-import { vi } from 'vitest';
-
-import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
-import type { NativeClientSnapshot } from '../../../hooks/useNativeClientEvents';
-import type { ClerkExpoNativeModule } from '../../../utils/native-module';
-import { FakePersistentTokenCache, setActiveEnvironment } from './environment';
-import { FakeClerk } from './fakeClerk';
-import { FakeNativeClerk } from './fakeNativeClerk';
-import { FakeClerkServer } from './fakeServer';
-import type { NativeSyncHarness, RenderWithNativeSync } from './harness';
-
-const nativeClientChangedEvent = 'clerkNativeClientChanged';
-
-function serializeClient(native: FakeNativeClerk): string {
- return JSON.stringify(native.client);
-}
-
-/**
- * Today's `ClerkExpo` native module contract, modeled on the iOS bridge: `configure` adopts a bearer
- * token, `syncClientStateFromJs` adopts/refreshes and echoes a change tagged with the JS `sourceId`, and
- * native-originated changes are emitted as `clerkNativeClientChanged` snapshots.
- */
-function createCurrentNativeModule(native: FakeNativeClerk, calls: string[]): ClerkExpoNativeModule {
- const listeners = new Set<(snapshot: NativeClientSnapshot) => void>();
-
- native.onChange((change, origin) => {
- const snapshot: NativeClientSnapshot = {
- changed: { client: change.client, deviceToken: change.deviceToken },
- deviceToken: native.token,
- ...(origin.kind === 'js' && origin.tag ? { sourceId: origin.tag } : {}),
- };
- for (const listener of [...listeners]) {
- listener(snapshot);
- }
- });
-
- return {
- addListener: (eventName: string, listener?: (...args: unknown[]) => void) => {
- calls.push('addListener');
- if (eventName !== nativeClientChangedEvent || !listener) {
- return { remove: () => undefined };
- }
- const typedListener = listener as (snapshot: NativeClientSnapshot) => void;
- listeners.add(typedListener);
- return { remove: () => listeners.delete(typedListener) };
- },
- configure: async (_publishableKey: string, bearerToken: string | null) => {
- calls.push('configure');
- await native.waitForStartup();
- if (bearerToken && (bearerToken !== native.token || !native.client)) {
- await native.adoptToken(bearerToken);
- } else {
- await native.refreshClient();
- }
- native.isConfigured = true;
- },
- getClientToken: () => {
- calls.push('getClientToken');
- return Promise.resolve(native.token);
- },
- syncClientStateFromJs: async (
- deviceToken: string | null,
- sourceId: string | null,
- didChangeClient: boolean,
- didChangeDeviceToken: boolean,
- ) => {
- calls.push('syncClientStateFromJs');
- if (!native.isConfigured) {
- return;
- }
- const previousToken = native.token;
- const previousClient = serializeClient(native);
- const changeSincePrevious = () => ({
- client: serializeClient(native) !== previousClient,
- deviceToken: native.token !== previousToken,
- });
- try {
- if (didChangeDeviceToken && deviceToken && deviceToken !== native.token) {
- await native.adoptToken(deviceToken);
- } else if (didChangeClient || didChangeDeviceToken) {
- await native.refreshClient();
- }
- } catch (error) {
- const change = changeSincePrevious();
- if (change.client || change.deviceToken) {
- native.emitChange(change, { kind: 'native' });
- }
- throw error;
- }
- native.emitChange(changeSincePrevious(), { kind: 'js', tag: sourceId });
- },
- };
-}
-
-async function flushMacrotasks(count: number): Promise {
- for (let i = 0; i < count; i++) {
- await new Promise(resolve => setTimeout(resolve, 0));
- }
-}
-
-export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => {
- const server = scenario.server ?? new FakeClerkServer();
- const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null);
- const js = new FakeClerk(server);
- const persistentTokenCache =
- scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined
- ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {})
- : null;
- const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined;
- const nativeModuleCalls: string[] = [];
-
- setActiveEnvironment({
- js,
- nativeModule: scenario.nativeModule === 'absent' ? null : createCurrentNativeModule(native, nativeModuleCalls),
- });
-
- // Fresh engine modules per scenario: the native module is resolved at import time and the
- // coordinator and default in-memory token cache are module singletons.
- vi.resetModules();
- const { ClerkProvider } = await import('../../ClerkProvider');
- const { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } =
- await import('../../nativeClientSyncCoordinator');
-
- const view = render(
- ,
- );
-
- const settle = async () => {
- for (let round = 0; round < 8; round++) {
- await act(async () => {
- await flushMacrotasks(10);
- });
- }
- };
-
- await waitFor(() => {
- if (!js.loaded) {
- throw new Error('Clerk has not loaded yet');
- }
- });
- await settle();
-
- const harness: NativeSyncHarness = {
- server,
- native,
- js,
- persistentTokenCache,
- jsDeviceToken: () => js.currentDeviceToken(),
- nativeModuleCalls,
- run: async action => {
- let result!: Awaited>;
- await act(async () => {
- result = await action();
- });
- return result;
- },
- settle,
- awaitJsToNativeSync: () => waitForPendingJsToNativeSync(),
- pullNativeToJs: () => synchronizeNativeClientToJs(),
- releaseNativeStartup,
- unmount: () => view.unmount(),
- };
- return harness;
-};
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
index 9af865fa942..8358f2e7d43 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
@@ -1,11 +1,13 @@
import type { TokenCache } from '../../../cache/types';
import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
+import type { ClientTokenCache } from '../../nativeClientSync';
import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer';
export type FakeSessionResource = {
id: string;
status: 'active';
- user: { id: string; profileVersion: number };
+ updatedAt: Date;
+ user: { id: string; profileVersion: number; updatedAt: Date };
};
export type JsEmission = {
@@ -17,7 +19,13 @@ type ListenerCallback = (resources: { client: FakeClientResource; session: FakeS
type StatusListener = (status: string) => void;
function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource {
- return { id: session.id, status: 'active', user: { id: session.userId, profileVersion: session.profileVersion } };
+ const updatedAt = new Date(session.profileVersion);
+ return {
+ id: session.id,
+ status: 'active',
+ updatedAt,
+ user: { id: session.userId, profileVersion: session.profileVersion, updatedAt },
+ };
}
/**
@@ -63,7 +71,8 @@ export class FakeClientResource {
/**
* Stand-in for the clerk-js instance returned by `getClerkInstance`, with the same surface the existing
* ClerkProvider tests mock. It talks to the fake Frontend API through the token cache it was built with,
- * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`.
+ * mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`,
+ * including the compare-and-set write against the token the request used.
*/
export class FakeClerk {
loaded = false;
@@ -73,7 +82,7 @@ export class FakeClerk {
__internal_setActiveInProgress = false;
/** Every state emitted to Clerk listeners (what `useAuth` consumers observe). */
readonly emissions: JsEmission[] = [];
- tokenCache: TokenCache | undefined;
+ tokenCache: (TokenCache & Partial) | undefined;
#listeners: ListenerCallback[] = [];
#statusListeners = new Set();
#onLoadedListeners: Array<() => void> = [];
@@ -85,7 +94,9 @@ export class FakeClerk {
const token = (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
const response = await request(token);
if (response.token) {
- await this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token);
+ await (this.tokenCache?.saveClientToken
+ ? this.tokenCache.saveClientToken(response.token, token)
+ : this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token));
}
return response;
}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
index 0eedec3ac2c..6e308ac447e 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts
@@ -15,9 +15,12 @@ export type NativeSyncScenario = {
/** Device token the native SDK has persisted from a previous launch. */
nativeDeviceToken?: string | null;
nativeModule?: 'present' | 'absent';
+ /** The native SDK fails to configure, e.g. a native build that cannot initialize Clerk. */
+ failNativeConfigure?: boolean;
disableNativeClientSync?: boolean;
/** Keep the native SDK in its startup phase until `releaseNativeStartup` is called. */
delayNativeStartup?: boolean;
+ strictMode?: boolean;
};
/**
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
index 146c41f6751..63272a52c61 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/index.ts
@@ -1,4 +1,4 @@
// Swap this export to run the behavior suite against a different sync engine.
-export { renderWithNativeSync } from './currentEngineAdapter';
+export { renderWithNativeSync } from './newEngineAdapter';
export { FakeClerkServer } from './fakeServer';
export type { NativeSyncHarness, NativeSyncScenario } from './harness';
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
index 8518d6d5ed0..9a8030be429 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
@@ -145,18 +145,16 @@ describe('native client sync behavior', () => {
await expectConverged(h);
});
- it('never replaces a signed-in JS client with a foreign sessionless native client', async () => {
- const { server, token, clientId, sessionIds } = seedDevice(['user_1']);
+ it('follows native onto a new client when native replaces the shared device token', async () => {
+ const { server, token } = seedDevice(['user_1']);
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
- const emissionIndex = h.js.emissions.length;
await h.run(() => h.native.switchToForeignSessionlessClient());
await h.settle();
- expect(h.js.client?.id).toBe(clientId);
- expect(h.js.session?.id).toBe(sessionIds.user_1);
- expect(signedOutEmissionsSince(h, emissionIndex)).toEqual([]);
- expect(await h.jsDeviceToken()).toBe(token);
+ expect(h.native.token).not.toBe(token);
+ expect(h.js.client?.id).toBe(server.clientIdForToken(h.native.token));
+ expect(h.js.session).toBeNull();
await expectConverged(h);
});
@@ -174,6 +172,35 @@ describe('native client sync behavior', () => {
await expectConverged(h);
});
+ it('signs JS out when a JS request is rejected after native removed the only session', async () => {
+ const { server, token } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+
+ h.native.holdChangeNotifications();
+ await h.run(() => h.native.signOut());
+ await h.run(() => h.js.getSessionToken());
+
+ expect(h.js.session).toBeNull();
+ expect(h.js.client?.signedInSessions).toEqual([]);
+ await h.run(() => h.native.releaseChangeNotifications());
+ await expectConverged(h);
+ });
+
+ it('refetches the JS client a bounded number of times for a burst of rejected requests', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const jsClientFetchesBefore = server.requestCount('js', 'GET /client');
+
+ h.native.holdChangeNotifications();
+ await h.run(() => h.native.signOutSession(sessionIds.user_1));
+ await h.run(() => Promise.all(Array.from({ length: 5 }, () => h.js.getSessionToken())));
+
+ expect(h.js.session?.id).toBe(sessionIds.user_2);
+ expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2);
+ await h.run(() => h.native.releaseChangeNotifications());
+ await expectConverged(h);
+ });
+
it('settles a native change without an echo loop', async () => {
const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
@@ -185,8 +212,8 @@ describe('native client sync behavior', () => {
await h.settle();
await expectConverged(h);
- expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(2);
- expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(1);
+ expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBe(1);
+ expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(0);
});
});
@@ -273,7 +300,7 @@ describe('native client sync behavior', () => {
await expectConverged(h);
expect(server.requestCount('js', 'GET /client') - jsClientFetchesBefore).toBeLessThanOrEqual(1);
- expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBeLessThanOrEqual(2);
+ expect(h.native.clientRefreshCount - nativeRefreshesBefore).toBe(1);
});
});
@@ -299,6 +326,46 @@ describe('native client sync behavior', () => {
await expectConverged(h);
});
+ it('configures native once when ClerkProvider mounts under StrictMode', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+
+ const h = await start({ server, jsDeviceToken: token, strictMode: true });
+
+ expect(h.nativeModuleCalls.filter(call => call === 'configureNative')).toHaveLength(1);
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ await expectConverged(h);
+ });
+
+ it('keeps JS working on its own token cache with sync disabled when native fails to configure', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token, failNativeConfigure: true });
+
+ expect(h.js.session?.id).toBe(sessionIds.user_1);
+ server.rotateTokenOnNextResponse(token);
+ await h.run(() => h.js.updateProfile());
+ await h.settle();
+
+ const rotatedToken = h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY) ?? null;
+ expect(rotatedToken).not.toBe(token);
+ expect(await h.jsDeviceToken()).toBe(rotatedToken);
+ expect(h.native.token).toBe(token);
+ expect(h.nativeModuleCalls.filter(call => call !== 'configureNative' && call !== 'addListener')).toEqual([]);
+ await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
+ });
+
+ it('mirrors the shared device token into the app token cache', async () => {
+ const server = new FakeClerkServer();
+ const stale = server.seedClient();
+ const signedIn = server.seedClient({ users: ['user_1'] });
+
+ const h = await start({ server, jsDeviceToken: stale.token, nativeDeviceToken: signedIn.token });
+ server.rotateTokenOnNextResponse(signedIn.token);
+ await h.run(() => h.js.updateProfile());
+
+ await waitFor(() => expect(h.persistentTokenCache?.read(CLERK_CLIENT_JWT_KEY)).toBe(h.native.token));
+ expect(h.native.token).not.toBe(signedIn.token);
+ });
+
it('delivers a JS sign-in that happens while native is still starting up', async () => {
const h = await start({ delayNativeStartup: true });
@@ -348,6 +415,24 @@ describe('native client sync behavior', () => {
expect(h.js.client?.signedInSessions.map(session => session.id)).toContain(sessionId);
expect(h.js.session?.id).toBe(sessionId);
});
+
+ it('rejects with environment_unavailable when native does not finish a refresh in time', async () => {
+ const h = await start({ delayNativeStartup: true });
+ await h.run(() => h.js.signIn('user_1'));
+
+ vi.useFakeTimers();
+ try {
+ const barrier = expect(h.awaitJsToNativeSync()).rejects.toMatchObject({ code: 'environment_unavailable' });
+ await vi.advanceTimersByTimeAsync(5_000);
+ await barrier;
+ } finally {
+ vi.useRealTimers();
+ }
+
+ await h.run(() => h.releaseNativeStartup());
+ await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
+ await expectConverged(h);
+ });
});
describe('without native client sync', () => {
@@ -368,9 +453,8 @@ describe('native client sync behavior', () => {
});
});
- describe('known gaps in the current engine', () => {
- // Native adopts whatever token JS passes to `configure`, so a stale JS token replaces native's signed-in one.
- it.fails('keeps the native signed-in session when JS starts with a stale device token', async () => {
+ describe('shared device token', () => {
+ it('keeps the native signed-in session when JS starts with a stale device token', async () => {
const server = new FakeClerkServer();
const stale = server.seedClient();
const signedIn = server.seedClient({ users: ['user_1'] });
@@ -381,8 +465,7 @@ describe('native client sync behavior', () => {
await expectConverged(h);
});
- // Without compare-and-set, each side keeps its own rotation and they end on different tokens.
- it.fails('converges on one device token when JS and native rotate it at the same time', async () => {
+ it('converges on one device token when JS and native rotate it at the same time', async () => {
const { server, token, sessionIds } = seedDevice(['user_1']);
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx
new file mode 100644
index 00000000000..c5d86387b16
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx
@@ -0,0 +1,162 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { StrictMode } from 'react';
+import { vi } from 'vitest';
+
+import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
+import type { ClerkExpoNativeModule } from '../../../utils/native-module';
+import { FakePersistentTokenCache, setActiveEnvironment } from './environment';
+import { FakeClerk } from './fakeClerk';
+import { FakeNativeClerk } from './fakeNativeClerk';
+import { FakeClerkServer } from './fakeServer';
+import type { NativeSyncHarness, RenderWithNativeSync } from './harness';
+
+const nativeClientInvalidatedEvent = 'clerkNativeClientInvalidated';
+
+/**
+ * The `ClerkExpo` native module contract of the simplified engine: native storage owns the device token,
+ * `configureNative` keeps native's own token and only adopts the seed when it has none, `setDeviceToken` is a
+ * compare-and-set, and any change to native's client or token is signalled without a payload.
+ */
+function createNativeModule(
+ native: FakeNativeClerk,
+ calls: string[],
+ { failConfigure }: { failConfigure: boolean },
+): ClerkExpoNativeModule {
+ const listeners = new Set<() => void>();
+ native.onChange(() => {
+ for (const listener of [...listeners]) {
+ listener();
+ }
+ });
+
+ const notifyIfChanged = async (change: () => Promise | void) => {
+ const previousToken = native.token;
+ const previousClient = JSON.stringify(native.client);
+ await change();
+ const changed = {
+ client: JSON.stringify(native.client) !== previousClient,
+ deviceToken: native.token !== previousToken,
+ };
+ if (changed.client || changed.deviceToken) {
+ native.emitChange(changed, { kind: 'native' });
+ }
+ };
+
+ return {
+ addListener: (eventName, listener) => {
+ calls.push('addListener');
+ if (eventName !== nativeClientInvalidatedEvent || !listener) {
+ return { remove: () => undefined };
+ }
+ const invalidated = () => listener();
+ listeners.add(invalidated);
+ return { remove: () => listeners.delete(invalidated) };
+ },
+ configureNative: (_publishableKey, seedDeviceToken) => {
+ calls.push('configureNative');
+ if (failConfigure) {
+ return Promise.reject(new Error('native configure failed'));
+ }
+ native.token ??= seedDeviceToken;
+ native.isConfigured = true;
+ return Promise.resolve();
+ },
+ getDeviceToken: () => {
+ calls.push('getDeviceToken');
+ return Promise.resolve(native.token);
+ },
+ setDeviceToken: async (token, expected) => {
+ calls.push('setDeviceToken');
+ if (native.token !== expected) {
+ return false;
+ }
+ await notifyIfChanged(() => {
+ native.token = token;
+ });
+ return true;
+ },
+ refreshClient: async () => {
+ calls.push('refreshClient');
+ await native.waitForStartup();
+ await notifyIfChanged(() => native.refreshClient());
+ },
+ };
+}
+
+async function flushMacrotasks(count: number): Promise {
+ for (let i = 0; i < count; i++) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+}
+
+export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {}) => {
+ const server = scenario.server ?? new FakeClerkServer();
+ const native = new FakeNativeClerk(server, scenario.nativeDeviceToken ?? null);
+ const js = new FakeClerk(server);
+ const persistentTokenCache =
+ scenario.persistentTokenCache || scenario.jsDeviceToken !== undefined
+ ? new FakePersistentTokenCache(scenario.jsDeviceToken ? { [CLERK_CLIENT_JWT_KEY]: scenario.jsDeviceToken } : {})
+ : null;
+ const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined;
+ const nativeModuleCalls: string[] = [];
+
+ setActiveEnvironment({
+ js,
+ nativeModule:
+ scenario.nativeModule === 'absent'
+ ? null
+ : createNativeModule(native, nativeModuleCalls, { failConfigure: !!scenario.failNativeConfigure }),
+ });
+
+ // Fresh engine modules per scenario: the native module is resolved at import time and the native
+ // configuration and active engine are module singletons.
+ vi.resetModules();
+ const { ClerkProvider } = await import('../../ClerkProvider');
+ const { idle, pullFromNative } = await import('../../nativeClientSync');
+
+ const provider = (
+
+ );
+ const view = render(scenario.strictMode ? {provider} : provider);
+
+ const settle = async () => {
+ for (let round = 0; round < 8; round++) {
+ await act(async () => {
+ await flushMacrotasks(10);
+ });
+ }
+ };
+
+ await waitFor(() => {
+ if (!js.loaded) {
+ throw new Error('Clerk has not loaded yet');
+ }
+ });
+ await settle();
+
+ const harness: NativeSyncHarness = {
+ server,
+ native,
+ js,
+ persistentTokenCache,
+ jsDeviceToken: () => js.currentDeviceToken(),
+ nativeModuleCalls,
+ run: async action => {
+ let result!: Awaited>;
+ await act(async () => {
+ result = await action();
+ });
+ return result;
+ },
+ settle,
+ awaitJsToNativeSync: () => idle(),
+ pullNativeToJs: () => pullFromNative(),
+ releaseNativeStartup,
+ unmount: () => view.unmount(),
+ };
+ return harness;
+};
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts
deleted file mode 100644
index dde69d9c2f0..00000000000
--- a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts
+++ /dev/null
@@ -1,266 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-
-import type { NativeClientEvent } from '../../hooks/useNativeClientEvents';
-import {
- __internal_resetNativeClientSyncCoordinator,
- registerNativeToJsSyncHandler,
- synchronizeNativeClientToJs,
- trackPendingJsToNativeSync,
- waitForPendingJsToNativeSync,
-} from '../nativeClientSyncCoordinator';
-
-function deferred(): { promise: Promise; resolve: () => void } {
- let resolve!: () => void;
- const promise = new Promise(innerResolve => {
- resolve = innerResolve;
- });
- return { promise, resolve };
-}
-
-function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } {
- let reject!: (error: Error) => void;
- const promise = new Promise((_resolve, innerReject) => {
- reject = innerReject;
- });
- return { promise, reject };
-}
-
-function nativeClientEvent(issuedAt: number): NativeClientEvent {
- return {
- issuedAt,
- changed: { client: true, deviceToken: true },
- deviceToken: `native-token-${issuedAt}`,
- };
-}
-
-let unregister: (() => void) | undefined;
-
-beforeEach(() => {
- __internal_resetNativeClientSyncCoordinator();
-});
-
-afterEach(() => {
- vi.useRealTimers();
- unregister?.();
- unregister = undefined;
-});
-
-describe('native client sync coordinator', () => {
- test('preserves a JS-to-native sync failure until a later sync succeeds', async () => {
- const error = new Error('native sync failed');
- trackPendingJsToNativeSync(Promise.reject(error));
-
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(error);
- await expect(waitForPendingJsToNativeSync()).rejects.toBe(error);
-
- trackPendingJsToNativeSync(Promise.resolve());
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- });
-
- test('does not restore an older failure after a newer sync succeeds', async () => {
- const olderSync = rejectableDeferred();
- trackPendingJsToNativeSync(olderSync.promise);
- trackPendingJsToNativeSync(Promise.resolve());
-
- olderSync.reject(new Error('stale native sync failure'));
-
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- });
-
- test('ignores a tracked synchronization that is invalidated before it rejects', async () => {
- const staleSync = rejectableDeferred();
- const invalidate = trackPendingJsToNativeSync(staleSync.promise);
-
- invalidate();
- staleSync.reject(new Error('canceled native refresh failed'));
- await Promise.resolve();
-
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- });
-
- test('rejects with environment unavailable when JS-to-native synchronization times out', async () => {
- vi.useFakeTimers();
- const pendingSync = deferred();
- trackPendingJsToNativeSync(pendingSync.promise);
-
- const waiting = expect(waitForPendingJsToNativeSync()).rejects.toMatchObject({
- code: 'environment_unavailable',
- message: 'Timed out waiting for the native Clerk client to synchronize.',
- });
-
- await vi.advanceTimersByTimeAsync(5_000);
- await waiting;
- pendingSync.resolve();
- });
-
- test('honors an extended timeout for native bootstrap synchronization', async () => {
- vi.useFakeTimers();
- const pendingSync = deferred();
- trackPendingJsToNativeSync(pendingSync.promise, 15_000);
-
- const waiting = waitForPendingJsToNativeSync();
- let didSettle = false;
- void waiting.then(
- () => {
- didSettle = true;
- },
- () => {
- didSettle = true;
- },
- );
-
- await vi.advanceTimersByTimeAsync(5_000);
- expect(didSettle).toBe(false);
-
- pendingSync.resolve();
- await expect(waiting).resolves.toBeUndefined();
- });
-
- test('ignores pending synchronization outcomes from before a reset', async () => {
- const staleSync = rejectableDeferred();
- trackPendingJsToNativeSync(staleSync.promise);
-
- __internal_resetNativeClientSyncCoordinator();
- trackPendingJsToNativeSync(Promise.resolve());
- staleSync.reject(new Error('stale native sync failure'));
-
- await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined();
- });
-
- test('waits for an event sync before starting explicit synchronization', async () => {
- const eventSync = deferred();
- const explicitSync = deferred();
- const handler = vi.fn((event?: NativeClientEvent | null) => (event ? eventSync.promise : explicitSync.promise));
- unregister = registerNativeToJsSyncHandler(handler);
-
- const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1));
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
-
- const explicit = synchronizeNativeClientToJs();
- await Promise.resolve();
- expect(handler).toHaveBeenCalledTimes(1);
-
- eventSync.resolve();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
- expect(handler).toHaveBeenLastCalledWith();
-
- explicitSync.resolve();
- await Promise.all([fromEvent, explicit]);
- });
-
- test('runs a follow-up synchronization when an event arrives during explicit synchronization', async () => {
- const explicitSync = deferred();
- const followUpSync = deferred();
- const signOutEvent: NativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: true },
- deviceToken: null,
- };
- const handler = vi
- .fn()
- .mockImplementationOnce(() => explicitSync.promise)
- .mockImplementationOnce(() => followUpSync.promise);
- unregister = registerNativeToJsSyncHandler(handler);
-
- const explicit = synchronizeNativeClientToJs();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
-
- const fromEvent = synchronizeNativeClientToJs(signOutEvent);
- expect(fromEvent).toBe(explicit);
-
- explicitSync.resolve();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
- expect(handler).toHaveBeenLastCalledWith(signOutEvent);
-
- let didFinish = false;
- void fromEvent.then(() => {
- didFinish = true;
- });
- await Promise.resolve();
- expect(didFinish).toBe(false);
-
- followUpSync.resolve();
- await Promise.all([explicit, fromEvent]);
- });
-
- test('merges change flags while preserving the latest event snapshot during explicit synchronization', async () => {
- const explicitSync = deferred();
- const followUpSync = deferred();
- const handler = vi
- .fn()
- .mockImplementationOnce(() => explicitSync.promise)
- .mockImplementationOnce(() => followUpSync.promise);
- unregister = registerNativeToJsSyncHandler(handler);
-
- const explicit = synchronizeNativeClientToJs();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
-
- const clientEvent: NativeClientEvent = {
- issuedAt: 1,
- changed: { client: true, deviceToken: false },
- deviceToken: 'native-token-1',
- };
- const tokenEvent: NativeClientEvent = {
- issuedAt: 2,
- changed: { client: false, deviceToken: true },
- deviceToken: null,
- sourceId: 'native-sign-out',
- };
- const fromClientEvent = synchronizeNativeClientToJs(clientEvent);
- const fromTokenEvent = synchronizeNativeClientToJs(tokenEvent);
-
- explicitSync.resolve();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
- expect(handler).toHaveBeenLastCalledWith({
- ...tokenEvent,
- changed: { client: true, deviceToken: true },
- });
-
- followUpSync.resolve();
- await Promise.all([explicit, fromClientEvent, fromTokenEvent]);
- });
-
- test('runs a follow-up synchronization for another explicit request', async () => {
- const firstSync = deferred();
- const followUpSync = deferred();
- const handler = vi
- .fn()
- .mockImplementationOnce(() => firstSync.promise)
- .mockImplementationOnce(() => followUpSync.promise);
- unregister = registerNativeToJsSyncHandler(handler);
-
- const first = synchronizeNativeClientToJs();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
-
- const second = synchronizeNativeClientToJs();
- expect(second).toBe(first);
-
- firstSync.resolve();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
-
- followUpSync.resolve();
- await Promise.all([first, second]);
- });
-
- test('serializes native event synchronizations in arrival order', async () => {
- const firstSync = deferred();
- const secondSync = deferred();
- const handler = vi.fn((event?: NativeClientEvent | null) =>
- event?.issuedAt === 1 ? firstSync.promise : secondSync.promise,
- );
- unregister = registerNativeToJsSyncHandler(handler);
-
- const first = synchronizeNativeClientToJs(nativeClientEvent(1));
- const second = synchronizeNativeClientToJs(nativeClientEvent(2));
-
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
- expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(1));
-
- firstSync.resolve();
- await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
- expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(2));
-
- secondSync.resolve();
- await Promise.all([first, second]);
- });
-});
diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts
new file mode 100644
index 00000000000..6920d6afded
--- /dev/null
+++ b/packages/expo/src/provider/nativeClientSync.ts
@@ -0,0 +1,336 @@
+import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types';
+
+import { MemoryTokenCache } from '../cache/MemoryTokenCache';
+import type { TokenCache } from '../cache/types';
+import { CLERK_CLIENT_JWT_KEY } from '../constants';
+import type { ClerkExpoNativeModule } from '../utils/native-module';
+
+const CONFIGURE_TIMEOUT_MS = 3_000;
+const IDLE_TIMEOUT_MS = 5_000;
+const INVALIDATED_EVENT = 'clerkNativeClientInvalidated';
+
+type FetchableClient = ClientResource & { fetch?: (options?: { fetchMaxTries?: number }) => Promise };
+
+export type SyncableClerk = {
+ addListener: (listener: () => void) => () => void;
+ client?: ClientResource | null;
+ session?: SignedInSessionResource | null;
+ setActive: (params: { session: SignedInSessionResource | null }) => Promise;
+ updateClient: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void;
+ handleUnauthenticated: (options?: { broadcast?: boolean }) => Promise;
+ __internal_reloadInitialResources?: () => Promise;
+ __internal_setActiveInProgress?: boolean;
+};
+
+/** A token cache whose client JWT writes are compare-and-set against the token the request used. */
+export type ClientTokenCache = TokenCache & {
+ saveClientToken: (token: string, requestToken: string | null) => Promise;
+};
+
+type SingleFlight = { run: () => Promise; current: () => Promise | null };
+
+type Engine = {
+ settled: () => Promise;
+ pull: () => Promise;
+ refreshFailure: () => { error: unknown } | null;
+};
+
+const noop = () => undefined;
+
+let configuration: { publishableKey: string; ready: Promise } | null = null;
+let activeEngine: Engine | null = null;
+
+function warn(message: string, error?: unknown): void {
+ if (__DEV__) {
+ console.warn(`[ClerkExpo] ${message}`, error);
+ }
+}
+
+function withTimeout(promise: Promise, ms: number, error: () => Error): Promise {
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(error()), ms);
+ });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+function singleFlight(task: () => Promise): SingleFlight {
+ let running: Promise | null = null;
+ let rerun = false;
+ const run = (): Promise => {
+ if (running) {
+ rerun = true;
+ return running;
+ }
+ running = (async () => {
+ let failure: { error: unknown } | null = null;
+ try {
+ do {
+ rerun = false;
+ try {
+ await task();
+ failure = null;
+ } catch (error) {
+ failure = { error };
+ }
+ } while (rerun);
+ } finally {
+ running = null;
+ }
+ if (failure) {
+ throw failure.error;
+ }
+ })();
+ return running;
+ };
+ return { run, current: () => running };
+}
+
+function defaultSession(client: ClientResource | null | undefined): SignedInSessionResource | null {
+ const sessions = client?.signedInSessions ?? [];
+ return sessions.find(session => session.id === client?.lastActiveSessionId) ?? sessions[0] ?? null;
+}
+
+function fingerprint(clerk: SyncableClerk): string {
+ const client = clerk.client;
+ const user = clerk.session?.user;
+ return JSON.stringify([
+ client?.id ?? null,
+ client?.lastActiveSessionId ?? null,
+ client?.signedInSessions.map(session => [session.id, session.status, +session.updatedAt]) ?? [],
+ user?.id ?? null,
+ +(user?.updatedAt ?? 0),
+ ]);
+}
+
+function configureOnce(native: ClerkExpoNativeModule, publishableKey: string, tokenCache: TokenCache) {
+ if (configuration?.publishableKey !== publishableKey) {
+ const ready = (async () => {
+ const seed = (await tokenCache.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
+ await native.configureNative(publishableKey, seed);
+ })();
+ configuration = {
+ publishableKey,
+ ready: withTimeout(ready, CONFIGURE_TIMEOUT_MS, () => new Error('Timed out configuring the native Clerk SDK.'))
+ .then(() => true)
+ .catch(error => {
+ warn('Native client sync is disabled because the native Clerk SDK failed to configure.', error);
+ return false;
+ }),
+ };
+ }
+ return configuration.ready;
+}
+
+/**
+ * Shares one device token, owned by native storage, between clerk-js and the native Clerk SDK, and
+ * keeps both clients current by refetching on a payload-free "client changed" signal in each direction.
+ */
+export function createNativeClientSync(
+ native: ClerkExpoNativeModule,
+ publishableKey: string,
+ getUserTokenCache: () => TokenCache | undefined,
+) {
+ const fallback = () => getUserTokenCache() ?? MemoryTokenCache;
+ const ready = () => configureOnce(native, publishableKey, fallback());
+ let mirroredToken: string | null = null;
+ let lastFingerprint: string | undefined;
+ let refreshFailure: { error: unknown } | null = null;
+
+ const mirror = (token: string | null) => {
+ if (token && token !== mirroredToken) {
+ mirroredToken = token;
+ void Promise.resolve(fallback().saveToken(CLERK_CLIENT_JWT_KEY, token)).catch(noop);
+ }
+ };
+
+ const setDeviceToken = (token: string | null, expected: string | null) =>
+ native.setDeviceToken(token, expected).catch(error => {
+ warn('Failed to update the native device token.', error);
+ return false;
+ });
+
+ const tokenCache: ClientTokenCache = {
+ getToken: async key => {
+ if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) {
+ return fallback().getToken(key);
+ }
+ return native.getDeviceToken().catch(error => {
+ warn('Failed to read the native device token.', error);
+ return fallback().getToken(key);
+ });
+ },
+ saveToken: async (key, token) => {
+ if (key !== CLERK_CLIENT_JWT_KEY || !(await ready())) {
+ return fallback().saveToken(key, token);
+ }
+ await tokenCache.saveClientToken(token, await native.getDeviceToken().catch(() => null));
+ },
+ clearToken: key => fallback().clearToken?.(key),
+ saveClientToken: async (token, requestToken) => {
+ if (!(await ready())) {
+ return fallback().saveToken(CLERK_CLIENT_JWT_KEY, token);
+ }
+ const didSet = token === requestToken || (await setDeviceToken(token, requestToken));
+ mirror(didSet ? token : await native.getDeviceToken().catch(() => null));
+ },
+ };
+
+ const attach = (clerk: SyncableClerk): (() => void) => {
+ let reconciling: Promise | null = null;
+
+ // Refetches the JS client with the shared device token and follows native's active session.
+ const pull = async () => {
+ const client = clerk.client as FetchableClient | null | undefined;
+ if (typeof client?.fetch === 'function') {
+ clerk.updateClient(await client.fetch({ fetchMaxTries: 1 }));
+ } else {
+ await clerk.__internal_reloadInitialResources?.();
+ }
+ const target = defaultSession(clerk.client);
+ if (target && clerk.session?.id !== target.id && !reconciling && !clerk.__internal_setActiveInProgress) {
+ await clerk.setActive({ session: target });
+ }
+ };
+
+ const fromNative = singleFlight(async () => {
+ if (await ready()) {
+ await pull();
+ await reconciling;
+ lastFingerprint = fingerprint(clerk);
+ }
+ });
+
+ const toNative = singleFlight(async () => {
+ // Let an in-flight native pull settle first so its own emissions are not echoed back.
+ await fromNative.current()?.catch(noop);
+ const next = fingerprint(clerk);
+ if (next === lastFingerprint || !(await ready())) {
+ return;
+ }
+ lastFingerprint = next;
+ try {
+ await native.refreshClient();
+ refreshFailure = null;
+ } catch (error) {
+ lastFingerprint = undefined;
+ refreshFailure = { error };
+ throw error;
+ }
+ });
+
+ const originalUpdateClient = clerk.updateClient;
+ const updateClient: SyncableClerk['updateClient'] = (client, options) => {
+ const sessionId = clerk.session?.id;
+ const fallbackSession = defaultSession(client);
+ const wasRemoved = !!sessionId && !client.signedInSessions.some(session => session.id === sessionId);
+ if (!fallbackSession || !(wasRemoved || reconciling)) {
+ return originalUpdateClient(client, options);
+ }
+ // clerk-js would emit a transient signed-out state before another session is activated.
+ originalUpdateClient(client, { __internal_dangerouslySkipEmit: true });
+ if (reconciling || clerk.__internal_setActiveInProgress) {
+ return;
+ }
+ reconciling = clerk
+ .setActive({ session: fallbackSession })
+ .catch(error => {
+ warn('Failed to activate the remaining session.', error);
+ originalUpdateClient(client, options);
+ })
+ .finally(() => {
+ reconciling = null;
+ });
+ };
+
+ const originalHandleUnauthenticated = clerk.handleUnauthenticated;
+ let isHandlingUnauthenticated = false;
+ const handleUnauthenticated: SyncableClerk['handleUnauthenticated'] = async options => {
+ if (isHandlingUnauthenticated) {
+ return;
+ }
+ isHandlingUnauthenticated = true;
+ try {
+ if (await ready()) {
+ // Native may have moved the client to another session that JS has not seen yet.
+ const pendingReconcile = reconciling;
+ await pull();
+ // A reconcile that predates this call may be the request that got the 401, so it is not awaited.
+ if (reconciling !== pendingReconcile) {
+ await reconciling;
+ }
+ if (clerk.session || reconciling) {
+ return;
+ }
+ }
+ } catch (error) {
+ warn('Failed to refresh the client from native after an unauthenticated response.', error);
+ } finally {
+ isHandlingUnauthenticated = false;
+ }
+ return originalHandleUnauthenticated(options);
+ };
+
+ clerk.updateClient = updateClient;
+ clerk.handleUnauthenticated = handleUnauthenticated;
+ const removeClerkListener = clerk.addListener(
+ () => void toNative.run().catch(error => warn('Failed to refresh the native client.', error)),
+ );
+ const subscription = native.addListener?.(
+ INVALIDATED_EVENT,
+ () => void fromNative.run().catch(error => warn('Failed to refresh the client from native.', error)),
+ );
+
+ const engine: Engine = {
+ settled: async () => {
+ while (toNative.current() || fromNative.current()) {
+ await Promise.all([toNative.current()?.catch(noop), fromNative.current()?.catch(noop)]);
+ }
+ },
+ pull: fromNative.run,
+ refreshFailure: () => refreshFailure,
+ };
+ activeEngine = engine;
+
+ return () => {
+ removeClerkListener();
+ subscription?.remove();
+ if (clerk.updateClient === updateClient) {
+ clerk.updateClient = originalUpdateClient;
+ }
+ if (clerk.handleUnauthenticated === handleUnauthenticated) {
+ clerk.handleUnauthenticated = originalHandleUnauthenticated;
+ }
+ if (activeEngine === engine) {
+ activeEngine = null;
+ }
+ };
+ };
+
+ return { tokenCache, attach };
+}
+
+/**
+ * Resolves once pending JS→native and native→JS syncs have settled. Rejects with the latest native
+ * refresh failure until a later refresh succeeds, or with `environment_unavailable` after a timeout.
+ */
+export async function idle(): Promise {
+ const engine = activeEngine;
+ if (!engine) {
+ return;
+ }
+ await withTimeout(engine.settled(), IDLE_TIMEOUT_MS, () =>
+ Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), {
+ code: 'environment_unavailable' as const,
+ }),
+ );
+ const failure = engine.refreshFailure();
+ if (failure) {
+ throw failure.error;
+ }
+}
+
+/** Refetches the JS client after a native operation and resolves once it has been applied. */
+export function pullFromNative(): Promise {
+ return activeEngine?.pull() ?? Promise.resolve();
+}
diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx
deleted file mode 100644
index 5cdb87dc6ae..00000000000
--- a/packages/expo/src/provider/nativeClientSync.tsx
+++ /dev/null
@@ -1,1296 +0,0 @@
-import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types';
-import { type MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { Platform } from 'react-native';
-
-import { MemoryTokenCache } from '../cache';
-import type { TokenCache } from '../cache/types';
-import { CLERK_CLIENT_JWT_KEY } from '../constants';
-import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents';
-import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module';
-import {
- registerNativeToJsSyncHandler,
- synchronizeNativeClientToJs,
- trackPendingJsToNativeSync,
-} from './nativeClientSyncCoordinator';
-
-const tokenCacheReadTimeoutMs = 1_000;
-const nativeDeviceTokenPollIntervalMs = 100;
-const nativeDeviceTokenAvailabilityTimeoutMs = 3_000;
-const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync';
-const unauthenticatedRecoveryCooldownMs = 5_000;
-const nativeClientConfigurationMaxAttempts = 2;
-const nativeClientConfigurationRetryDelayMs = 250;
-const nativeClientBootstrapTimeoutMs = Platform.OS === 'android' ? 35_000 : 10_000;
-const useNativeClientBootstrapEffect = Platform.OS === 'ios' || Platform.OS === 'android' ? useLayoutEffect : useEffect;
-
-export type SyncableClerkInstance = {
- addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void;
- addOnLoaded?: (listener: () => void) => void;
- client?: ClientResource;
- handleUnauthenticated?: (options?: { broadcast?: boolean }) => Promise;
- loaded?: boolean;
- off?: (event: 'status', listener: (status: string) => void) => void;
- on?: (event: 'status', listener: (status: string) => void) => void;
- session?: SignedInSessionResource | null;
- status?: string;
- setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise;
- updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void;
- __internal_setActiveInProgress?: boolean;
- __internal_reloadInitialResources?: () => void | Promise;
-};
-
-type RefreshableClientResource = ClientResource & {
- fetch?: (options?: { fetchMaxTries?: number }) => Promise;
- fromJSON?: (data: ClientJSONSnapshot) => ClientResource;
-};
-
-type NativeRefreshFromJsOptions = {
- deviceToken?: string | null;
- didChangeClient: boolean;
- didChangeDeviceToken: boolean;
-};
-
-type NativeClientSyncCompletion = {
- invalidateTracking: () => void;
- promise: Promise;
- resolve: () => void;
-};
-
-type NativeClientBootstrapRegistration = {
- clerkInstance: SyncableClerkInstance | null | undefined;
- generation: number;
- invalidateTracking: () => void;
- publishableKey: string;
- tokenCache: TokenCache | undefined;
-};
-
-export type NativeRefreshFromJsController = {
- cancel: () => void;
- syncDeviceTokenToNative: (deviceToken: string | null) => void;
-};
-
-export type DeviceTokenCacheListener = (deviceToken: string | null) => void;
-
-function delay(ms: number): Promise {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-function createNativeClientSyncCompletion(): NativeClientSyncCompletion {
- let resolve!: () => void;
- const promise = new Promise(innerResolve => {
- resolve = innerResolve;
- });
- return { invalidateTracking: () => undefined, promise, resolve };
-}
-
-export function useSyncableTokenCache({
- suppressTokenCacheNotificationsRef,
- tokenCache,
- tokenCacheListenersRef,
-}: {
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
- tokenCacheListenersRef: MutableRefObject>;
-}): TokenCache | undefined {
- return useMemo(() => {
- const effectiveTokenCache =
- tokenCache ?? (Platform.OS === 'ios' || Platform.OS === 'android' ? MemoryTokenCache : undefined);
- if (!effectiveTokenCache) {
- return undefined;
- }
-
- let hasKnownDeviceToken = false;
- let knownDeviceToken: string | null = null;
-
- const notifyDeviceTokenListeners = (deviceToken: string | null) => {
- if (suppressTokenCacheNotificationsRef.current > 0) {
- return;
- }
-
- for (const listener of tokenCacheListenersRef.current) {
- listener(deviceToken);
- }
- };
-
- return {
- getToken: async key => {
- const token = await effectiveTokenCache.getToken(key);
- if (key === CLERK_CLIENT_JWT_KEY && !hasKnownDeviceToken) {
- hasKnownDeviceToken = true;
- knownDeviceToken = token ?? null;
- }
- return token;
- },
- saveToken: async (key, token) => {
- await effectiveTokenCache.saveToken(key, token);
- if (key === CLERK_CLIENT_JWT_KEY) {
- const didChange = !hasKnownDeviceToken || knownDeviceToken !== token;
- hasKnownDeviceToken = true;
- knownDeviceToken = token;
- if (didChange) {
- notifyDeviceTokenListeners(token);
- }
- }
- },
- clearToken: async key => {
- await effectiveTokenCache.clearToken?.(key);
- if (key === CLERK_CLIENT_JWT_KEY) {
- const didChange = !hasKnownDeviceToken || knownDeviceToken !== null;
- hasKnownDeviceToken = true;
- knownDeviceToken = null;
- if (didChange) {
- notifyDeviceTokenListeners(null);
- }
- }
- },
- };
- }, [suppressTokenCacheNotificationsRef, tokenCache, tokenCacheListenersRef]);
-}
-
-async function readNativeDeviceToken({ waitForToken }: { waitForToken: boolean }): Promise {
- const ClerkExpo = NativeClerkModule;
- if (!ClerkExpo?.getClientToken) {
- return null;
- }
-
- const startedAt = Date.now();
- let remainingMs = nativeDeviceTokenAvailabilityTimeoutMs;
-
- do {
- const nativeDeviceToken = await ClerkExpo.getClientToken();
- if (nativeDeviceToken) {
- return nativeDeviceToken;
- }
-
- if (!waitForToken) {
- return null;
- }
-
- remainingMs = nativeDeviceTokenAvailabilityTimeoutMs - (Date.now() - startedAt);
- if (remainingMs <= 0) {
- return null;
- }
-
- await delay(Math.min(nativeDeviceTokenPollIntervalMs, remainingMs));
- } while (remainingMs > 0);
-
- return null;
-}
-
-async function syncDeviceTokenToCache(tokenCache: TokenCache | undefined, deviceToken: string | null): Promise {
- if (deviceToken) {
- await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, deviceToken);
- return;
- }
-
- await tokenCache?.clearToken?.(CLERK_CLIENT_JWT_KEY);
-}
-
-async function syncDeviceTokenToCacheWithoutNotifying({
- deviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- deviceToken: string | null;
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- suppressTokenCacheNotificationsRef.current += 1;
- try {
- await syncDeviceTokenToCache(tokenCache, deviceToken);
- } finally {
- suppressTokenCacheNotificationsRef.current = Math.max(0, suppressTokenCacheNotificationsRef.current - 1);
- }
-}
-
-async function syncNativeDeviceTokenToCache({
- deviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- deviceToken: string | null;
- suppressTokenCacheNotificationsRef?: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- if (suppressTokenCacheNotificationsRef) {
- await syncDeviceTokenToCacheWithoutNotifying({
- deviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- return;
- }
-
- await syncDeviceTokenToCache(tokenCache, deviceToken);
-}
-
-function getDefaultSignedInSession(client: ClientResource | null | undefined): SignedInSessionResource | null {
- if (!client) {
- return null;
- }
-
- if (client.lastActiveSessionId) {
- const lastActiveSession = client.signedInSessions.find(session => session.id === client.lastActiveSessionId);
- if (lastActiveSession) {
- return lastActiveSession;
- }
- }
-
- return client.signedInSessions[0] ?? null;
-}
-
-function canRefreshJsClientFromServer(clerkInstance: SyncableClerkInstance): boolean {
- const client = clerkInstance.client as RefreshableClientResource | undefined;
-
- return typeof client?.fetch === 'function' && typeof clerkInstance.updateClient === 'function';
-}
-
-function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise {
- const client = clerkInstance.client as RefreshableClientResource | undefined;
-
- if (typeof client?.fetch !== 'function' || typeof clerkInstance.updateClient !== 'function') {
- return Promise.resolve(null);
- }
-
- return client.fetch({ fetchMaxTries: 1 });
-}
-
-type ClientStateSnapshot = {
- id: string | null;
- hasSignedInSession: boolean;
- restore: (() => ClientResource) | null;
-};
-
-function snapshotClientState(client: ClientResource | null | undefined): ClientStateSnapshot {
- const resource = client as RefreshableClientResource | undefined;
- const fromJSON = resource?.fromJSON?.bind(resource);
- let restore: ClientStateSnapshot['restore'] = null;
-
- if (resource && fromJSON) {
- const state = resource.__internal_toSnapshot();
- restore = () => fromJSON(state);
- }
-
- return {
- id: client?.id ?? null,
- hasSignedInSession: Boolean(client && getDefaultSignedInSession(client)),
- restore,
- };
-}
-
-// Client.fetch mutates the resource, so compare against pre-fetch values.
-function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refreshedClient: ClientResource): boolean {
- if (!previousSnapshot.id || !refreshedClient.id || previousSnapshot.id === refreshedClient.id) {
- return false;
- }
-
- return previousSnapshot.hasSignedInSession && refreshedClient.signedInSessions.length === 0;
-}
-
-async function refreshJsClientFromNativeState({
- clerkInstance,
- nativeDeviceToken,
- previousDeviceToken,
- rejectForeignSessionlessClient = false,
- reloadInitialResources,
- shouldSyncDeviceToken = true,
- suppressDeviceTokenRollbackNotification = false,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- clerkInstance: SyncableClerkInstance;
- nativeDeviceToken: string | null;
- previousDeviceToken?: string | null;
- rejectForeignSessionlessClient?: boolean;
- reloadInitialResources: boolean;
- shouldSyncDeviceToken?: boolean;
- suppressDeviceTokenRollbackNotification?: boolean;
- suppressTokenCacheNotificationsRef?: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- const previousClientSnapshot = snapshotClientState(clerkInstance.client);
-
- const restorePreviousDeviceToken = async () => {
- if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) {
- return;
- }
-
- // On the 401 path a rollback is part of recovery, not an external rotation, so it must not
- // reopen the cooldown. The native-event path still notifies so native resyncs the restored token.
- await syncNativeDeviceTokenToCache({
- deviceToken: previousDeviceToken,
- suppressTokenCacheNotificationsRef: suppressDeviceTokenRollbackNotification
- ? suppressTokenCacheNotificationsRef
- : undefined,
- tokenCache,
- });
- };
-
- let refreshedClient: ClientResource | null;
- try {
- if (shouldSyncDeviceToken) {
- await syncNativeDeviceTokenToCache({
- deviceToken: nativeDeviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- }
-
- refreshedClient = await fetchRefreshedJsClient(clerkInstance);
- } catch (error) {
- await restorePreviousDeviceToken();
- throw error;
- }
-
- if (refreshedClient) {
- if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) {
- await restorePreviousDeviceToken();
- const restoredClient = previousClientSnapshot.restore?.();
- if (restoredClient) {
- clerkInstance.updateClient?.(restoredClient);
- await reconcileJsActiveSessionFromClient({
- clerkInstance,
- });
- }
- return 'restored';
- }
-
- clerkInstance.updateClient?.(refreshedClient);
- await reconcileJsActiveSessionFromClient({
- clerkInstance,
- });
- return 'refreshed';
- }
-
- if (reloadInitialResources && typeof clerkInstance.__internal_reloadInitialResources === 'function') {
- await clerkInstance.__internal_reloadInitialResources();
- await reconcileJsActiveSessionFromClient({
- clerkInstance,
- });
- return getDefaultSignedInSession(clerkInstance.client) ? 'refreshed' : false;
- }
-
- return false;
-}
-
-async function reloadJsClientFromNativeState({
- clerkInstance,
- nativeDeviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- clerkInstance: SyncableClerkInstance;
- nativeDeviceToken: string;
- suppressTokenCacheNotificationsRef?: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- await syncNativeDeviceTokenToCache({
- deviceToken: nativeDeviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
-
- await clerkInstance.__internal_reloadInitialResources?.();
- await reconcileJsActiveSessionFromClient({
- clerkInstance,
- });
- return Boolean(getDefaultSignedInSession(clerkInstance.client));
-}
-
-async function recoverJsClientFromNativeDeviceToken({
- clerkInstance,
- error,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- clerkInstance: SyncableClerkInstance;
- error: unknown;
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false });
- if (!nativeDeviceToken) {
- return false;
- }
-
- if (__DEV__) {
- console.warn('[NativeClientSync] Failed to refresh JS client with native device token:', error);
- }
-
- try {
- return await reloadJsClientFromNativeState({
- clerkInstance,
- nativeDeviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- } catch (recoveryError) {
- if (__DEV__) {
- console.warn('[NativeClientSync] Failed to recover JS client after unauthenticated state:', recoveryError);
- }
- return false;
- }
-}
-
-async function reconcileJsActiveSessionFromClient({
- clerkInstance,
-}: {
- clerkInstance: SyncableClerkInstance;
-}): Promise {
- const fallbackSession = getDefaultSignedInSession(clerkInstance.client);
- if (!fallbackSession || typeof clerkInstance.setActive !== 'function') {
- return;
- }
-
- const currentSession = clerkInstance.session;
- const currentSessionStillExists = currentSession
- ? clerkInstance.client?.signedInSessions.some(session => session.id === currentSession.id)
- : false;
-
- if (currentSessionStillExists && currentSession?.id === fallbackSession.id) {
- return;
- }
-
- await clerkInstance.setActive({ session: fallbackSession });
-}
-
-async function runWithSuppressedJsClientChanges(
- suppressJsClientChangedRef: MutableRefObject | undefined,
- task: () => Promise,
-): Promise {
- if (!suppressJsClientChangedRef) {
- return task();
- }
-
- suppressJsClientChangedRef.current += 1;
- try {
- return await task();
- } finally {
- suppressJsClientChangedRef.current = Math.max(0, suppressJsClientChangedRef.current - 1);
- }
-}
-
-function mergePendingNativeRefreshOptions(
- current: NativeRefreshFromJsOptions | null,
- next: NativeRefreshFromJsOptions,
-): NativeRefreshFromJsOptions {
- if (!current) {
- return next;
- }
-
- const merged: NativeRefreshFromJsOptions = {
- didChangeClient: current.didChangeClient || next.didChangeClient,
- didChangeDeviceToken: current.didChangeDeviceToken || next.didChangeDeviceToken,
- };
-
- if ('deviceToken' in current) {
- merged.deviceToken = current.deviceToken ?? null;
- }
-
- if ('deviceToken' in next) {
- merged.deviceToken = next.deviceToken ?? null;
- }
-
- return merged;
-}
-
-const tokenCacheReadTimedOut = Symbol('tokenCacheReadTimedOut');
-
-// `undefined` = read timed out, `null` = confirmed missing token.
-async function getCachedDeviceToken(tokenCache: TokenCache | undefined): Promise {
- if (!tokenCache) {
- return null;
- }
-
- let timeoutId: ReturnType | undefined;
- try {
- const result = await Promise.race([
- tokenCache.getToken(CLERK_CLIENT_JWT_KEY),
- new Promise(resolve => {
- timeoutId = setTimeout(() => resolve(tokenCacheReadTimedOut), tokenCacheReadTimeoutMs);
- }),
- ]);
- if (result === tokenCacheReadTimedOut) {
- return undefined;
- }
- return result ?? null;
- } finally {
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- }
-}
-
-async function syncNativeClientToJs({
- clerkInstance,
- nativeRefreshFromJsControllerRef,
- nativeClientEvent,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- clerkInstance: SyncableClerkInstance;
- nativeRefreshFromJsControllerRef?: MutableRefObject;
- nativeClientEvent?: NativeClientEvent | null;
- suppressJsClientChangedRef?: MutableRefObject;
- suppressTokenCacheNotificationsRef?: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}): Promise {
- const didChangeClient = nativeClientEvent?.changed.client ?? true;
- const didChangeDeviceToken = nativeClientEvent?.changed.deviceToken ?? true;
-
- if (!didChangeClient && !didChangeDeviceToken) {
- return;
- }
-
- const nativeDeviceToken = nativeClientEvent
- ? nativeClientEvent.deviceToken
- : await readNativeDeviceToken({
- waitForToken: true,
- });
-
- if (!nativeDeviceToken && !nativeClientEvent) {
- return;
- }
-
- const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined;
- const hasSignedInJsClient = Boolean(getDefaultSignedInSession(clerkInstance.client));
-
- if (didChangeDeviceToken && hasSignedInJsClient) {
- // Timed-out cache read leaves no rollback snapshot, so keep JS authoritative.
- if (previousDeviceToken === undefined) {
- return;
- }
-
- if (previousDeviceToken && !canRefreshJsClientFromServer(clerkInstance)) {
- nativeRefreshFromJsControllerRef?.current?.syncDeviceTokenToNative(previousDeviceToken);
- return;
- }
- }
-
- await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => {
- nativeRefreshFromJsControllerRef?.current?.cancel();
-
- await refreshJsClientFromNativeState({
- clerkInstance,
- nativeDeviceToken,
- previousDeviceToken,
- rejectForeignSessionlessClient: true,
- reloadInitialResources: true,
- shouldSyncDeviceToken: didChangeDeviceToken,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- });
-}
-
-/**
- * Syncs JS SDK client changes to the native Clerk SDK so native components
- * (UserButton, UserProfileView) stay in sync after JS-owned resource changes.
- *
- * Must be rendered inside `ClerkReactProvider` so the Clerk instance has loaded
- * resources to emit.
- */
-export function NativeClientSync({
- enabled,
- clerkInstance,
- nativeRefreshFromJsControllerRef,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- tokenCacheListenersRef,
-}: {
- enabled: boolean;
- clerkInstance: SyncableClerkInstance | null | undefined;
- nativeRefreshFromJsControllerRef: MutableRefObject;
- suppressJsClientChangedRef: MutableRefObject;
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
- tokenCacheListenersRef: MutableRefObject>;
-}): null {
- const isRefreshingNativeFromJsRef = useRef(false);
- const nativeRefreshPromiseRef = useRef | null>(null);
- const invalidateTrackedNativeRefreshRef = useRef<(() => void) | null>(null);
- const pendingNativeRefreshRef = useRef(null);
- const pendingNativeRefreshBeforeReadyRef = useRef(null);
- const pendingNativeRefreshBeforeReadyCompletionRef = useRef(null);
- const nativeRefreshGenerationRef = useRef(0);
- const lastUnauthenticatedRecoveryRef = useRef(undefined);
- const enabledRef = useRef(enabled);
- enabledRef.current = enabled;
-
- const queueNativeRefreshBeforeReady = useCallback((options: NativeRefreshFromJsOptions) => {
- pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions(
- pendingNativeRefreshBeforeReadyRef.current,
- options,
- );
- if (!pendingNativeRefreshBeforeReadyCompletionRef.current) {
- const completion = createNativeClientSyncCompletion();
- pendingNativeRefreshBeforeReadyCompletionRef.current = completion;
- completion.invalidateTracking = trackPendingJsToNativeSync(completion.promise);
- }
- }, []);
-
- const cancelNativeRefreshFromJs = useCallback(() => {
- invalidateTrackedNativeRefreshRef.current?.();
- invalidateTrackedNativeRefreshRef.current = null;
- pendingNativeRefreshRef.current = null;
- pendingNativeRefreshBeforeReadyRef.current = null;
- pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking();
- pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve();
- pendingNativeRefreshBeforeReadyCompletionRef.current = null;
- nativeRefreshGenerationRef.current += 1;
- isRefreshingNativeFromJsRef.current = false;
- nativeRefreshPromiseRef.current = null;
- }, []);
-
- useEffect(() => {
- if (
- !clerkInstance ||
- typeof clerkInstance.updateClient !== 'function' ||
- typeof clerkInstance.setActive !== 'function'
- ) {
- return;
- }
-
- const originalUpdateClient = clerkInstance.updateClient.bind(clerkInstance);
- let isReconcilingRemovedActiveSession = false;
-
- const updateClient: SyncableClerkInstance['updateClient'] = (newClient, options) => {
- const currentSessionId = clerkInstance.session?.id;
- const fallbackSession = getDefaultSignedInSession(newClient);
- const currentSessionWasRemoved = currentSessionId
- ? !newClient.signedInSessions.some(session => session.id === currentSessionId)
- : false;
- const alreadyReconcilingRemovedActiveSession = isReconcilingRemovedActiveSession;
-
- if ((currentSessionWasRemoved || alreadyReconcilingRemovedActiveSession) && fallbackSession) {
- // Clerk JS briefly emits signed-out when the active session disappears,
- // even if the refreshed client still has another signed-in session.
- // Keep that transient state internal so native session switching does
- // not dismiss mounted native UI before setActive settles on JS.
- originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true });
-
- if (clerkInstance.__internal_setActiveInProgress || alreadyReconcilingRemovedActiveSession) {
- return;
- }
-
- isReconcilingRemovedActiveSession = true;
- void runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => {
- try {
- await clerkInstance.setActive?.({ session: fallbackSession });
- } catch (error) {
- if (__DEV__) {
- console.warn('[NativeClientSync] Failed to set remaining active JS session:', error);
- }
- originalUpdateClient(newClient, options);
- } finally {
- isReconcilingRemovedActiveSession = false;
- }
- });
- return;
- }
-
- if (options) {
- originalUpdateClient(newClient, options);
- return;
- }
-
- originalUpdateClient(newClient);
- };
-
- clerkInstance.updateClient = updateClient;
-
- return () => {
- if (clerkInstance.updateClient === updateClient) {
- clerkInstance.updateClient = originalUpdateClient;
- }
- };
- }, [clerkInstance, suppressJsClientChangedRef]);
-
- const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): Promise => {
- if (isRefreshingNativeFromJsRef.current) {
- pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options);
- nativeRefreshGenerationRef.current += 1;
- return nativeRefreshPromiseRef.current ?? Promise.resolve();
- }
-
- const initialGeneration = nativeRefreshGenerationRef.current + 1;
- nativeRefreshGenerationRef.current = initialGeneration;
- isRefreshingNativeFromJsRef.current = true;
-
- const refreshNativeFromJsClient = async (
- options: NativeRefreshFromJsOptions,
- generation: number,
- ): Promise => {
- const ClerkExpo = NativeClerkModule;
- if (!ClerkExpo || generation !== nativeRefreshGenerationRef.current) {
- return;
- }
-
- const deviceToken = options.didChangeDeviceToken ? (options.deviceToken ?? null) : null;
- if (generation !== nativeRefreshGenerationRef.current) {
- return;
- }
-
- const sourceId = `${nativeClientSyncSourceIdPrefix}-${generation}`;
- await ClerkExpo.syncClientStateFromJs(
- deviceToken,
- sourceId,
- options.didChangeClient,
- options.didChangeDeviceToken,
- );
- };
-
- const nativeRefreshPromise = (async () => {
- let pendingOptions = options;
- let generation = initialGeneration;
- let refreshError: unknown;
- let didRefreshFail = false;
- do {
- pendingNativeRefreshRef.current = null;
- try {
- await refreshNativeFromJsClient(pendingOptions, generation);
- refreshError = undefined;
- didRefreshFail = false;
- } catch (error: unknown) {
- refreshError = error;
- didRefreshFail = true;
- if (__DEV__) {
- console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error);
- }
- if (pendingNativeRefreshRef.current) {
- pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(
- pendingOptions,
- pendingNativeRefreshRef.current,
- );
- }
- }
- pendingOptions = pendingNativeRefreshRef.current ?? {
- didChangeClient: false,
- didChangeDeviceToken: false,
- };
- if (pendingNativeRefreshRef.current !== null) {
- generation = nativeRefreshGenerationRef.current + 1;
- nativeRefreshGenerationRef.current = generation;
- }
- } while (pendingNativeRefreshRef.current !== null);
-
- if (didRefreshFail) {
- throw refreshError;
- }
- })();
- const finishNativeRefresh = () => {
- if (nativeRefreshPromiseRef.current === nativeRefreshPromise) {
- isRefreshingNativeFromJsRef.current = false;
- nativeRefreshPromiseRef.current = null;
- invalidateTrackedNativeRefreshRef.current = null;
- }
- };
-
- nativeRefreshPromiseRef.current = nativeRefreshPromise;
- void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh);
- invalidateTrackedNativeRefreshRef.current = trackPendingJsToNativeSync(nativeRefreshPromise);
- return nativeRefreshPromise;
- }, []);
-
- useEffect(() => {
- nativeRefreshFromJsControllerRef.current = {
- cancel: cancelNativeRefreshFromJs,
- syncDeviceTokenToNative: deviceToken => {
- void queueNativeRefreshFromJs({
- deviceToken,
- didChangeClient: false,
- didChangeDeviceToken: true,
- });
- },
- };
-
- return () => {
- if (nativeRefreshFromJsControllerRef.current?.cancel === cancelNativeRefreshFromJs) {
- nativeRefreshFromJsControllerRef.current = null;
- }
- };
- }, [cancelNativeRefreshFromJs, nativeRefreshFromJsControllerRef, queueNativeRefreshFromJs]);
-
- useEffect(() => {
- if (!enabled) {
- return;
- }
-
- if (pendingNativeRefreshBeforeReadyRef.current) {
- const pendingOptions = pendingNativeRefreshBeforeReadyRef.current;
- const pendingCompletion = pendingNativeRefreshBeforeReadyCompletionRef.current;
- pendingNativeRefreshBeforeReadyRef.current = null;
- pendingNativeRefreshBeforeReadyCompletionRef.current = null;
- void queueNativeRefreshFromJs(pendingOptions).then(pendingCompletion?.resolve, pendingCompletion?.resolve);
- }
- }, [enabled, queueNativeRefreshFromJs]);
-
- useEffect(() => {
- return cancelNativeRefreshFromJs;
- }, [cancelNativeRefreshFromJs]);
-
- useEffect(() => {
- const listener: DeviceTokenCacheListener = deviceToken => {
- // A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown.
- lastUnauthenticatedRecoveryRef.current = undefined;
-
- const options = {
- deviceToken,
- didChangeClient: false,
- didChangeDeviceToken: true,
- };
-
- if (!enabledRef.current) {
- if (clerkInstance?.loaded) {
- queueNativeRefreshBeforeReady(options);
- }
- return;
- }
-
- void queueNativeRefreshFromJs(options);
- };
- const tokenCacheListeners = tokenCacheListenersRef.current;
-
- tokenCacheListeners.add(listener);
- return () => {
- tokenCacheListeners.delete(listener);
- };
- }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, tokenCacheListenersRef]);
-
- useEffect(() => {
- if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') {
- return;
- }
-
- const originalHandleUnauthenticated = clerkInstance.handleUnauthenticated.bind(clerkInstance);
- let isHandlingUnauthenticated = false;
-
- const handleUnauthenticated: SyncableClerkInstance['handleUnauthenticated'] = async options => {
- if (isHandlingUnauthenticated) {
- return;
- }
-
- isHandlingUnauthenticated = true;
- try {
- // Re-reading native state and refetching the client for every response in a 401 burst only amplifies it.
- const lastRecovery = lastUnauthenticatedRecoveryRef.current;
- if (lastRecovery !== undefined) {
- const elapsed = Date.now() - lastRecovery;
- // A backwards clock jump makes elapsed negative; treat it as expired instead of waiting out the gap.
- if (elapsed >= 0 && elapsed < unauthenticatedRecoveryCooldownMs) {
- return await originalHandleUnauthenticated(options);
- }
- }
- lastUnauthenticatedRecoveryRef.current = Date.now();
-
- try {
- return await runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => {
- try {
- const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false });
- const previousDeviceToken = await getCachedDeviceToken(tokenCache);
- // Native may have already moved the server-side client to a new
- // active session. Refresh JS before allowing Clerk JS' stale-session
- // 401 path to collapse the whole client to signed out.
- const result = await refreshJsClientFromNativeState({
- clerkInstance,
- nativeDeviceToken,
- previousDeviceToken,
- rejectForeignSessionlessClient: true,
- reloadInitialResources: false,
- suppressDeviceTokenRollbackNotification: true,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- // The suppressed rollback write skips the listener that resyncs native, so the
- // restored token must be pushed to native from here.
- if (result === 'restored' && previousDeviceToken !== undefined) {
- nativeRefreshFromJsControllerRef.current?.syncDeviceTokenToNative(previousDeviceToken);
- }
- if (result) {
- return;
- }
- } catch (error) {
- const didRecover = await recoverJsClientFromNativeDeviceToken({
- clerkInstance,
- error,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- if (didRecover) {
- return;
- }
- }
-
- return originalHandleUnauthenticated(options);
- });
- } finally {
- // Slow attempts must not finish with a mostly spent window, so the stamp moves to settle
- // time. A rotation mid-attempt cleared the ref to force a fresh attempt; keep it cleared.
- if (lastUnauthenticatedRecoveryRef.current !== undefined) {
- lastUnauthenticatedRecoveryRef.current = Date.now();
- }
- }
- } finally {
- isHandlingUnauthenticated = false;
- }
- };
-
- clerkInstance.handleUnauthenticated = handleUnauthenticated;
-
- return () => {
- if (clerkInstance.handleUnauthenticated === handleUnauthenticated) {
- clerkInstance.handleUnauthenticated = originalHandleUnauthenticated;
- }
- };
- }, [
- clerkInstance,
- nativeRefreshFromJsControllerRef,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- ]);
-
- useEffect(() => {
- if (!clerkInstance || typeof clerkInstance.addListener !== 'function') {
- return;
- }
-
- const unsubscribe = clerkInstance.addListener(
- () => {
- if (suppressJsClientChangedRef.current > 0) {
- return;
- }
-
- if (!enabledRef.current) {
- if (clerkInstance.loaded) {
- queueNativeRefreshBeforeReady({
- didChangeClient: true,
- didChangeDeviceToken: false,
- });
- }
- return;
- }
-
- void queueNativeRefreshFromJs({
- didChangeClient: true,
- didChangeDeviceToken: false,
- });
- },
- { skipInitialEmit: true },
- );
-
- return () => {
- unsubscribe();
- };
- }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, suppressJsClientChangedRef]);
-
- return null;
-}
-
-function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise {
- if (clerkInstance.loaded) {
- return Promise.resolve();
- }
-
- if (typeof clerkInstance.on === 'function' && typeof clerkInstance.off === 'function') {
- return new Promise(resolve => {
- let didSettle = false;
- const settle = () => {
- if (didSettle) {
- return;
- }
- didSettle = true;
- clerkInstance.off?.('status', handleStatus);
- resolve();
- };
- const handleStatus = (status: string) => {
- if (status === 'ready' || status === 'degraded' || status === 'error') {
- settle();
- }
- };
-
- clerkInstance.on?.('status', handleStatus);
- if (
- clerkInstance.loaded ||
- clerkInstance.status === 'ready' ||
- clerkInstance.status === 'degraded' ||
- clerkInstance.status === 'error'
- ) {
- settle();
- }
- });
- }
-
- if (typeof clerkInstance.addOnLoaded === 'function') {
- return new Promise(resolve => clerkInstance.addOnLoaded?.(resolve));
- }
-
- if (__DEV__) {
- console.warn('[ClerkProvider] Clerk instance has no load status listener');
- }
- return Promise.resolve();
-}
-
-export function useNativeClientBootstrap({
- enabled,
- publishableKey,
- nativeRefreshFromJsControllerRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- clerkInstance,
-}: {
- enabled: boolean;
- publishableKey: string;
- nativeRefreshFromJsControllerRef: MutableRefObject;
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
- clerkInstance: SyncableClerkInstance | null | undefined;
-}) {
- const activeBootstrapRef = useRef(null);
- const bootstrapGenerationRef = useRef(0);
- const isMountedRef = useRef(true);
- const [readyPublishableKey, setReadyPublishableKey] = useState(null);
-
- useNativeClientBootstrapEffect(() => {
- isMountedRef.current = true;
- const canBootstrap = enabled && (Platform.OS === 'ios' || Platform.OS === 'android') && Boolean(publishableKey);
- const activeBootstrap = activeBootstrapRef.current;
- const canReuseActiveBootstrap =
- canBootstrap &&
- activeBootstrap?.publishableKey === publishableKey &&
- activeBootstrap.clerkInstance === clerkInstance &&
- activeBootstrap.tokenCache === tokenCache;
-
- if (activeBootstrap && !canReuseActiveBootstrap) {
- activeBootstrap.invalidateTracking();
- activeBootstrapRef.current = null;
- setReadyPublishableKey(null);
- }
-
- if (canBootstrap && !activeBootstrapRef.current) {
- const configuringPublishableKey = publishableKey;
- const bootstrapRegistration: NativeClientBootstrapRegistration = {
- clerkInstance,
- generation: ++bootstrapGenerationRef.current,
- invalidateTracking: () => undefined,
- publishableKey: configuringPublishableKey,
- tokenCache,
- };
- activeBootstrapRef.current = bootstrapRegistration;
- setReadyPublishableKey(null);
- const isCurrentConfiguration = () =>
- isMountedRef.current &&
- activeBootstrapRef.current === bootstrapRegistration &&
- bootstrapGenerationRef.current === bootstrapRegistration.generation;
-
- const configureNativeClerk = async () => {
- try {
- const ClerkExpo = NativeClerkModule;
-
- if (ClerkExpo?.configure) {
- if (clerkInstance) {
- await waitForClerkInstanceLoad(clerkInstance);
-
- if (!isCurrentConfiguration()) {
- return;
- }
- }
-
- let initialJsDeviceToken: string | null = null;
- try {
- initialJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null;
- } catch (e) {
- if (__DEV__) {
- console.warn('[ClerkProvider] Token cache read failed:', e);
- }
- }
-
- if (!isCurrentConfiguration()) {
- return;
- }
-
- await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken);
-
- if (!isCurrentConfiguration()) {
- return;
- }
-
- if (clerkInstance) {
- const currentJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null;
- const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false });
-
- if (!isCurrentConfiguration()) {
- return;
- }
-
- if (currentJsDeviceToken !== nativeDeviceToken) {
- if (
- !nativeDeviceToken ||
- (initialJsDeviceToken !== null && currentJsDeviceToken !== initialJsDeviceToken)
- ) {
- nativeRefreshFromJsControllerRef.current?.cancel();
- await ClerkExpo.syncClientStateFromJs(
- currentJsDeviceToken,
- `${nativeClientSyncSourceIdPrefix}-bootstrap`,
- true,
- true,
- );
- } else {
- await syncNativeClientToJs({
- clerkInstance,
- nativeRefreshFromJsControllerRef,
- nativeClientEvent: {
- changed: { client: true, deviceToken: true },
- deviceToken: nativeDeviceToken,
- issuedAt: Date.now(),
- },
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- }
- }
- }
-
- if (isCurrentConfiguration()) {
- setReadyPublishableKey(configuringPublishableKey);
- }
- }
- } catch (error) {
- const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module');
- if (isNativeModuleNotFound) {
- if (__DEV__) {
- console.debug(
- `[ClerkProvider] Native Clerk module not available. ` +
- `To enable native features, install @clerk/expo-native-components and add "@clerk/expo-native-components" to your app.json plugins array.`,
- );
- }
- } else if (__DEV__) {
- console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error);
- }
- throw error;
- }
- };
- const configureNativeClerkWithRetry = async () => {
- for (let attempt = 1; attempt <= nativeClientConfigurationMaxAttempts; attempt++) {
- try {
- await configureNativeClerk();
- return;
- } catch (error) {
- const isNativeModuleNotFound =
- error instanceof Error && error.message.includes('Cannot find native module');
- if (
- !isCurrentConfiguration() ||
- isNativeModuleNotFound ||
- attempt === nativeClientConfigurationMaxAttempts
- ) {
- if (isCurrentConfiguration()) {
- nativeRefreshFromJsControllerRef.current?.cancel();
- }
- throw error;
- }
-
- await new Promise(resolve => setTimeout(resolve, nativeClientConfigurationRetryDelayMs));
- if (!isCurrentConfiguration()) {
- return;
- }
- }
- }
- };
- const nativeClientBootstrap = configureNativeClerkWithRetry();
- bootstrapRegistration.invalidateTracking = trackPendingJsToNativeSync(
- nativeClientBootstrap,
- nativeClientBootstrapTimeoutMs,
- );
- void nativeClientBootstrap;
- }
-
- return () => {
- isMountedRef.current = false;
- const bootstrapRegistration = activeBootstrapRef.current;
- queueMicrotask(() => {
- if (!isMountedRef.current && activeBootstrapRef.current === bootstrapRegistration) {
- bootstrapRegistration?.invalidateTracking();
- activeBootstrapRef.current = null;
- }
- });
- };
- }, [
- enabled,
- publishableKey,
- nativeRefreshFromJsControllerRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- clerkInstance,
- ]);
-
- return {
- isMountedRef,
- isNativeClientReady: readyPublishableKey === publishableKey,
- };
-}
-
-export function useNativeClientEventSync({
- enabled,
- clerkInstance,
- isMountedRef,
- nativeRefreshFromJsControllerRef,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
-}: {
- enabled: boolean;
- clerkInstance: SyncableClerkInstance | null | undefined;
- isMountedRef: MutableRefObject;
- nativeRefreshFromJsControllerRef: MutableRefObject;
- suppressJsClientChangedRef: MutableRefObject;
- suppressTokenCacheNotificationsRef: MutableRefObject;
- tokenCache: TokenCache | undefined;
-}) {
- const { nativeClientEvent } = useNativeClientEvents(enabled);
-
- useEffect(() => {
- if (!clerkInstance) {
- return;
- }
-
- return registerNativeToJsSyncHandler(async event => {
- if (!isMountedRef.current) {
- throw new Error('ClerkProvider was unmounted before native client synchronization completed.');
- }
-
- await syncNativeClientToJs({
- clerkInstance,
- nativeRefreshFromJsControllerRef,
- nativeClientEvent: event,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- });
- });
- }, [
- clerkInstance,
- isMountedRef,
- nativeRefreshFromJsControllerRef,
- suppressJsClientChangedRef,
- suppressTokenCacheNotificationsRef,
- tokenCache,
- ]);
-
- useEffect(() => {
- if (!enabled || !nativeClientEvent || nativeClientEvent.sourceId?.startsWith(nativeClientSyncSourceIdPrefix)) {
- return;
- }
-
- const syncNativeClientStateToJs = async () => {
- try {
- await synchronizeNativeClientToJs(nativeClientEvent);
- } catch (error) {
- console.error(`[ClerkProvider] Failed to sync native client state:`, error);
- }
- };
-
- void syncNativeClientStateToJs();
- }, [enabled, nativeClientEvent]);
-}
diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts
deleted file mode 100644
index aa7de3b47fa..00000000000
--- a/packages/expo/src/provider/nativeClientSyncCoordinator.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import type { NativeClientEvent } from '../hooks/useNativeClientEvents';
-
-type NativeToJsSyncHandler = (nativeClientEvent?: NativeClientEvent | null) => Promise;
-
-type NativeToJsSyncRegistration = {
- handler: NativeToJsSyncHandler;
- pendingEventSyncs: Set>;
- pendingExplicitSync: Promise | null;
- pendingExplicitSyncEvent: NativeClientEvent | null;
- explicitSyncRequestGeneration: number;
- explicitSyncCompletedGeneration: number;
-};
-
-const pendingJsToNativeSyncs = new Map, number>();
-const defaultPendingJsToNativeSyncTimeoutMs = 5_000;
-let jsToNativeSyncGeneration = 0;
-let latestSettledJsToNativeSyncGeneration = 0;
-let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null;
-let nativeToJsSyncRegistration: NativeToJsSyncRegistration | null = null;
-let jsToNativeSyncEpoch = 0;
-
-function removePendingSync(pendingSyncs: Set>, sync: Promise): void {
- pendingSyncs.delete(sync);
-}
-
-function mergeNativeClientEvents(current: NativeClientEvent | null, next: NativeClientEvent): NativeClientEvent {
- if (!current) {
- return next;
- }
-
- return {
- ...next,
- changed: {
- client: current.changed.client || next.changed.client,
- deviceToken: current.changed.deviceToken || next.changed.deviceToken,
- },
- };
-}
-
-function createPendingJsToNativeSyncTimeoutError(): Error & { code: 'environment_unavailable' } {
- return Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), {
- code: 'environment_unavailable' as const,
- });
-}
-
-export function trackPendingJsToNativeSync(
- sync: Promise,
- timeoutMs = defaultPendingJsToNativeSyncTimeoutMs,
-): () => void {
- const epoch = jsToNativeSyncEpoch;
- const generation = ++jsToNativeSyncGeneration;
- let isInvalidated = false;
- const trackedSync = sync.then(
- () => {
- if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) {
- latestSettledJsToNativeSyncGeneration = generation;
- latestJsToNativeSyncFailure = null;
- }
- },
- error => {
- if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) {
- latestSettledJsToNativeSyncGeneration = generation;
- latestJsToNativeSyncFailure = { error, generation };
- }
- },
- );
-
- pendingJsToNativeSyncs.set(trackedSync, timeoutMs);
- void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync));
-
- return () => {
- isInvalidated = true;
- pendingJsToNativeSyncs.delete(trackedSync);
- };
-}
-
-export async function waitForPendingJsToNativeSync(): Promise {
- const waitStartedAt = Date.now();
- let deadline = waitStartedAt + defaultPendingJsToNativeSyncTimeoutMs;
- while (pendingJsToNativeSyncs.size > 0) {
- deadline = Math.max(deadline, waitStartedAt + Math.max(...pendingJsToNativeSyncs.values()));
- const remainingMs = deadline - Date.now();
- if (remainingMs <= 0) {
- throw createPendingJsToNativeSyncTimeoutError();
- }
-
- let timeoutId: ReturnType | undefined;
- const timeout = new Promise((_resolve, reject) => {
- timeoutId = setTimeout(() => {
- reject(createPendingJsToNativeSyncTimeoutError());
- }, remainingMs);
- });
-
- try {
- await Promise.race([Promise.all(pendingJsToNativeSyncs.keys()), timeout]);
- } finally {
- clearTimeout(timeoutId);
- }
- }
-
- if (latestJsToNativeSyncFailure) {
- throw latestJsToNativeSyncFailure.error;
- }
-}
-
-export function __internal_resetNativeClientSyncCoordinator(): void {
- jsToNativeSyncEpoch += 1;
- pendingJsToNativeSyncs.clear();
- jsToNativeSyncGeneration = 0;
- latestSettledJsToNativeSyncGeneration = 0;
- latestJsToNativeSyncFailure = null;
- nativeToJsSyncRegistration = null;
-}
-
-export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): () => void {
- const registration = {
- handler,
- pendingEventSyncs: new Set>(),
- pendingExplicitSync: null,
- pendingExplicitSyncEvent: null,
- explicitSyncRequestGeneration: 0,
- explicitSyncCompletedGeneration: 0,
- };
- nativeToJsSyncRegistration = registration;
-
- return () => {
- if (nativeToJsSyncRegistration === registration) {
- nativeToJsSyncRegistration = null;
- }
- };
-}
-
-export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEvent | null): Promise {
- const registration = nativeToJsSyncRegistration;
- if (!registration) {
- return Promise.reject(new Error('Native Clerk client synchronization is not available.'));
- }
-
- if (nativeClientEvent) {
- if (registration.pendingExplicitSync) {
- registration.pendingExplicitSyncEvent = mergeNativeClientEvents(
- registration.pendingExplicitSyncEvent,
- nativeClientEvent,
- );
- registration.explicitSyncRequestGeneration += 1;
- return registration.pendingExplicitSync;
- }
-
- const pendingEvents = [...registration.pendingEventSyncs];
- const sync = Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))).then(() =>
- registration.handler(nativeClientEvent),
- );
- registration.pendingEventSyncs.add(sync);
- void sync.then(
- () => removePendingSync(registration.pendingEventSyncs, sync),
- () => removePendingSync(registration.pendingEventSyncs, sync),
- );
- return sync;
- }
-
- registration.explicitSyncRequestGeneration += 1;
- if (registration.pendingExplicitSync) {
- return registration.pendingExplicitSync;
- }
-
- const pendingEvents = [...registration.pendingEventSyncs];
- const sync = (async () => {
- await Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined)));
-
- let firstError: unknown;
- let didFail = false;
- while (registration.explicitSyncCompletedGeneration < registration.explicitSyncRequestGeneration) {
- const generation = registration.explicitSyncRequestGeneration;
- const pendingEvent = registration.pendingExplicitSyncEvent;
- registration.pendingExplicitSyncEvent = null;
- try {
- await (pendingEvent ? registration.handler(pendingEvent) : registration.handler());
- } catch (error) {
- if (!didFail) {
- firstError = error;
- didFail = true;
- }
- }
- registration.explicitSyncCompletedGeneration = generation;
- }
-
- registration.pendingExplicitSync = null;
-
- if (didFail) {
- throw firstError;
- }
- })();
- registration.pendingExplicitSync = sync;
- return sync;
-}
diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts
index 4f08c16bedd..3c45a2d4059 100644
--- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts
+++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts
@@ -453,6 +453,33 @@ describe('createClerkInstance', () => {
expect(latestTokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'fresh-token');
});
+ test('writes a rotated client token against the token each request was sent with', async () => {
+ const tokenCache = {
+ getToken: vi.fn().mockResolvedValueOnce('token-a').mockResolvedValueOnce('token-b'),
+ saveToken: vi.fn(() => Promise.resolve()),
+ saveClientToken: vi.fn(() => Promise.resolve()),
+ };
+
+ const createClerkInstance = await loadCreateClerkInstance();
+ const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
+ const clerk = getClerkInstance({ publishableKey: 'pk_test_123', tokenCache }) as unknown as MockClerk;
+
+ const beforeRequest = clerk.__internal_onBeforeRequest.mock.calls[0][0];
+ const afterResponse = clerk.__internal_onAfterResponse.mock.calls[0][0];
+ const firstRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/client') };
+ const secondRequest = { headers: new Headers(), url: new URL('https://clerk.example.com/v1/me') };
+ await beforeRequest(firstRequest);
+ await beforeRequest(secondRequest);
+ await afterResponse(secondRequest, { headers: new Headers({ authorization: 'token-c' }), payload: null });
+ await afterResponse(firstRequest, { headers: new Headers({ authorization: 'token-d' }), payload: null });
+
+ expect(tokenCache.saveClientToken.mock.calls).toEqual([
+ ['token-c', 'token-b'],
+ ['token-d', 'token-a'],
+ ]);
+ expect(tokenCache.saveToken).not.toHaveBeenCalled();
+ });
+
describe('initial resource recovery', () => {
const setupUnavailableResources = async () => {
const createClerkInstance = await loadCreateClerkInstance();
diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts
index 56230604852..b82c8e64278 100644
--- a/packages/expo/src/provider/singleton/createClerkInstance.ts
+++ b/packages/expo/src/provider/singleton/createClerkInstance.ts
@@ -25,6 +25,7 @@ import { CLERK_CLIENT_JWT_KEY } from '../../constants';
import { errorThrower } from '../../errorThrower';
import { assertValidProxyUrl } from '../../utils/errors';
import { isNative } from '../../utils/runtime';
+import type { ClientTokenCache } from '../nativeClientSync';
import type { BuildClerkOptions } from './types';
/**
@@ -124,7 +125,13 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
}
const getToken = (key: string) => __internal_tokenCache.getToken(key);
- const saveToken = (key: string, token: string) => __internal_tokenCache.saveToken(key, token);
+ const saveClientToken = (token: string, requestToken: string | null) => {
+ const tokenCache = __internal_tokenCache as TokenCache & Partial;
+ return tokenCache.saveClientToken
+ ? tokenCache.saveClientToken(token, requestToken)
+ : tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token);
+ };
+ const requestTokens = new WeakMap();
__internal_clerkOptions = { publishableKey, proxyUrl, domain };
const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk;
@@ -291,6 +298,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
requestInit.url?.searchParams.append('_is_native', '1');
const jwt = await getToken(CLERK_CLIENT_JWT_KEY);
+ requestTokens.set(requestInit, jwt ?? null);
(requestInit.headers as Headers).set('authorization', jwt || '');
// Instructs the backend that the request is from a mobile device.
@@ -303,10 +311,10 @@ export function createClerkInstance(ClerkClass: typeof Clerk) {
let nativeApiErrorShown = false;
// @ts-expect-error - This is an internal API
- __internal_clerk.__internal_onAfterResponse(async (_: FapiRequestInit, response: FapiResponse) => {
+ __internal_clerk.__internal_onAfterResponse(async (requestInit: FapiRequestInit, response: FapiResponse) => {
const authHeader = response.headers.get('authorization');
if (authHeader) {
- await saveToken(CLERK_CLIENT_JWT_KEY, authHeader);
+ await saveClientToken(authHeader, requestTokens.get(requestInit) ?? null);
}
if (__DEV__ && !nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') {
diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts
index 51d214fc53c..6870d597df8 100644
--- a/packages/expo/src/specs/NativeClerkModule.android.ts
+++ b/packages/expo/src/specs/NativeClerkModule.android.ts
@@ -6,18 +6,10 @@ import type {
NativeClientSyncModule,
} from './NativeClerkModule.types';
-interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial {
- // Exposed by Expo Modules EventEmitter for internal native client change events.
+interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule {
+ // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
- configure(publishableKey: string, bearerToken: string | null): Promise;
- getClientToken(): Promise;
- syncClientStateFromJs(
- deviceToken: string | null,
- sourceId: string | null,
- didChangeClient: boolean,
- didChangeDeviceToken: boolean,
- ): Promise;
}
// Optional so it resolves to null in Expo Go instead of throwing at import time.
diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts
index c999c57d6c4..457fa431210 100644
--- a/packages/expo/src/specs/NativeClerkModule.ts
+++ b/packages/expo/src/specs/NativeClerkModule.ts
@@ -6,18 +6,10 @@ import type {
NativeClientSyncModule,
} from './NativeClerkModule.types';
-export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial {
- // Exposed by Expo Modules EventEmitter for internal native client change events.
+export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, NativeClientSyncModule {
+ // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
- configure(publishableKey: string, bearerToken: string | null): Promise;
- getClientToken(): Promise;
- syncClientStateFromJs(
- deviceToken: string | null,
- sourceId: string | null,
- didChangeClient: boolean,
- didChangeDeviceToken: boolean,
- ): Promise;
}
export default requireOptionalNativeModule('ClerkExpo');
diff --git a/packages/expo/src/utils/__tests__/native-module.test.ts b/packages/expo/src/utils/__tests__/native-module.test.ts
index 70d95547ba5..cfe3882cdf7 100644
--- a/packages/expo/src/utils/__tests__/native-module.test.ts
+++ b/packages/expo/src/utils/__tests__/native-module.test.ts
@@ -10,9 +10,10 @@ const makeNativeModule = ({ includeEventMethods = true } = {}) => ({
addListener: vi.fn(),
}
: {}),
- configure: vi.fn(),
- getClientToken: vi.fn(),
- syncClientStateFromJs: vi.fn(),
+ configureNative: vi.fn(),
+ getDeviceToken: vi.fn(),
+ setDeviceToken: vi.fn(),
+ refreshClient: vi.fn(),
});
vi.mock('react-native', () => ({
@@ -54,7 +55,7 @@ describe('native module loader', () => {
test('returns null when no native module satisfies the bootstrap contract', async () => {
mocks.nativeModule = {
- configure: vi.fn(),
+ configureNative: vi.fn(),
};
const { ClerkExpoModule } = await importNativeModule();
diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts
index e81c3aabee9..e89da01bc8e 100644
--- a/packages/expo/src/utils/native-module.ts
+++ b/packages/expo/src/utils/native-module.ts
@@ -11,15 +11,8 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro
export type ClerkExpoNativeModule = {
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
- configure(publishableKey: string, bearerToken: string | null): Promise;
- getClientToken(): Promise;
- syncClientStateFromJs(
- deviceToken: string | null,
- sourceId: string | null,
- didChangeClient: boolean,
- didChangeDeviceToken: boolean,
- ): Promise;
-} & Partial;
+} & NativeClientSyncModule &
+ Partial;
function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule {
if (!module || typeof module !== 'object') {
@@ -28,9 +21,10 @@ function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule {
const maybeModule = module as Record;
return (
- typeof maybeModule.configure === 'function' &&
- typeof maybeModule.getClientToken === 'function' &&
- typeof maybeModule.syncClientStateFromJs === 'function'
+ typeof maybeModule.configureNative === 'function' &&
+ typeof maybeModule.getDeviceToken === 'function' &&
+ typeof maybeModule.setDeviceToken === 'function' &&
+ typeof maybeModule.refreshClient === 'function'
);
}
From 1b59ac37781280c1400ac38c27b761bb5a0a4ee1 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:40:50 -0400
Subject: [PATCH 13/15] fix(expo): ignore session activity in the native sync
fingerprint
Session updatedAt can move on session activity and token refresh, which
would refresh the native client on every token refresh. Profile edits
still reach native through the active user's updatedAt.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../nativeClientSyncBehavior/fakeClerk.ts | 9 ++++++---
.../nativeClientSyncBehavior/fakeServer.ts | 13 ++++++++----
.../nativeClientSync.behavior.test.tsx | 20 +++++++++++++++++++
.../expo/src/provider/nativeClientSync.ts | 2 +-
4 files changed, 36 insertions(+), 8 deletions(-)
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
index 8358f2e7d43..0f7bfc91839 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts
@@ -6,6 +6,7 @@ import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fake
export type FakeSessionResource = {
id: string;
status: 'active';
+ activityVersion: number;
updatedAt: Date;
user: { id: string; profileVersion: number; updatedAt: Date };
};
@@ -19,12 +20,12 @@ type ListenerCallback = (resources: { client: FakeClientResource; session: FakeS
type StatusListener = (status: string) => void;
function toSessionResource(session: FakeServerClientJSON['sessions'][number]): FakeSessionResource {
- const updatedAt = new Date(session.profileVersion);
return {
id: session.id,
status: 'active',
- updatedAt,
- user: { id: session.userId, profileVersion: session.profileVersion, updatedAt },
+ activityVersion: session.activityVersion,
+ updatedAt: new Date(session.profileVersion + session.activityVersion),
+ user: { id: session.userId, profileVersion: session.profileVersion, updatedAt: new Date(session.profileVersion) },
};
}
@@ -63,6 +64,7 @@ export class FakeClientResource {
id: session.id,
userId: session.user.id,
profileVersion: session.user.profileVersion,
+ activityVersion: session.activityVersion,
})),
};
}
@@ -238,6 +240,7 @@ export class FakeClerk {
await this.handleUnauthenticated();
return false;
}
+ this.#applyPiggybackedClient(response);
return true;
}
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
index 637a7d84d51..9a3ea40a8f8 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts
@@ -2,6 +2,8 @@ export type FakeServerSessionJSON = {
id: string;
userId: string;
profileVersion: number;
+ /** Bumped by session activity such as minting a session token. */
+ activityVersion: number;
};
export type FakeServerClientJSON = {
@@ -95,7 +97,7 @@ export class FakeClerkServer {
const { client, token } = this.#createClient();
const sessionIds: Record = {};
for (const userId of users) {
- const session = { id: this.#nextId('sess'), userId, profileVersion: 0 };
+ const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 };
client.sessions.push(session);
sessionIds[userId] = session.id;
}
@@ -151,7 +153,7 @@ export class FakeClerkServer {
client = created.client;
responseToken = created.token;
}
- const session = { id: this.#nextId('sess'), userId, profileVersion: 0 };
+ const session = { id: this.#nextId('sess'), userId, profileVersion: 0, activityVersion: 0 };
client.sessions.push(session);
client.lastActiveSessionId = session.id;
return { ...this.#respond(responseToken, client), sessionId: session.id };
@@ -193,8 +195,11 @@ export class FakeClerkServer {
async createSessionToken(caller: FapiCaller, token: string | null, sessionId: string): Promise {
await this.#request(caller, 'POST /client/sessions/:id/tokens', token);
const client = this.#clientFor(token);
- const status = client?.sessions.some(session => session.id === sessionId) ? 200 : 401;
- return this.#respond(token, client, status);
+ const session = client?.sessions.find(candidate => candidate.id === sessionId);
+ if (session) {
+ session.activityVersion += 1;
+ }
+ return this.#respond(token, client, session ? 200 : 401);
}
async updateUser(caller: FapiCaller, token: string | null, sessionId: string): Promise {
diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
index 9a8030be429..8659c489ee8 100644
--- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx
@@ -289,6 +289,26 @@ describe('native client sync behavior', () => {
await expectConverged(h);
});
+ it('refreshes native for a profile update but not for session activity that only bumps the session', async () => {
+ const { server, token, sessionIds } = seedDevice(['user_1']);
+ const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
+ const refreshesBefore = h.native.clientRefreshCount;
+ const updatedAtBefore = h.js.session?.updatedAt.getTime();
+
+ await h.run(() => Promise.all([h.js.getSessionToken(), h.js.getSessionToken()]));
+ await h.settle();
+
+ expect(h.js.session?.updatedAt.getTime()).toBeGreaterThan(updatedAtBefore ?? Infinity);
+ expect(h.native.clientRefreshCount - refreshesBefore).toBe(0);
+
+ await h.run(() => h.js.updateProfile());
+ await h.settle();
+
+ expect(h.native.clientRefreshCount - refreshesBefore).toBe(1);
+ expect(h.native.client?.sessions.find(session => session.id === sessionIds.user_1)?.profileVersion).toBe(1);
+ await expectConverged(h);
+ });
+
it('settles a JS change without an echo loop', async () => {
const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts
index 6920d6afded..dc282fc4f3d 100644
--- a/packages/expo/src/provider/nativeClientSync.ts
+++ b/packages/expo/src/provider/nativeClientSync.ts
@@ -97,7 +97,7 @@ function fingerprint(clerk: SyncableClerk): string {
return JSON.stringify([
client?.id ?? null,
client?.lastActiveSessionId ?? null,
- client?.signedInSessions.map(session => [session.id, session.status, +session.updatedAt]) ?? [],
+ client?.signedInSessions.map(session => [session.id, session.status]) ?? [],
user?.id ?? null,
+(user?.updatedAt ?? 0),
]);
From 5a9f6abf3236ceb5b614885453bc5323f1a5fb89 Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:00:08 -0400
Subject: [PATCH 14/15] refactor(expo-native): remove legacy client sync bridge
Remove configure, getClientToken, syncClientStateFromJs and the
clerkNativeClientChanged event from the iOS and Android ClerkExpo module,
along with the client observation loop and JS-sync suppression state that
only served them. The JS engine now uses configureNative, getDeviceToken,
setDeviceToken, refreshClient and clerkNativeClientInvalidated.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/remove-legacy-expo-native-sync.md | 2 +
.../expo/modules/clerk/ClerkAuthViewModule.kt | 2 +-
.../expo/modules/clerk/ClerkExpoModule.kt | 428 +-----------------
.../ios/ClerkExpoModule.swift | 88 +---
.../ios/ClerkNativeBridge.swift | 252 +----------
.../src/specs/__tests__/androidSpecs.test.ts | 2 +-
6 files changed, 17 insertions(+), 757 deletions(-)
create mode 100644 .changeset/remove-legacy-expo-native-sync.md
diff --git a/.changeset/remove-legacy-expo-native-sync.md b/.changeset/remove-legacy-expo-native-sync.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/remove-legacy-expo-native-sync.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
index 8d5d9e49506..b72c1801e40 100644
--- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
+++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
@@ -64,7 +64,7 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo
private val onHostBack by EventDispatcher()
init {
- // At cold start, ClerkExpoModule.configure() may run before React's
+ // At cold start, ClerkExpoModule.configureNative() may run before React's
// host-resume sync, so this view's construction is a reliable second hook.
activity?.let { Clerk.attachActivity(it) }
}
diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
index 84ec5128587..93c1030fcb5 100644
--- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
+++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -32,20 +32,16 @@ import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeout
import org.json.JSONObject
private const val TAG = "ClerkExpoModule"
private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
-private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated"
private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
@@ -204,9 +200,6 @@ internal fun biometricCredentialBridgeError(
class ClerkExpoModule : Module() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private var authFlowStateObserverJob: Job? = null
- private var clientStateObserverJob: Job? = null
- private var lastObservedClientState: ClientStateSnapshot? = null
- private var jsOriginatedClientSyncDepth = 0
private var configuredPublishableKey: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var clientInvalidationObserverJob: Job? = null
@@ -220,62 +213,23 @@ class ClerkExpoModule : Module() {
val isAuthFlowComplete: Boolean
)
- private data class ClientStateSnapshot(
- val client: Client?,
- val deviceToken: String?
- )
-
- private data class ClientStateChanges(
- val client: Boolean,
- val deviceToken: Boolean
- )
-
- companion object {
- private var sharedInstance: ClerkExpoModule? = null
-
- fun emitClientChanged(sourceId: String? = null) {
- val instance = sharedInstance ?: return
- instance.sendEvent(
- NATIVE_CLIENT_CHANGED_EVENT,
- instance.clientChangedPayload(
- sourceId = sourceId,
- changes = ClientStateChanges(client = true, deviceToken = true)
- )
- )
- }
- }
-
override fun definition() = ModuleDefinition {
Name("ClerkExpo")
- Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)
+ Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)
OnCreate {
- sharedInstance = this@ClerkExpoModule
startAuthFlowStateObserver()
}
OnDestroy {
- if (sharedInstance === this@ClerkExpoModule) {
- sharedInstance = null
- }
authFlowStateObserverJob?.cancel()
authFlowStateObserverJob = null
- clientStateObserverJob?.cancel()
- clientStateObserverJob = null
clientInvalidationObserverJob?.cancel()
clientInvalidationObserverJob = null
mainHandler.removeCallbacksAndMessages(null)
}
- AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
- configure(pubKey, bearerToken, promise)
- }
-
- AsyncFunction("getClientToken") { promise: Promise ->
- getClientToken(promise)
- }
-
AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise ->
configureNative(pubKey, seedDeviceToken, promise)
}
@@ -296,21 +250,6 @@ class ClerkExpoModule : Module() {
promise.resolve(authFlowStatePayload())
}
- AsyncFunction("syncClientStateFromJs") {
- deviceToken: String?,
- sourceId: String?,
- didChangeClient: Boolean,
- didChangeDeviceToken: Boolean,
- promise: Promise ->
- syncClientStateFromJs(
- deviceToken,
- sourceId,
- didChangeClient,
- didChangeDeviceToken,
- promise
- )
- }
-
AsyncFunction("getTrustedDeviceAvailability") {
id: String?,
identifierHint: String?,
@@ -401,249 +340,6 @@ class ClerkExpoModule : Module() {
)
}
- private fun startClientStateObserver() {
- if (clientStateObserverJob != null) {
- return
- }
-
- lastObservedClientState = clientStateSnapshot()
-
- clientStateObserverJob = coroutineScope.launch {
- Clerk.clientFlow.collect { client ->
- val previousClientState = lastObservedClientState
- val newClientState = clientStateSnapshot(client)
-
- if (newClientState == previousClientState) {
- return@collect
- }
-
- lastObservedClientState = newClientState
- if (jsOriginatedClientSyncDepth > 0) {
- return@collect
- }
-
- sendEvent(
- NATIVE_CLIENT_CHANGED_EVENT,
- clientChangedPayload(
- deviceToken = newClientState.deviceToken,
- changes = ClientStateChanges(
- client = newClientState.client != previousClientState?.client,
- deviceToken = newClientState.deviceToken != previousClientState?.deviceToken
- )
- )
- )
- }
- }
- }
-
- private fun clientStateSnapshot(client: Client? = Clerk.clientFlow.value): ClientStateSnapshot {
- return ClientStateSnapshot(
- client = client,
- deviceToken = try {
- Clerk.getDeviceToken()
- } catch (e: Exception) {
- debugLog(TAG, "clientStateSnapshot - getDeviceToken failed: ${e.message}")
- null
- }
- )
- }
-
- private fun clientChangedPayload(
- sourceId: String? = null,
- changes: ClientStateChanges,
- deviceToken: String? = clientStateSnapshot().deviceToken
- ): Map {
- val result = mutableMapOf(
- "changed" to mapOf(
- "client" to changes.client,
- "deviceToken" to changes.deviceToken
- ),
- "deviceToken" to deviceToken
- )
- if (!sourceId.isNullOrEmpty()) {
- result["sourceId"] = sourceId
- }
- return result
- }
-
- private fun emitSyncedClientChanged(
- sourceId: String?,
- changes: ClientStateChanges,
- snapshot: ClientStateSnapshot = clientStateSnapshot()
- ) {
- lastObservedClientState = snapshot
- sendEvent(
- NATIVE_CLIENT_CHANGED_EVENT,
- clientChangedPayload(
- sourceId = sourceId,
- changes = changes,
- deviceToken = snapshot.deviceToken
- )
- )
- }
-
- // MARK: - configure
-
- private fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
- val context = reactContext ?: run {
- promise.reject("E_INIT_FAILED", "React context is not available", null)
- return
- }
-
- coroutineScope.launch {
- try {
- val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() }
-
- if (!Clerk.isInitialized.value) {
- // First-time initialization — write the bearer token to SharedPreferences
- // before initializing so the SDK boots with the correct client.
- if (normalizedBearerToken != null) {
- context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
- .edit()
- .putString("DEVICE_TOKEN", normalizedBearerToken)
- .apply()
- }
-
- Clerk.initialize(context, pubKey, clerkConfigurationOptions())
- startClientStateObserver()
- // clerk-android registers ActivityLifecycleCallbacks during
- // initialize(), but in React Native MainActivity has already passed
- // onResume() by the time mounts and we reach this
- // line, so the callbacks miss the initial activity. Without seeding,
- // the first Credential Manager call (Google sign-in / passkeys)
- // fails with MissingActivity until the user backgrounds and
- // foregrounds the app. currentActivity can be null here on
- // cold start before React's host-resume sync — AuthView and
- // UserProfile also call attachActivity() on mount as a backstop.
- appContext.currentActivity?.let { Clerk.attachActivity(it) }
- // Must be set AFTER Clerk.initialize() because initialize()
- // resets customTheme to its `theme` parameter (default null).
- loadThemeFromAssets(context)
-
- // Wait for initialization to complete with timeout
- try {
- withTimeout(10_000L) {
- Clerk.isInitialized.first { it }
- }
- // If a bearer token was provided, wait for native client state to hydrate
- // before resolving the configure call.
- if (normalizedBearerToken != null) {
- withTimeout(5_000L) {
- Clerk.clientFlow.first { it != null }
- }
- }
- } catch (e: TimeoutCancellationException) {
- val initError = Clerk.initializationError.value
- val message = if (initError != null) {
- "Clerk initialization timed out: ${initError.message}"
- } else {
- "Clerk initialization timed out after 10 seconds"
- }
- promise.reject("E_TIMEOUT", message, null)
- return@launch
- }
-
- // Check for initialization errors
- val error = Clerk.initializationError.value
- if (error != null) {
- promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null)
- } else {
- configuredPublishableKey = pubKey
- lastObservedClientState = clientStateSnapshot()
- promise.resolve(null)
- }
- return@launch
- }
-
- val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
- if (activePublishableKey != null && activePublishableKey != pubKey) {
- Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions())
- startClientStateObserver()
- appContext.currentActivity?.let { Clerk.attachActivity(it) }
- loadThemeFromAssets(context)
-
- try {
- withTimeout(10_000L) {
- Clerk.isInitialized.first { it }
- }
- } catch (e: TimeoutCancellationException) {
- val initError = Clerk.initializationError.value
- val message = if (initError != null) {
- "Clerk reconfiguration timed out: ${initError.message}"
- } else {
- "Clerk reconfiguration timed out after 10 seconds"
- }
- promise.reject("E_TIMEOUT", message, null)
- return@launch
- }
-
- val error = Clerk.initializationError.value
- if (error != null) {
- promise.reject("E_RECONFIGURE_FAILED", "Failed to reconfigure Clerk SDK: ${error.message}", null)
- return@launch
- }
-
- if (normalizedBearerToken != null) {
- val clientState = clientStateSnapshot()
- if (clientState.deviceToken != normalizedBearerToken || clientState.client == null) {
- val result = Clerk.updateDeviceToken(normalizedBearerToken)
- if (result is ClerkResult.Failure) {
- debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}")
- }
- }
-
- try {
- withTimeout(5_000L) {
- Clerk.clientFlow.first { it != null }
- }
- } catch (_: TimeoutCancellationException) {
- debugLog(TAG, "configure - client did not appear after reconfigure token update")
- }
- }
-
- configuredPublishableKey = pubKey
- lastObservedClientState = clientStateSnapshot()
- promise.resolve(null)
- return@launch
- }
-
- // Already initialized — use the public SDK API to update
- // the device token and trigger a client/environment refresh.
- startClientStateObserver()
- if (normalizedBearerToken != null) {
- val clientState = clientStateSnapshot()
- val result = if (
- clientState.deviceToken != normalizedBearerToken ||
- clientState.client == null
- ) {
- Clerk.updateDeviceToken(normalizedBearerToken)
- } else {
- // A remounted JS runtime can have the same token while native
- // client state is stale, so preserve one refresh in that case.
- Clerk.refreshClient()
- }
- if (result is ClerkResult.Failure) {
- debugLog(TAG, "configure - client refresh failed: ${result.error}")
- }
-
- // Wait for client state to hydrate with the new token (up to 5s).
- try {
- withTimeout(5_000L) {
- Clerk.clientFlow.first { it != null }
- }
- } catch (_: TimeoutCancellationException) {
- debugLog(TAG, "configure - client did not appear after token update")
- }
- }
-
- lastObservedClientState = clientStateSnapshot()
- promise.resolve(null)
- } catch (e: Exception) {
- promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
- }
- }
- }
-
// MARK: - single-token client sync
private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) {
@@ -672,11 +368,9 @@ class ClerkExpoModule : Module() {
// Must follow initialize(), which resets customTheme.
loadThemeFromAssets(context)
}
- startClientStateObserver()
val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
startClientInvalidationObserver()
- lastObservedClientState = clientStateSnapshot()
if (didAdoptSeed) {
// An initialization refresh started before the seed was stored is fenced off by the token change.
launch {
@@ -715,7 +409,16 @@ class ClerkExpoModule : Module() {
}
private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint {
- return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken)
+ return ClerkClientFingerprint.from(client, currentDeviceToken())
+ }
+
+ private fun currentDeviceToken(): String? {
+ return try {
+ Clerk.getDeviceToken()
+ } catch (e: Exception) {
+ debugLog(TAG, "getDeviceToken failed: ${e.message}")
+ null
+ }
}
private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null
@@ -778,21 +481,6 @@ class ClerkExpoModule : Module() {
}
}
- // MARK: - getClientToken
-
- private fun getClientToken(promise: Promise) {
- try {
- // Use the SDK's public API which handles encrypted storage transparently.
- // Direct SharedPreferences reads break on clerk-android >= 1.0.11 where
- // DEVICE_TOKEN is encrypted via StorageCipher.
- val deviceToken = Clerk.getDeviceToken()
- promise.resolve(deviceToken)
- } catch (e: Exception) {
- debugLog(TAG, "getClientToken failed: ${e.message}")
- promise.resolve(null)
- }
- }
-
// MARK: - biometric credentials
private fun getBiometricCredentialAvailability(
@@ -1062,100 +750,6 @@ class ClerkExpoModule : Module() {
promise.reject(error.code, error.message, exception)
}
- // MARK: - syncClientStateFromJs
-
- private fun syncClientStateFromJs(
- deviceToken: String?,
- sourceId: String?,
- didChangeClient: Boolean,
- didChangeDeviceToken: Boolean,
- promise: Promise
- ) {
- if (!Clerk.isInitialized.value) {
- promise.resolve(null)
- return
- }
-
- coroutineScope.launch {
- try {
- jsOriginatedClientSyncDepth += 1
- val previousClientState = clientStateSnapshot()
- var refreshedClientWhileUpdatingToken = false
-
- if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) {
- val currentDeviceToken = try {
- Clerk.getDeviceToken()
- } catch (_: Exception) {
- null
- }
-
- if (currentDeviceToken != deviceToken) {
- when (val result = Clerk.updateDeviceToken(deviceToken)) {
- is ClerkResult.Failure -> {
- promise.reject(
- "E_SYNC_FROM_JS_FAILED",
- result.error?.firstMessage() ?: result.throwable?.message ?: "Device token sync failed",
- null
- )
- return@launch
- }
- is ClerkResult.Success -> {
- refreshedClientWhileUpdatingToken = true
- try {
- withTimeout(5_000L) {
- Clerk.clientFlow.first { it != null }
- }
- } catch (_: TimeoutCancellationException) {
- debugLog(TAG, "syncClientStateFromJs - client did not appear after token update")
- }
- }
- }
- }
- }
-
- if (!refreshedClientWhileUpdatingToken && (didChangeClient || didChangeDeviceToken)) {
- when (val result = Clerk.refreshClient()) {
- is ClerkResult.Failure -> {
- promise.reject(
- "E_SYNC_FROM_JS_FAILED",
- result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
- null
- )
- }
- is ClerkResult.Success -> {
- val newClientState = clientStateSnapshot()
- emitSyncedClientChanged(
- sourceId,
- ClientStateChanges(
- client = newClientState.client != previousClientState.client,
- deviceToken = newClientState.deviceToken != previousClientState.deviceToken
- ),
- newClientState
- )
- promise.resolve(null)
- }
- }
- return@launch
- }
-
- val newClientState = clientStateSnapshot()
- emitSyncedClientChanged(
- sourceId,
- ClientStateChanges(
- client = newClientState.client != previousClientState.client,
- deviceToken = newClientState.deviceToken != previousClientState.deviceToken
- ),
- newClientState
- )
- promise.resolve(null)
- } catch (e: Exception) {
- promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client state sync failed", e)
- } finally {
- jsOriginatedClientSyncDepth = maxOf(0, jsOriginatedClientSyncDepth - 1)
- }
- }
- }
-
// MARK: - Theme Loading
private fun loadThemeFromAssets(context: Context) {
diff --git a/packages/expo-native-components/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift
index fb84e7f59a0..411bf2c7204 100644
--- a/packages/expo-native-components/ios/ClerkExpoModule.swift
+++ b/packages/expo-native-components/ios/ClerkExpoModule.swift
@@ -9,7 +9,6 @@ import Foundation
public class ClerkExpoModule: Module {
private static let nativeAuthFlowChangedEvent = "clerkNativeAuthFlowChanged"
- private static let nativeClientChangedEvent = "clerkNativeClientChanged"
private static let nativeClientInvalidatedEvent = "clerkNativeClientInvalidated"
private static weak var sharedInstance: ClerkExpoModule?
@@ -17,16 +16,13 @@ public class ClerkExpoModule: Module {
public func definition() -> ModuleDefinition {
Name("ClerkExpo")
- Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent, Self.nativeClientInvalidatedEvent)
+ Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientInvalidatedEvent)
OnCreate {
Self.sharedInstance = self
ClerkNativeBridge.setAuthFlowChangedEmitter { body in
Self.emitAuthFlowChanged(body)
}
- ClerkNativeBridge.setClientChangedEmitter { body in
- Self.emitClientChanged(body)
- }
ClerkNativeBridge.setClientInvalidatedEmitter {
Self.emitClientInvalidated()
}
@@ -36,19 +32,10 @@ public class ClerkExpoModule: Module {
if Self.sharedInstance === self {
Self.sharedInstance = nil
ClerkNativeBridge.setAuthFlowChangedEmitter(nil)
- ClerkNativeBridge.setClientChangedEmitter(nil)
ClerkNativeBridge.setClientInvalidatedEmitter(nil)
}
}
- AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in
- self.configure(publishableKey, bearerToken: bearerToken, promise: promise)
- }
-
- AsyncFunction("getClientToken") { (promise: Promise) in
- self.getClientToken(promise: promise)
- }
-
AsyncFunction("configureNative") { (publishableKey: String, seedDeviceToken: String?, promise: Promise) in
Task { @MainActor in
do {
@@ -98,21 +85,6 @@ public class ClerkExpoModule: Module {
self.getAuthFlowState(promise: promise)
}
- AsyncFunction("syncClientStateFromJs") {
- (deviceToken: String?,
- sourceId: String?,
- didChangeClient: Bool,
- didChangeDeviceToken: Bool,
- promise: Promise) in
- self.syncClientStateFromJs(
- deviceToken,
- sourceId: sourceId,
- didChangeClient: didChangeClient,
- didChangeDeviceToken: didChangeDeviceToken,
- promise: promise
- )
- }
-
AsyncFunction("getTrustedDeviceAvailability") {
(id: String?, identifierHint: String?, promise: Promise) in
self.getBiometricCredentialAvailability(id: id, identifierHint: identifierHint, promise: promise)
@@ -172,28 +144,6 @@ public class ClerkExpoModule: Module {
}
}
- // MARK: - configure
-
- private func configure(_ publishableKey: String, bearerToken: String?, promise: Promise) {
- Task {
- do {
- try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken)
- promise.resolve()
- } catch {
- promise.reject("E_CONFIGURE_FAILED", error.localizedDescription)
- }
- }
- }
-
- // MARK: - getClientToken
-
- private func getClientToken(promise: Promise) {
- Task {
- let token = await ClerkNativeBridge.shared.getClientToken()
- promise.resolve(token)
- }
- }
-
// MARK: - getAuthFlowState
private func getAuthFlowState(promise: Promise) {
@@ -203,28 +153,6 @@ public class ClerkExpoModule: Module {
}
}
- // MARK: - syncClientStateFromJs
-
- private func syncClientStateFromJs(_ deviceToken: String?,
- sourceId: String?,
- didChangeClient: Bool,
- didChangeDeviceToken: Bool,
- promise: Promise) {
- Task {
- do {
- try await ClerkNativeBridge.shared.syncClientStateFromJs(
- deviceToken: deviceToken,
- sourceId: sourceId,
- didChangeClient: didChangeClient,
- didChangeDeviceToken: didChangeDeviceToken
- )
- promise.resolve()
- } catch {
- promise.reject("E_SYNC_FROM_JS_FAILED", error.localizedDescription)
- }
- }
- }
-
// MARK: - Biometric credentials
private func getBiometricCredentialAvailability(id: String?, identifierHint: String?, promise: Promise) {
@@ -337,20 +265,6 @@ public class ClerkExpoModule: Module {
promise.reject(descriptor.code, descriptor.message)
}
- /// Emits a native client change event to JS from anywhere in the native layer.
- /// Used by native views to ask ClerkProvider to reload JS client state.
- static func emitClientChanged(_ body: [String: Any]? = nil) {
- let eventBody = body ?? [:]
-
- guard let instance = sharedInstance else {
- return
- }
-
- DispatchQueue.main.async { [weak instance] in
- instance?.sendEvent(Self.nativeClientChangedEvent, eventBody)
- }
- }
-
static func emitClientInvalidated() {
guard let instance = sharedInstance else {
return
diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift
index 0551e609f32..5c0ccbc5d19 100644
--- a/packages/expo-native-components/ios/ClerkNativeBridge.swift
+++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift
@@ -397,7 +397,6 @@ func userProfileCustomPageLabel(
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)?
-private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
private var clerkNativeClientInvalidatedEmitter: (() -> Void)?
struct ClerkNativeErrorDescriptor {
@@ -432,8 +431,6 @@ private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware {
final class ClerkNativeBridge {
static let shared = ClerkNativeBridge()
- private static let clerkLoadMaxAttempts = 30
- private static let clerkLoadIntervalNs: UInt64 = 100_000_000
private static var clerkConfigured = false
private static var configuredPublishableKey: String?
@@ -441,12 +438,9 @@ final class ClerkNativeBridge {
var lightTheme: ClerkTheme?
var darkTheme: ClerkTheme?
- private var clientObservationGeneration = 0
- private var lastObservedClientState: ClientStateSnapshot?
private var authFlowObservationGeneration = 0
private var lastObservedAuthFlowState: AuthFlowStateSnapshot?
private var configurationDepth = 0
- private var jsOriginatedClientSyncDepth = 0
private var clientInvalidationGeneration = 0
private var clientInvalidationTracker: ClerkClientInvalidationTracker?
private var pendingURL: URL?
@@ -454,23 +448,11 @@ final class ClerkNativeBridge {
private init() {}
- private struct ClientStateSnapshot: Equatable {
- let client: Client?
- let deviceToken: String?
- }
-
private struct AuthFlowStateSnapshot: Equatable {
let isLoaded: Bool
let isAuthFlowComplete: Bool
}
- private struct ClientStateChanges {
- let client: Bool
- let deviceToken: Bool
-
- static let all = ClientStateChanges(client: true, deviceToken: true)
- }
-
/// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
/// (for extension apps sharing a keychain group), then falling back to the bundle identifier.
private static var keychainService: String? {
@@ -482,7 +464,6 @@ final class ClerkNativeBridge {
@MainActor
private func endConfiguration() {
- lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
let authFlowState = Self.authFlowStateSnapshot()
lastObservedAuthFlowState = authFlowState
configurationDepth = max(0, configurationDepth - 1)
@@ -496,55 +477,6 @@ final class ClerkNativeBridge {
}
}
- @MainActor
- func configure(publishableKey: String, bearerToken: String? = nil) async throws {
- configurationDepth += 1
- defer { endConfiguration() }
-
- loadThemes()
-
- if Self.shouldReconfigure(for: publishableKey) {
- try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
- Self.clerkConfigured = true
- Self.configuredPublishableKey = publishableKey
- startClientObserver(reset: true)
- startAuthFlowObserver(reset: true)
-
- let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
- await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
- Self.postConfiguredNotification()
- shouldFlushPendingURL = true
- return
- }
-
- if Self.clerkConfigured {
- startClientObserver()
- startAuthFlowObserver()
- let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken)
- if didUpdateDeviceToken {
- await Self.waitForLoadedClient()
- } else if let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
- // A remounted JS runtime can have the same token while native client
- // state is stale, so preserve one refresh in that case.
- _ = try await Clerk.shared.refreshClient()
- await Self.waitForLoadedClient()
- }
- shouldFlushPendingURL = true
- return
- }
-
- Self.clerkConfigured = true
- Self.configuredPublishableKey = publishableKey
- Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
- startClientObserver()
- startAuthFlowObserver()
-
- let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
- await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
- Self.postConfiguredNotification()
- shouldFlushPendingURL = true
- }
-
/// Configures ClerkKit without waiting for the client to load. ClerkKit's stored device token
/// wins; `seedDeviceToken` is only adopted when ClerkKit has none.
@MainActor
@@ -558,17 +490,14 @@ final class ClerkNativeBridge {
if Self.shouldReconfigure(for: publishableKey) {
try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
Self.configuredPublishableKey = publishableKey
- startClientObserver(reset: true)
startAuthFlowObserver(reset: true)
} else if Self.clerkConfigured {
didConfigure = false
- startClientObserver()
startAuthFlowObserver()
} else {
Self.clerkConfigured = true
Self.configuredPublishableKey = publishableKey
Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
- startClientObserver()
startAuthFlowObserver()
}
@@ -669,7 +598,7 @@ final class ClerkNativeBridge {
/// `AuthView` only reaches `Clerk.handle(_:)` from `.onOpenURL`, which never fires for a UIKit-hosted controller.
@MainActor
func handle(url: URL) {
- // A cold launch delivers the callback before, or partway through, JS calling `configure`.
+ // A cold launch delivers the callback before, or partway through, JS calling `configureNative`.
guard Self.clerkConfigured, configurationDepth == 0 else {
pendingURL = url
return
@@ -684,47 +613,6 @@ final class ClerkNativeBridge {
}
}
- @MainActor
- private func startClientObserver(reset: Bool = false) {
- guard reset || clientObservationGeneration == 0 else {
- return
- }
-
- clientObservationGeneration += 1
- let generation = clientObservationGeneration
- lastObservedClientState = Self.clientStateSnapshot()
- observeClient(generation: generation)
- }
-
- @MainActor
- private func observeClient(generation: Int) {
- withObservationTracking {
- _ = Self.clientStateSnapshot()
- } onChange: { [weak self] in
- Task { @MainActor [weak self] in
- await Task.yield()
-
- guard let self, generation == self.clientObservationGeneration else { return }
-
- let newClientState = Self.clientStateSnapshot()
- if let previousClientState = self.lastObservedClientState, newClientState != previousClientState {
- self.lastObservedClientState = newClientState
- if self.configurationDepth == 0, self.jsOriginatedClientSyncDepth == 0 {
- let payload = Self.clientChangedPayload(
- changes: .init(
- client: newClientState.client != previousClientState.client,
- deviceToken: newClientState.deviceToken != previousClientState.deviceToken
- )
- )
- Self.emitClientChanged(payload)
- }
- }
-
- self.observeClient(generation: generation)
- }
- }
- }
-
@MainActor
private func startAuthFlowObserver(reset: Bool = false) {
guard reset || authFlowObservationGeneration == 0 else {
@@ -779,46 +667,6 @@ final class ClerkNativeBridge {
]
}
- @MainActor
- private static func clientStateSnapshot() -> ClientStateSnapshot {
- let client = Clerk.shared.client
-
- return ClientStateSnapshot(
- client: client,
- deviceToken: Clerk.shared.deviceToken
- )
- }
-
- @MainActor
- private static func clientChangedPayload(sourceId: String? = nil, changes: ClientStateChanges = .all) -> [String: Any] {
- var payload: [String: Any] = [:]
- payload["changed"] = [
- "client": changes.client,
- "deviceToken": changes.deviceToken,
- ]
- payload["deviceToken"] = Clerk.shared.deviceToken ?? NSNull()
- if let sourceId, !sourceId.isEmpty {
- payload["sourceId"] = sourceId
- }
-
- return payload
- }
-
- @MainActor
- private static func syncTokenState(bearerToken: String?) async throws -> Bool {
- await waitForLoadedClient()
-
- guard let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
- else {
- return false
- }
- guard Clerk.shared.deviceToken != token || Clerk.shared.client == nil else {
- return false
- }
- _ = try await Clerk.shared.updateDeviceToken(token)
- return true
- }
-
private static func shouldReconfigure(for publishableKey: String) -> Bool {
guard clerkConfigured, let configuredPublishableKey else { return false }
return configuredPublishableKey != publishableKey
@@ -832,30 +680,6 @@ final class ClerkNativeBridge {
return .init(keychainConfig: .init(service: service), middleware: middleware)
}
- @MainActor
- private static func waitForLoadedClient() async {
- // Wait for Clerk to finish loading client state from cached data + API refresh.
- // The bridge sync contract is device-token based, not session based.
- for _ in 0.. String? {
- guard Self.clerkConfigured else { return nil }
- return Clerk.shared.deviceToken
- }
-
@MainActor
func getAuthFlowState() -> [String: Any] {
Self.authFlowStatePayload(Self.authFlowStateSnapshot())
@@ -1196,76 +1020,10 @@ final class ClerkNativeBridge {
)
}
- @MainActor
- func syncClientStateFromJs(
- deviceToken: String?,
- sourceId: String?,
- didChangeClient: Bool,
- didChangeDeviceToken: Bool
- ) async throws {
- guard Self.clerkConfigured else { return }
-
- let previousClientState = Self.clientStateSnapshot()
- var completedSuccessfully = false
- jsOriginatedClientSyncDepth += 1
- defer {
- let finalClientState = Self.clientStateSnapshot()
- lastObservedClientState = finalClientState
- jsOriginatedClientSyncDepth = max(0, jsOriginatedClientSyncDepth - 1)
-
- if !completedSuccessfully, finalClientState != previousClientState {
- Self.emitClientChanged(
- Self.clientChangedPayload(
- changes: .init(
- client: finalClientState.client != previousClientState.client,
- deviceToken: finalClientState.deviceToken != previousClientState.deviceToken
- )
- )
- )
- }
- }
-
- var refreshedClientWhileUpdatingToken = false
-
- if didChangeDeviceToken,
- let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
- {
- if Clerk.shared.deviceToken != token {
- _ = try await Clerk.shared.updateDeviceToken(token)
- await Self.waitForLoadedClient()
- refreshedClientWhileUpdatingToken = true
- }
- }
-
- if !refreshedClientWhileUpdatingToken, didChangeClient || didChangeDeviceToken {
- _ = try await Clerk.shared.refreshClient()
- await Self.waitForLoadedClient()
- }
-
- let newClientState = Self.clientStateSnapshot()
- lastObservedClientState = newClientState
- Self.emitClientChanged(
- Self.clientChangedPayload(
- sourceId: sourceId,
- changes: .init(
- client: newClientState.client != previousClientState.client,
- deviceToken: newClientState.deviceToken != previousClientState.deviceToken
- )
- )
- )
- completedSuccessfully = true
- }
-
private static func postConfiguredNotification() {
NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
}
- static func setClientChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
- clerkNativeClientEventQueue.sync {
- clerkNativeClientChangedEmitter = emitter
- }
- }
-
static func setClientInvalidatedEmitter(_ emitter: (() -> Void)?) {
clerkNativeClientEventQueue.sync {
clerkNativeClientInvalidatedEmitter = emitter
@@ -1292,14 +1050,6 @@ final class ClerkNativeBridge {
emitter?(body)
}
- /// Requests that ClerkProvider reload the JS client from native client state.
- static func emitClientChanged(_ body: [String: Any]? = nil) {
- let emitter = clerkNativeClientEventQueue.sync {
- clerkNativeClientChangedEmitter
- }
- emitter?(body)
- }
-
private static func authMode(from mode: String) -> AuthView.Mode {
switch mode {
case "signIn":
diff --git a/packages/expo/src/specs/__tests__/androidSpecs.test.ts b/packages/expo/src/specs/__tests__/androidSpecs.test.ts
index bbebc8f11c9..6ec0234bdbf 100644
--- a/packages/expo/src/specs/__tests__/androidSpecs.test.ts
+++ b/packages/expo/src/specs/__tests__/androidSpecs.test.ts
@@ -39,7 +39,7 @@ describe('android native module specs', () => {
});
test('resolve the module registered under the expected name (development build)', async () => {
- const clerkExpoModule = { configure: vi.fn() };
+ const clerkExpoModule = { configureNative: vi.fn() };
const googleSignInModule = { signIn: vi.fn() };
mocks.available = { ClerkExpo: clerkExpoModule, ClerkGoogleSignIn: googleSignInModule };
From f9b9404c714ac93cdaf3f373e76551832ed65c4c Mon Sep 17 00:00:00 2001
From: Mike Pitre <12040919+mikepitre@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:11:21 -0400
Subject: [PATCH 15/15] feat(clerk-js,shared): add experimental trusted device
resources
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../experimental-trusted-device-resources.md | 12 ++
packages/clerk-js/bundlewatch.config.json | 2 +-
.../clerk-js/src/core/resources/AuthConfig.ts | 39 ++++-
.../src/core/resources/BiometricCredential.ts | 117 ++++++++++++++
.../core/resources/TrustedDeviceChallenge.ts | 35 ++++
packages/clerk-js/src/core/resources/User.ts | 25 +++
.../src/core/resources/Verification.ts | 7 +
.../resources/__tests__/AuthConfig.test.ts | 38 +++++
.../__tests__/BiometricCredential.test.ts | 153 ++++++++++++++++++
.../core/resources/__tests__/SignIn.test.ts | 85 ++++++++++
.../resources/__tests__/Verification.test.ts | 75 +++++++++
.../clerk-js/src/core/resources/internal.ts | 1 +
packages/shared/src/types/authConfig.ts | 15 ++
.../shared/src/types/biometricCredential.ts | 98 +++++++++++
packages/shared/src/types/factors.ts | 28 ++++
packages/shared/src/types/index.ts | 1 +
packages/shared/src/types/json.ts | 57 +++++++
packages/shared/src/types/signInCommon.ts | 15 +-
packages/shared/src/types/snapshots.ts | 3 +
packages/shared/src/types/strategies.ts | 5 +
packages/shared/src/types/user.ts | 30 ++++
packages/shared/src/types/verification.ts | 6 +
22 files changed, 843 insertions(+), 4 deletions(-)
create mode 100644 .changeset/experimental-trusted-device-resources.md
create mode 100644 packages/clerk-js/src/core/resources/BiometricCredential.ts
create mode 100644 packages/clerk-js/src/core/resources/TrustedDeviceChallenge.ts
create mode 100644 packages/clerk-js/src/core/resources/__tests__/BiometricCredential.test.ts
create mode 100644 packages/clerk-js/src/core/resources/__tests__/Verification.test.ts
create mode 100644 packages/shared/src/types/biometricCredential.ts
diff --git a/.changeset/experimental-trusted-device-resources.md b/.changeset/experimental-trusted-device-resources.md
new file mode 100644
index 00000000000..67de7eb89a4
--- /dev/null
+++ b/.changeset/experimental-trusted-device-resources.md
@@ -0,0 +1,12 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/shared': minor
+---
+
+Add experimental support for trusted device (biometric credential) resources in native apps:
+
+- `signIn.create({ strategy: 'trusted_device', trustedDeviceId })` and `signIn.attemptFirstFactor({ strategy: 'trusted_device', trustedDeviceId, clientData, signature, algorithm: 'ES256' })` are now typed and supported. The challenge to sign is exposed on `signIn.firstFactorVerification.trustedDeviceChallenge`.
+- `User` gains `__experimental_getBiometricCredentials()`, `__experimental_prepareBiometricCredential()`, `__experimental_attemptBiometricCredential()` and `__experimental_revokeBiometricCredential()` for managing a user's enrolled biometric credentials.
+- The environment's auth config now exposes `nativeSettings`, indicating whether trusted device sign-in and enrollment prompts are enabled.
+
+These APIs are experimental and may change in future minor releases.
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index a421c0f931c..1bbefe91488 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "81KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
- { "path": "./dist/clerk.native.js", "maxSize": "80KB" },
+ { "path": "./dist/clerk.native.js", "maxSize": "82KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts
index 3bfc61dbacf..c190ddacd77 100644
--- a/packages/clerk-js/src/core/resources/AuthConfig.ts
+++ b/packages/clerk-js/src/core/resources/AuthConfig.ts
@@ -1,4 +1,11 @@
-import type { AuthConfigJSON, AuthConfigJSONSnapshot, AuthConfigResource, PhoneCodeChannel } from '@clerk/shared/types';
+import type {
+ AuthConfigJSON,
+ AuthConfigJSONSnapshot,
+ AuthConfigResource,
+ NativeSettingsJSON,
+ NativeSettingsResource,
+ PhoneCodeChannel,
+} from '@clerk/shared/types';
import { unixEpochToDate } from '../../utils/date';
import { BaseResource } from './internal';
@@ -9,6 +16,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource {
singleSessionMode: boolean = false;
preferredChannels: Record