-
Notifications
You must be signed in to change notification settings - Fork 436
Chris/mobile 343 bridge android to a native module that is available in the #7816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Chris/mobile 343 bridge android to a native module that is available in the #7816
Conversation
- Implemented UserButton component to open UserProfileView on press. - Created UserProfile component for comprehensive profile management. - Integrated native ClerkExpo module for iOS functionality. - Updated ClerkProvider to configure Clerk iOS SDK. - Added exports for new components in the native index file. - Adjusted TypeScript configuration to include additional files. - Modified build process to temporarily skip declaration generation. - Updated dependencies in pnpm-lock.yaml for compatibility.
…d ClerkViewFactory
…ge-ios-to-a-native-module-that-is-available-in-the-expo
…profile management
…ge-android-to-a-native-module-that-is-available-in-the
…ling and UI presentation - Consolidated Clerk SDK initialization and session management in ClerkExpoModule. - Removed ClerkProfileActivity and replaced it with ClerkUserProfileActivity for better clarity and functionality. - Introduced ClerkViewFactory to manage creation of intents for authentication and user profile activities. - Enhanced error handling and promise management for asynchronous operations. - Updated SignIn and UserProfile components to synchronize native and JS session states effectively. - Improved user experience by ensuring the auth modal is always presented, allowing native UI to manage signed-in state. - Added backward-compatible wrappers for SignedIn and SignedOut components.
…and improved session handling
- Updated `clerk-android` versions in `build.gradle` to `0.1.30` for API and `0.1.4` for UI. - Added Kotlin metadata version check skip to address compatibility issues. - Introduced packaging exclusions for duplicate META-INF files in Android. - Enhanced `ClerkAuthActivity` to improve session handling and logging. - Updated `ClerkExpoModule` to include detailed logging for session retrieval. - Improved `ClerkUserProfileActivity` to handle sign-out detection and logging. - Refined `SignIn` and `UserProfile` components to prevent duplicate auth callbacks and improve user state management. - Added packaging exclusions in the Expo config plugin for Android to resolve dependency conflicts.
- Introduced AuthView component to handle sign-in and sign-up using native UI. - Added AuthView types for better type safety. - Removed deprecated SignIn component and its types. - Updated UserButton and UserProfileView components with enhanced documentation. - Refactored ClerkProvider to sync native sessions with JS SDK. - Adjusted TypeScript configurations for improved declaration generation.
…lement crash in React Native
… state management
🦋 Changeset detectedLatest commit: 21945eb The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
!snapshot |
|
Hey @chriscanin - the snapshot version command generated the following package versions:
Tip: Use the snippet copy button below to quickly install the required packages. npm i @clerk/agent-toolkit@0.2.9-snapshot.v20260210225422 --save-exact
npm i @clerk/astro@3.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/backend@3.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/chrome-extension@3.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/clerk-js@6.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/dev-cli@1.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/expo@3.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/expo-passkeys@1.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/express@2.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/fastify@2.6.9-snapshot.v20260210225422 --save-exact
npm i @clerk/localizations@4.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/msw@0.0.1-snapshot.v20260210225422 --save-exact
npm i @clerk/nextjs@7.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/nuxt@2.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/react@6.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/react-router@3.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/shared@4.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/tanstack-react-start@1.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/testing@2.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/ui@1.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/upgrade@2.0.0-snapshot.v20260210225422 --save-exact
npm i @clerk/vue@2.0.0-snapshot.v20260210225422 --save-exact |
📝 WalkthroughWalkthroughThis pull request implements native Clerk integration for Expo applications on iOS and Android platforms. It adds native Android activities and Kotlin modules for authentication and user profile management, iOS Swift modules and view factories for the same flows, and Expo plugin configuration to wire both platforms during prebuild. New React Native components bridge to the native implementations. The Clerk SDK initialization is updated to support headless mode when a Clerk instance is provided. Documentation is added for the new native component exports, and TypeScript configurations are updated to reflect the expanded module structure. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
🤖 Fix all issues with AI agents
In `@packages/expo/android/build.gradle`:
- Around line 50-56: Update the clerk Android UI dependency from 0.1.4 to 0.1.5
in the Gradle dependency declaration (replace the artifact version referencing
clerk-android-ui) and remove the temporary Kotlin compiler bypass by deleting
the "-Xskip-metadata-version-check" entry from the
kotlinOptions.freeCompilerArgs block (the block that contains jvmTarget = "17"
and freeCompilerArgs). Ensure the project no longer relies on the workaround so
the kotlinOptions section only sets the desired jvmTarget and the dependency now
points to clerk-android-ui:0.1.5.
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt`:
- Around line 368-375: Remove the deprecated onBackPressed override from
ClerkAuthActivity (the override function named onBackPressed currently checking
EXTRA_DISMISSABLE and calling setResult/ super.onBackPressed); rely on the
existing Compose BackHandler (lines ~297-305 in this file) to handle back
presses instead, and scan ClerkAuthActivity for any callers that relied on the
onBackPressed override to ensure they use the Compose handler or explicit
finish/setResult logic instead.
- Around line 219-234: The polling loop and the LaunchedEffect(session) path can
race and both call setResult/finish because they check and set the Boolean
isAuthComplete non-atomically; replace that guard with an atomic check-or-set
(e.g., use an AtomicBoolean similar to the dismissed flag in
ClerkUserProfileActivity) or consolidate session-complete logic into a single
handler so both paths perform a single atomic compareAndSet on a shared
AtomicBoolean (referencing isAuthComplete, the LaunchedEffect(session) block,
the polling loop that reads Clerk.session, and the setResult/finish calls) to
ensure only one path proceeds to call setResult and finish().
- Around line 96-102: ClerkAuthActivity is logging PII (emails, phone numbers,
names, MFA details and raw IDs) via Log.d of TAG in places that include
client.signIn, client.signUp, signUp.emailAddress, supportedSecondFactors
entries, sessions and periodic polling logic; remove or redact PII and gate
verbose debug logs behind a debug flag (e.g., BuildConfig.DEBUG) or a runtime
debug toggle before shipping, replace direct PII fields with redacted
placeholders or hashed IDs, stop logging full supportedSecondFactors objects
(log only non-PII enums/counts), and disable the 2-second periodic full-client
dump (or make it conditional on debug mode) so only safe, non-identifying
diagnostics are emitted from ClerkAuthActivity.
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt`:
- Around line 160-213: The getSession AsyncFunction is currently logging PII
(user.firstName, user.lastName, user.imageUrl, user.emailAddresses) via
Log.d(TAG, ...) which is a privacy/compliance risk; remove those Log.d calls or
guard them behind a strict debug check (e.g., BuildConfig.DEBUG or a
module-level debug flag) inside AsyncFunction("getSession") so PII is never
logged in production, and if you need observability log only non-PII indicators
(e.g., "user present" or hashed/anonymized identifiers) using the existing TAG
and Clerk.session/Clerk.user symbols.
- Around line 67-69: The static promise fields pendingAuthPromise and
pendingProfilePromise can be silently overwritten by a second call to
presentAuth/presentProfile, and configure() awaits Clerk.isInitialized.first {
it } indefinitely if initialization never becomes true; before assigning a new
Promise in presentAuth/presentProfile reject the existing
pendingAuthPromise/pendingProfilePromise with an appropriate error and clear it
to avoid leaking unresolved JS promises, and in configure() replace the
unbounded first { it } wait with a bounded wait (use
kotlinx.coroutines.withTimeout) or concurrently observe
Clerk.initializationError and reject the configure promise if an error is
emitted so configure() always resolves or rejects; reference the symbols
pendingAuthPromise, pendingProfilePromise, presentAuth, presentProfile,
configure, Clerk.isInitialized, Clerk.initializationError and add the
withTimeout import.
In `@packages/expo/app.plugin.js`:
- Around line 277-316: The lookup paths in the possiblePaths array are using the
wrong package directory name; update the two paths that reference
'@clerk/clerk-expo' to use the correct '@clerk/expo' so the search for
ClerkViewFactory.swift (constructed from config.modRequest.projectRoot) will
succeed for normal npm/yarn and pnpm-hoisted installs; keep the rest of the
possiblePaths entries and the existing fs.existsSync check and sourceFile
assignment unchanged.
In `@packages/expo/ios/ClerkExpoModule.swift`:
- Around line 44-62: The continuation can never be resumed if
UIApplication.shared.keyWindow?.rootViewController is nil; update the code paths
in the auth presentation routine (the block that calls
factory.createAuthViewController(...) and the similar presentUserProfile
function) to handle a missing root view controller by immediately resuming the
checked continuation with a descriptive error (e.g., NSError with domain
"ClerkExpo" and an appropriate code/message) before returning, and ensure both
the success/failure from the completion closure and the "cannot present" error
are the only places that resume the continuation to avoid leaks or
double-resumes.
In `@packages/expo/ios/ClerkViewFactory.swift`:
- Around line 158-180: The subscribeToAuthEvents Task (authEventTask) can exit
the for-await loop or encounter events with nil createdSessionId without calling
the completion closure, leaking the continuation in ClerkExpoModule.swift;
update subscribeToAuthEvents in ClerkViewFactory.swift to ensure completion is
always invoked by (1) calling completion(.failure(...)) with a descriptive error
when the async sequence terminates naturally (after the for await loop finishes)
or when the Task is cancelled/deinit, and (2) treating nil createdSessionId
paths by invoking completion(.failure(...)) instead of silently skipping; apply
the same pattern to ClerkProfileWrapperViewController (the signedOut handler)
and ensure any place that relies on withCheckedThrowingContinuation is covered
so the continuation is always resumed exactly once.
In `@packages/expo/src/native/AuthView.tsx`:
- Line 281: The effect that mounts and calls presentModal in AuthView is
depending on onSuccess and onError which can be re-created each render; update
by removing onSuccess and onError from the useEffect dependency array and
instead store their latest values in refs (e.g., successRef.current and
errorRef.current), update those refs whenever props change, and have
presentModal read and invoke successRef.current / errorRef.current so the effect
won't re-run on every render but will still call the latest callbacks; keep
hasStartedRef logic as-is to prevent re-presentation.
- Around line 249-273: The current fragile string-match in AuthView (if
(error.message?.includes('already signed in'))) should be replaced with a robust
detection helper (e.g., isAlreadySignedInError) that first checks for structured
properties (error.code, error.name, error.type) returned by the native bridge
and falls back to multiple tolerant message patterns only if structured fields
are absent; update the recovery block that calls ClerkExpo.getSession and
clerk.setActive to use this helper, wrap the entire fallback in a try/catch with
explicit processLogger/console.error messages including the original error and
recovery attempt context, and reference symbols: AuthView,
isAlreadySignedInError (new), ClerkExpo.getSession, clerk.setActive,
authCompletedRef, and onSuccess.
In `@packages/expo/src/native/README.md`:
- Around line 144-153: Update the README usage example to import from the actual
package and use the correct component names: change the import path from
"@clerk/clerk-expo/native" to "@clerk/expo" and replace the SignIn and
UserProfile usages with the actual exported names AuthView and UserProfileView
(keep UserButton if still exported); update the JSX example to render <AuthView
/> (and <UserProfileView /> where shown) so symbols match the package exports.
In `@packages/expo/src/native/UserProfileView.tsx`:
- Around line 216-228: The code accesses an undocumented internal API
(__internal_reloadInitialResources) on the clerk object after signOut, which is
fragile; remove the cast and the await
clerkAny.__internal_reloadInitialResources() block from UserProfileView (and the
surrounding console logs) and instead: rely solely on the public clerk.signOut()
call for sign-out recovery, keep the existing try/catch around signOut to log
errors, and add a TODO comment referencing coordinating with the `@clerk/react`
SDK team to expose a supported recovery/reload method or to implement an
officially supported workaround if needed.
- Around line 170-244: The effect re-opens the native modal because isSignedIn
and possibly onSignOut are in the dependency array and signOutTriggered.current
is reset at the start of every run; fix by removing isSignedIn and onSignOut
from the useEffect deps and instead capture their latest values via refs (create
isSignedInRef.current = isSignedIn and onSignOutRef.current = onSignOut and use
those inside presentModal), only run the effect when isDismissable changes (use
[isDismissable] as the dependency array), and stop resetting
signOutTriggered.current at the top of this effect (reset it once on mount using
a separate useEffect(()=>{ signOutTriggered.current = false }, []) so the
sign-out guard persists across the sign-out transition).
In `@packages/expo/src/provider/ClerkProvider.tsx`:
- Around line 109-121: The code relies on undocumented internals by casting
clerkInstance to any and using clerkAny.loaded, clerkAny.addOnLoaded and
clerkAny.__internal_reloadInitialResources; update waitForLoad and the place
where setActive is called (reference: clerkInstance, waitForLoad, setActive,
clerkAny.loaded, clerkAny.addOnLoaded, __internal_reloadInitialResources) to
perform robust runtime guards: after waitForLoad resolves, explicitly verify the
client is ready using safe runtime checks (e.g. check for a documented public
readiness flag or, if absent, confirm clerkAny.loaded === true before calling
setActive), and if the ready check fails, avoid calling setActive and handle
fallback (retry, no-op, or surface an error) so the code won't call internals or
setActive prematurely when these internal properties are missing or changed.
- Around line 77-162: The async configureNativeClerk() starts fire-and-forget
polling and may call clerkInstance.setActive after unmount; add an
AbortController or isMountedRef to cancel work: create e.g. const abortCtrl =
new AbortController() / const mountedRef = useRef(true) and update it in the
effect cleanup, check abortCtrl.signal.aborted or !mountedRef.current inside the
polling loop and before awaiting waitForLoad and before calling setActive to
bail out early, clear any pending setTimeout waits, and if you attach a listener
via clerkAny.addOnLoaded, store and remove that listener in the cleanup so it
cannot fire after unmount; return a cleanup from the useEffect that aborts the
controller (or sets mountedRef false) and resets initStartedRef.current if
appropriate. Ensure references named in the diff (configureNativeClerk,
clerkInstance, clerkAny, pendingNativeSessionRef, sessionSyncedRef,
initStartedRef) are checked for the abort flag before mutating state or calling
setActive.
In `@packages/react/src/isomorphicClerk.ts`:
- Around line 308-316: The premount listener invocation is using an unsafe "as
any" cast on currentState; replace that with a proper Partial<State> (or
construct a full State with safe defaults) so ListenerCallback consumers get
correct typing. Specifically, change the value passed to
this.premountAddListenerCalls.forEach(listener => listener(...)) to be typed as
Partial<State> instead of any (or build a complete State by merging
clerk.client/session/user/organization with default no-op signIn/signUp/etc.),
and update usages around premountAddListenerCalls, currentState, and
ListenerCallback accordingly to remove the any cast while preserving runtime
behavior.
- Around line 323-331: The current catch on clerk.load silently swallows errors
and still calls finishInit(), causing `#initialized` to become true and emitting
clerkEvents.Status 'ready' even on failure; update the catch handler in the
isomorphicClerk initialization to log the error (include the caught error
object), set a failure/degraded status via clerkEvents.Status (e.g., 'error' or
'degraded') instead of 'ready', and ensure any state set by finishInit (such as
marking `#initialized` or assigning the clerk instance) reflects the failed load
so consumers can distinguish success from failure; modify the promise chain
around clerk.load(this.options) and the finishInit() logic to branch on success
vs failure using the unique symbols clerk.load, finishInit, and
clerkEvents.Status.
| kotlinOptions { | ||
| jvmTarget = "17" | ||
| // Skip Kotlin metadata version check to work around clerk-android being compiled with Kotlin 2.3.0 | ||
| // while Expo uses Kotlin 2.1.20. This is a temporary workaround until clerk-android publishes | ||
| // artifacts compatible with Kotlin 2.1.x | ||
| freeCompilerArgs += ["-Xskip-metadata-version-check"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
clerk-android SDK Kotlin 2.1 2.3 version compatibility
💡 Result:
Clerk’s Android SDK is sensitive to your project’s Kotlin compiler version (because the SDK is published as Kotlin-compiled artifacts).
- If your app is on Kotlin 2.1.x: use
com.clerk:clerk-android-api:0.1.31(andclerk-android-ui:0.1.5), released Jan 29, 2026, which explicitly downgrades Kotlin/deps to work on Kotlin 2.1.20. [1] - If your app is on Kotlin 2.3.x: use a newer Clerk Android SDK release after 0.1.31, because the repo updates the Kotlin toolchain to Kotlin 2.3.0 and 2.3.10 in later releases (so those artifacts are intended to be built/consumed with Kotlin 2.3). [2]
Also note the baseline project requirements in Clerk’s Android quickstart: minSdk 24+ and Java 17+. [3]
Sources
[1] Clerk Android SDK release v0.1.31 (notes include Kotlin 2.1.20 compatibility + module versions) https://github.com/clerk/clerk-android/releases
[2] Clerk Android SDK latest release notes showing Kotlin updated to 2.3.0 / 2.3.10 https://github.com/clerk/clerk-android/releases
[3] Clerk Android Quickstart (minSdk 24+, Java 17+) https://clerk.com/docs/android/getting-started/quickstart
🌐 Web query:
Kotlin 2.1.20 vs 2.3.0 binary compatibility metadata version check
💡 Result:
Kotlin/JVM libraries carry Kotlin metadata (in the @Metadata annotation and related structures). Tooling (the Kotlin compiler itself, KSP/kapt processors, analyzers like Dagger/Hilt/Room, etc.) must be able to read that metadata to typecheck and do symbol processing.
Why Kotlin 2.1.20 vs 2.3.0 can fail
- For JVM classfiles, the metadata version tracks the Kotlin language version (since Kotlin 1.4). So code compiled with Kotlin 2.3.0 typically has metadata version 2.3.0, while Kotlin 2.1.20 produces/“expects” 2.1.x-era metadata. [1]
- An older compiler/tool generally reads older metadata fine, but may only read a limited range “forward” (often ~one minor), and will error once the metadata is too new (the classic “compiled with an incompatible version of Kotlin / metadata is X, expected Y” failure mode). [2]
- Similarly, libraries that parse metadata via
kotlin-metadata-jvmcan fail fast:readStrict()is intentionally limited and supports only up to about one version beyond what yourkotlin-metadata-jvmversion considers “latest stable supported”; otherwise it throws to avoid mis-parsing unknown formats. [3]
What to do (practical fixes)
-
Best fix: align versions
Compile/consume with Kotlin 2.3.x if you depend on libraries compiled with 2.3.0 metadata (or downgrade those libraries to a Kotlin 2.1/2.2-compatible build). -
If the failure is from an annotation processor / analyzer (Dagger/Hilt/Room/etc.)
Update the tool so it uses a newer metadata parser. This is exactly the kind of issue reported when a tool supports up to metadata 2.2.0 but encounters 2.3.0. [4] -
If you directly use
kotlin-metadata-jvm/kotlinx-metadata-jvm
Upgrade to a version new enough to understand Kotlin 2.3 metadata (for exampleorg.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0). [5]
Rule of thumb: Kotlin 2.1.20 tooling reading Kotlin 2.3.0-produced metadata is not guaranteed to work; upgrade the compiler/tooling or downgrade the producer.
Sources
[1] https://kotlinlang.org/api/kotlinx-metadata-jvm/kotlin-metadata-jvm/kotlin.metadata.jvm/-jvm-metadata-version/
[2] https://stackoverflow.com/questions/79176331/what-caused-module-was-compiled-with-an-incompatible-version-of-kotlin-error
[3] https://kotlinlang.org/docs/metadata-jvm.html
[4] google/dagger#5001
[5] https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-metadata-jvm/2.3.0
🌐 Web query:
-Xskip-metadata-version-check Kotlin compiler flag when necessary
💡 Result:
-Xskip-metadata-version-check tells the Kotlin compiler to ignore Kotlin @Metadata version incompatibility when it loads classes compiled by a different/newer Kotlin compiler (i.e., “compiled with an incompatible version of Kotlin / metadata version … expected …”). This bypasses a safety check around Kotlin’s binary metadata format/versioning. [1][2]
Use it only when it’s truly necessary (temporary workaround)
Use -Xskip-metadata-version-check only as a short-term unblocker when you can’t immediately align versions, for example:
- You must consume a dependency built with a newer/pre-release Kotlin compiler than your project’s compiler (common during EAP / fast-moving multiplatform or library publishing). Kotlin team members explicitly suggested this as an “easiest solution” workaround in such cases. [1]
- You’re stuck with tooling that reads Kotlin metadata (annotation processors / code generators) and you hit “unsupported metadata version” errors due to version skew (e.g., older processors vs newer Kotlin). Dagger/Hilt issues show this exact failure mode (“Unsupported metadata version…”). [3][4]
Prefer fixing the root cause instead
Before using the flag, first try (in order):
- Upgrade your Kotlin Gradle plugin / compiler to match (or exceed) the Kotlin version used to compile the problematic dependency. (This is the intended fix for “incompatible metadata version”.)
- Upgrade the tool that reads metadata (e.g., Hilt/Dagger/other processors) so it supports your Kotlin version (these failures are widely reported as version-support gaps). [3][4]
- If you can’t upgrade, downgrade the dependency to one compiled with an older Kotlin version compatible with your project.
How to add it (Gradle)
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.add("-Xskip-metadata-version-check")
}Risk
Skipping the check can let the compiler (or metadata-reading tools) proceed with unknown/newer metadata formats, which is exactly what the check is designed to prevent. Kotlin’s metadata versioning exists because format changes can break readers. [2]
Rule of thumb: enable it only to unblock builds briefly, and remove it once you’ve aligned Kotlin + dependencies/tooling.
Sources
[1] Kotlin Discussions (JetBrains): workaround suggestion to pass -Xskip-metadata-version-check when a library is built with an earlier/different compiler pre-release. (discuss.kotlinlang.org)
[2] Kotlin kotlinx-metadata-jvm docs: metadata versioning and compatibility expectations. (kotlinlang.org)
[3] Google Dagger issue: “Hilt incompatible… Unsupported metadata version…” (example of metadata reader mismatch). (github.com)
[4] Google Dagger issue: “Unsupported metadata version…” tied to old kotlinx-metadata-jvm support vs newer Kotlin. (github.com)
Citations:
- 1: https://discuss.kotlinlang.org/t/kotlin-1-3-m2-new-multiplatform-projects-model/9264/29?utm_source=openai
- 2: https://kotlinlang.org/api/kotlinx-metadata-jvm/kotlin-metadata-jvm/kotlin.metadata.jvm/-jvm-metadata-version/?utm_source=openai
- 3: Hilt incompatible with Kotlin to 1.9 google/dagger#4035
- 4: Unsupported metadata version when using Hilt 2.42 with Kotlin 1.7.10 google/dagger#3470
Update clerk-android-ui to version 0.1.5 to eliminate the metadata version workaround.
The Clerk Android SDK officially provides clerk-android-ui:0.1.5 (released Jan 29, 2026) built with Kotlin 2.1.20 for compatibility with your project's Kotlin version. Replace version 0.1.4 with 0.1.5 and remove the -Xskip-metadata-version-check flag. Using an outdated dependency version with a compiler safety bypass is fragile and masks incompatibilities that should be resolved at the dependency level.
🤖 Prompt for AI Agents
In `@packages/expo/android/build.gradle` around lines 50 - 56, Update the clerk
Android UI dependency from 0.1.4 to 0.1.5 in the Gradle dependency declaration
(replace the artifact version referencing clerk-android-ui) and remove the
temporary Kotlin compiler bypass by deleting the "-Xskip-metadata-version-check"
entry from the kotlinOptions.freeCompilerArgs block (the block that contains
jvmTarget = "17" and freeCompilerArgs). Ensure the project no longer relies on
the workaround so the kotlinOptions section only sets the desired jvmTarget and
the dependency now points to clerk-android-ui:0.1.5.
| Log.d(TAG, "Waiting for client - attempt $attempts, client: ${client?.id}") | ||
| if (client != null) { | ||
| Log.d(TAG, "Client is ready: ${client.id}") | ||
| // Log detailed client state | ||
| Log.d(TAG, "Client signIn status: ${client.signIn?.status}") | ||
| Log.d(TAG, "Client signUp status: ${client.signUp?.status}") | ||
| Log.d(TAG, "Client sessions count: ${client.sessions?.size ?: 0}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PII leaked in production logs — email addresses, phone numbers, user names, and MFA details are logged in plaintext.
Lines 100-102 log client signIn/signUp status; Line 138 logs signUp.emailAddress; Lines 143-148 log email verification details; Line 193 logs supportedSecondFactors including emailAddressId and phoneNumberId; Lines 249-261 log session/user IDs and signIn/signUp details periodically every 2 seconds. On Android, Log.d output is accessible to any app on the device (pre-API 24) and is always captured in bug reports.
This extensive debug logging should be gated behind a debug flag or removed before shipping. At minimum, strip all PII fields (emails, names, phone numbers) from log statements.
Also applies to: 135-151, 191-194, 248-262
🤖 Prompt for AI Agents
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt`
around lines 96 - 102, ClerkAuthActivity is logging PII (emails, phone numbers,
names, MFA details and raw IDs) via Log.d of TAG in places that include
client.signIn, client.signUp, signUp.emailAddress, supportedSecondFactors
entries, sessions and periodic polling logic; remove or redact PII and gate
verbose debug logs behind a debug flag (e.g., BuildConfig.DEBUG) or a runtime
debug toggle before shipping, replace direct PII fields with redacted
placeholders or hashed IDs, stop logging full supportedSecondFactors objects
(log only non-PII enums/counts), and disable the 2-second periodic full-client
dump (or make it conditional on debug mode) so only safe, non-identifying
diagnostics are emitted from ClerkAuthActivity.
| // Check if auth completed - finish activity immediately | ||
| val currentSession = Clerk.session | ||
| if (currentSession != null && !isAuthComplete) { | ||
| Log.d(TAG, ">>> AUTH COMPLETED - Session detected in polling loop <<<") | ||
| Log.d(TAG, "Session ID: ${currentSession.id}") | ||
| isAuthComplete = true | ||
|
|
||
| // Finish activity with success | ||
| val resultIntent = Intent().apply { | ||
| putExtra("sessionId", currentSession.id) | ||
| putExtra("userId", currentSession.user?.id ?: Clerk.user?.id) | ||
| } | ||
| setResult(Activity.RESULT_OK, resultIntent) | ||
| finish() | ||
| break | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dual session-completion paths can race and call setResult/finish() twice.
The polling loop (Line 220-233) and the LaunchedEffect(session) flow (Line 268-289) both detect session creation and call setResult + finish(). Since these run in separate coroutines, they can both observe isAuthComplete == false before either sets it to true. While Android tolerates double finish(), this will fire the result intent handler twice on the JS side.
Consider using an AtomicBoolean or a dedicated guard (similar to the dismissed flag in ClerkUserProfileActivity) that both paths check-and-set atomically, or consolidate to a single detection mechanism.
Also applies to: 267-289
🤖 Prompt for AI Agents
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt`
around lines 219 - 234, The polling loop and the LaunchedEffect(session) path
can race and both call setResult/finish because they check and set the Boolean
isAuthComplete non-atomically; replace that guard with an atomic check-or-set
(e.g., use an AtomicBoolean similar to the dismissed flag in
ClerkUserProfileActivity) or consolidate session-complete logic into a single
handler so both paths perform a single atomic compareAndSet on a shared
AtomicBoolean (referencing isAuthComplete, the LaunchedEffect(session) block,
the polling loop that reads Clerk.session, and the setResult/finish calls) to
ensure only one path proceeds to call setResult and finish().
| override fun onBackPressed() { | ||
| val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) | ||
| if (dismissable) { | ||
| setResult(Activity.RESULT_CANCELED) | ||
| super.onBackPressed() | ||
| } | ||
| // If not dismissable, ignore back press | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onBackPressed() is deprecated since API 33.
The Compose BackHandler at lines 297-305 already handles back press correctly. This deprecated override should be removed to avoid conflicting with the Compose-based handler.
🤖 Prompt for AI Agents
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt`
around lines 368 - 375, Remove the deprecated onBackPressed override from
ClerkAuthActivity (the override function named onBackPressed currently checking
EXTRA_DISMISSABLE and calling setResult/ super.onBackPressed); rely on the
existing Compose BackHandler (lines ~297-305 in this file) to handle back
presses instead, and scan ClerkAuthActivity for any callers that relied on the
onBackPressed override to ensure they use the Compose handler or explicit
finish/setResult logic instead.
| // Pending promises for activity results | ||
| private var pendingAuthPromise: Promise? = null | ||
| private var pendingProfilePromise: Promise? = null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two paths to permanently unresolved JS promises.
-
pendingAuthPromise/pendingProfilePromiseoverwrite (lines 68-69, 127, 149): These are static fields. A second call topresentAuthbefore the first completes silently replaces the stored promise—the original promise is never resolved or rejected, hanging the JS caller indefinitely. -
configure()can hang forever (line 94):Clerk.isInitialized.first { it }suspends until atrueis emitted. If Clerk initialization fails in a way that never flips the flag totrue(e.g., fatal network error whereinitializationErroris set butisInitializedstaysfalse), the promise is never settled.
Suggested mitigations
For (1), reject the existing promise before overwriting:
+ pendingAuthPromise?.let {
+ it.reject(CodedException("Auth flow superseded by a new request"))
+ }
pendingAuthPromise = promiseFor (2), add a timeout or also observe initializationError:
- Clerk.isInitialized.first { it }
+ withTimeout(10_000L) {
+ Clerk.isInitialized.first { it }
+ }(requires import kotlinx.coroutines.withTimeout)
Also applies to: 87-107
🤖 Prompt for AI Agents
In `@packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt`
around lines 67 - 69, The static promise fields pendingAuthPromise and
pendingProfilePromise can be silently overwritten by a second call to
presentAuth/presentProfile, and configure() awaits Clerk.isInitialized.first {
it } indefinitely if initialization never becomes true; before assigning a new
Promise in presentAuth/presentProfile reject the existing
pendingAuthPromise/pendingProfilePromise with an appropriate error and clear it
to avoid leaking unresolved JS promises, and in configure() replace the
unbounded first { it } wait with a bounded wait (use
kotlinx.coroutines.withTimeout) or concurrently observe
Clerk.initializationError and reject the configure promise if an error is
emitted so configure() always resolves or rejects; reference the symbols
pendingAuthPromise, pendingProfilePromise, presentAuth, presentProfile,
configure, Clerk.isInitialized, Clerk.initializationError and add the
withTimeout import.
| console.warn('[UserProfileView] JS SDK sign out error:', signOutErr); | ||
| // Even if signOut throws, try to force reload to clear stale state | ||
| const clerkAny = clerk as { __internal_reloadInitialResources?: () => Promise<void> }; | ||
| if (clerkAny?.__internal_reloadInitialResources) { | ||
| try { | ||
| console.log('[UserProfileView] Force reloading JS SDK state...'); | ||
| await clerkAny.__internal_reloadInitialResources(); | ||
| console.log('[UserProfileView] JS SDK state reloaded'); | ||
| } catch (reloadErr) { | ||
| console.warn('[UserProfileView] Failed to reload JS SDK state:', reloadErr); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessing private internal API __internal_reloadInitialResources is fragile.
This casts clerk to any-like shape to access an undocumented internal method. If this method is removed or renamed in a future @clerk/react update, this silently fails, but the real concern is that it couples this code to internals. Consider whether clerk.signOut() alone is sufficient, or coordinate with the Clerk SDK team on a supported recovery API.
🤖 Prompt for AI Agents
In `@packages/expo/src/native/UserProfileView.tsx` around lines 216 - 228, The
code accesses an undocumented internal API (__internal_reloadInitialResources)
on the clerk object after signOut, which is fragile; remove the cast and the
await clerkAny.__internal_reloadInitialResources() block from UserProfileView
(and the surrounding console logs) and instead: rely solely on the public
clerk.signOut() call for sign-out recovery, keep the existing try/catch around
signOut to log errors, and add a TODO comment referencing coordinating with the
`@clerk/react` SDK team to expose a supported recovery/reload method or to
implement an officially supported workaround if needed.
| useEffect(() => { | ||
| if ((Platform.OS === 'ios' || Platform.OS === 'android') && pk && !initStartedRef.current) { | ||
| initStartedRef.current = true; | ||
|
|
||
| const configureNativeClerk = async () => { | ||
| try { | ||
| const { requireNativeModule } = require('expo-modules-core'); | ||
| const ClerkExpo = requireNativeModule('ClerkExpo'); | ||
|
|
||
| if (ClerkExpo?.configure) { | ||
| await ClerkExpo.configure(pk); | ||
|
|
||
| // Poll for native session (matching iOS's 3-second max wait) | ||
| const MAX_WAIT_MS = 3000; | ||
| const POLL_INTERVAL_MS = 100; | ||
| let sessionId: string | null = null; | ||
|
|
||
| for (let elapsed = 0; elapsed < MAX_WAIT_MS; elapsed += POLL_INTERVAL_MS) { | ||
| if (ClerkExpo?.getSession) { | ||
| const nativeSession = await ClerkExpo.getSession(); | ||
| sessionId = nativeSession?.sessionId; | ||
| if (sessionId) { | ||
| break; | ||
| } | ||
| } | ||
| await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); | ||
| } | ||
|
|
||
| if (sessionId && clerkInstance) { | ||
| pendingNativeSessionRef.current = sessionId; | ||
|
|
||
| // Wait for clerk to be loaded before syncing | ||
| const clerkAny = clerkInstance as any; | ||
|
|
||
| const waitForLoad = (): Promise<void> => { | ||
| return new Promise(resolve => { | ||
| if (clerkAny.loaded) { | ||
| resolve(); | ||
| } else if (typeof clerkAny.addOnLoaded === 'function') { | ||
| clerkAny.addOnLoaded(() => resolve()); | ||
| } else { | ||
| resolve(); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| await waitForLoad(); | ||
|
|
||
| if (!sessionSyncedRef.current && clerkInstance.setActive) { | ||
| sessionSyncedRef.current = true; | ||
| const pendingSession = pendingNativeSessionRef.current; | ||
|
|
||
| // If the native session is not in the client's sessions list, | ||
| // reload the client from the API so setActive can find it. | ||
| const sessionInClient = clerkInstance.client?.sessions?.some( | ||
| (s: { id: string }) => s.id === pendingSession, | ||
| ); | ||
| if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') { | ||
| await clerkAny.__internal_reloadInitialResources(); | ||
| } | ||
|
|
||
| try { | ||
| await clerkInstance.setActive({ session: pendingSession }); | ||
| } catch (err) { | ||
| console.error(`[ClerkProvider] Failed to sync native session:`, err); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } 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, add "@clerk/expo" to your app.json plugins array.`, | ||
| ); | ||
| } | ||
| } else { | ||
| console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); | ||
| } | ||
| } | ||
| }; | ||
| configureNativeClerk(); | ||
| } | ||
| }, [pk, clerkInstance]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No cleanup for the fire-and-forget async polling in useEffect.
configureNativeClerk() (Line 160) is an async function that polls for up to 3 seconds and then calls setActive. If the component unmounts during this window (e.g., navigation), the async work continues and clerkInstance.setActive may execute against a stale/unmounted context. Consider using an AbortController or a mounted ref to bail out early.
🤖 Prompt for AI Agents
In `@packages/expo/src/provider/ClerkProvider.tsx` around lines 77 - 162, The
async configureNativeClerk() starts fire-and-forget polling and may call
clerkInstance.setActive after unmount; add an AbortController or isMountedRef to
cancel work: create e.g. const abortCtrl = new AbortController() / const
mountedRef = useRef(true) and update it in the effect cleanup, check
abortCtrl.signal.aborted or !mountedRef.current inside the polling loop and
before awaiting waitForLoad and before calling setActive to bail out early,
clear any pending setTimeout waits, and if you attach a listener via
clerkAny.addOnLoaded, store and remove that listener in the cleanup so it cannot
fire after unmount; return a cleanup from the useEffect that aborts the
controller (or sets mountedRef false) and resets initStartedRef.current if
appropriate. Ensure references named in the diff (configureNativeClerk,
clerkInstance, clerkAny, pendingNativeSessionRef, sessionSyncedRef,
initStartedRef) are checked for the abort flag before mutating state or calling
setActive.
| const clerkAny = clerkInstance as any; | ||
|
|
||
| const waitForLoad = (): Promise<void> => { | ||
| return new Promise(resolve => { | ||
| if (clerkAny.loaded) { | ||
| resolve(); | ||
| } else if (typeof clerkAny.addOnLoaded === 'function') { | ||
| clerkAny.addOnLoaded(() => resolve()); | ||
| } else { | ||
| resolve(); | ||
| } | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fragile reliance on undocumented internal Clerk APIs via as any casts.
clerkAny.loaded, clerkAny.addOnLoaded, and clerkAny.__internal_reloadInitialResources (Lines 109-135) are accessed through as any casts. If any of these internal APIs change in a Clerk SDK update, this code will silently fail or throw at runtime. The waitForLoad function at Lines 111-121 resolves immediately if neither loaded nor addOnLoaded exists, which could cause setActive to be called before the client is ready.
At minimum, add a runtime check before calling setActive that the client is actually loaded, to avoid a hard failure if these internals change.
🤖 Prompt for AI Agents
In `@packages/expo/src/provider/ClerkProvider.tsx` around lines 109 - 121, The
code relies on undocumented internals by casting clerkInstance to any and using
clerkAny.loaded, clerkAny.addOnLoaded and
clerkAny.__internal_reloadInitialResources; update waitForLoad and the place
where setActive is called (reference: clerkInstance, waitForLoad, setActive,
clerkAny.loaded, clerkAny.addOnLoaded, __internal_reloadInitialResources) to
perform robust runtime guards: after waitForLoad resolves, explicitly verify the
client is ready using safe runtime checks (e.g. check for a documented public
readiness flag or, if absent, confirm clerkAny.loaded === true before calling
setActive), and if the ready check fails, avoid calling setActive and handle
fallback (retry, no-op, or surface an error) so the code won't call internals or
setActive prematurely when these internal properties are missing or changed.
| const currentState = { | ||
| client: clerk.client ?? null, | ||
| session: clerk.session ?? null, | ||
| user: clerk.user ?? null, | ||
| organization: clerk.organization ?? null, | ||
| }; | ||
| this.premountAddListenerCalls.forEach((_, listener) => { | ||
| listener(currentState as any); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as any cast on listener invocation bypasses State type contract.
Line 315 casts currentState to any before passing it to each premount listener. The ListenerCallback likely expects a full State shape; if the native clerk instance is missing fields beyond client/session/user/organization (e.g., signIn, signUp), listeners that destructure those properties will get undefined without any type-level warning. This could cause subtle runtime errors in downstream listener code.
Consider constructing currentState to satisfy the full State type, or at least cast to Partial<State> to be explicit about the contract.
🤖 Prompt for AI Agents
In `@packages/react/src/isomorphicClerk.ts` around lines 308 - 316, The premount
listener invocation is using an unsafe "as any" cast on currentState; replace
that with a proper Partial<State> (or construct a full State with safe defaults)
so ListenerCallback consumers get correct typing. Specifically, change the value
passed to this.premountAddListenerCalls.forEach(listener => listener(...)) to be
typed as Partial<State> instead of any (or build a complete State by merging
clerk.client/session/user/organization with default no-op signIn/signUp/etc.),
and update usages around premountAddListenerCalls, currentState, and
ListenerCallback accordingly to remove the any cast while preserving runtime
behavior.
| // Try to load, but finish initialization regardless | ||
| if (!clerk.loaded) { | ||
| clerk | ||
| .load(this.options) | ||
| .then(() => finishInit()) | ||
| .catch(() => finishInit()); | ||
| } else { | ||
| finishInit(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silently swallowing clerk.load() errors and emitting 'ready' status masks initialization failures.
When clerk.load() rejects, the .catch(() => finishInit()) path sets #initialized = true, emits clerkEvents.Status, 'ready', and assigns the (potentially half-initialized) clerk instance — identical to the success path. No error is logged and no error status is emitted, so downstream consumers (hooks, components) will behave as if Clerk initialized successfully.
If this is intentional (native side owns auth, JS load is best-effort), at minimum the error should be logged and a degraded/error status should be considered so consumers can distinguish success from failure.
Suggested improvement
// Try to load, but finish initialization regardless
if (!clerk.loaded) {
clerk
.load(this.options)
.then(() => finishInit())
- .catch(() => finishInit());
+ .catch((err) => {
+ console.warn('[Clerk] Headless clerk.load() failed, proceeding with partial init:', err);
+ finishInit();
+ });
} else {
finishInit();
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Try to load, but finish initialization regardless | |
| if (!clerk.loaded) { | |
| clerk | |
| .load(this.options) | |
| .then(() => finishInit()) | |
| .catch(() => finishInit()); | |
| } else { | |
| finishInit(); | |
| } | |
| // Try to load, but finish initialization regardless | |
| if (!clerk.loaded) { | |
| clerk | |
| .load(this.options) | |
| .then(() => finishInit()) | |
| .catch((err) => { | |
| console.warn('[Clerk] Headless clerk.load() failed, proceeding with partial init:', err); | |
| finishInit(); | |
| }); | |
| } else { | |
| finishInit(); | |
| } |
🤖 Prompt for AI Agents
In `@packages/react/src/isomorphicClerk.ts` around lines 323 - 331, The current
catch on clerk.load silently swallows errors and still calls finishInit(),
causing `#initialized` to become true and emitting clerkEvents.Status 'ready' even
on failure; update the catch handler in the isomorphicClerk initialization to
log the error (include the caught error object), set a failure/degraded status
via clerkEvents.Status (e.g., 'error' or 'degraded') instead of 'ready', and
ensure any state set by finishInit (such as marking `#initialized` or assigning
the clerk instance) reflects the failed load so consumers can distinguish
success from failure; modify the promise chain around clerk.load(this.options)
and the finishInit() logic to branch on success vs failure using the unique
symbols clerk.load, finishInit, and clerkEvents.Status.
Description
These changes can be tested by using the snapshot that will be commented in this PR discussion, and installing that into the expo quickstart repo on the branch:
chris/mobile-343-bridge-android-to-a-native-module-that-is-available-in-the(same branch name as here).
https://linear.app/clerk/issue/MOBILE-342/bridge-ios-to-a-native-module-that-is-available-in-the-expo-sdk
MOBILE-289
https://linear.app/clerk/issue/MOBILE-289/expo-google-universal-sign-in
Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Documentation