Skip to content

fix(expo): inline AuthView OAuth + Android sign-out state cleanup#8260

Open
chriscanin wants to merge 9 commits intomainfrom
chris/fix-inline-authview-sso
Open

fix(expo): inline AuthView OAuth + Android sign-out state cleanup#8260
chriscanin wants to merge 9 commits intomainfrom
chris/fix-inline-authview-sso

Conversation

@chriscanin
Copy link
Copy Markdown
Member

@chriscanin chriscanin commented Apr 7, 2026

Description

Two related fixes for the inline <AuthView> and <UserProfileView> components in @clerk/expo.

iOS, OAuth from forgot password screen

The inline AuthView was embedded as a child UIHostingController inside React Native's view hierarchy. This disrupted ASWebAuthenticationSession callbacks during OAuth flows started from the forgot password screen, causing the SSO sign-in to silently fail.

ClerkAuthNativeView now presents its UIHostingController via UIViewController.present() instead of embedding it as a child view. The visual appearance is unchanged (fullscreen, no animation) but the OAuth callback chain now completes correctly.

Also includes:

  • presentWhenReady() uses UIViewController.transitionCoordinator to wait for any in-flight modal dismissal (for example, UserProfileView sign out) instead of a fixed delay, fixing a visible white flash on initial mount.
  • viewDidDisappear checks the new session before returning a cancelled result so successful auth still propagates.

Android, sign-out state cleanup

Three related fixes to prevent the AuthView from getting stuck on the "Get help / Email support" screen after sign out:

  1. Detect new sign-ins by session ID change instead of "null to value". ClerkAuthExpoView.initialSessionId is captured at construction, but the view can be instantiated before signOut has finished clearing local state, causing it to capture a stale session ID. Switching to ID inequality lets subsequent sign-ins fire the auth completed event correctly.

  2. Per view ViewModelStore for ClerkAuthExpoView. The clerk-android AuthView's navigation ViewModel was scoped to the MainActivity, so its navigation state (for example, "Get help" destination) persisted across mount and unmount cycles within the same activity. Each ClerkAuthExpoView instance now provides its own ViewModelStoreOwner so the AuthView gets a fresh ViewModel scope per mount.

  3. Refresh client from server after sign out. Clerk.auth.signOut() only clears the active session, not the in-progress Clerk.client.signIn. After sign out (whether via the JS bridge signOut(), the inline UserProfileView, or the modal UserProfileActivity), call Client.getSkippingClientId() to fetch a brand new client. The skipping variant is required because Client.get() echoes back the same client_id header, returning the same client with the stale signIn still attached.

Files changed

  • packages/expo/ios/ClerkExpoModule.swift, ClerkAuthNativeView modal presentation and presentWhenReady
  • packages/expo/ios/ClerkViewFactory.swift, viewDidDisappear session detection
  • packages/expo/android/.../ClerkAuthExpoView.kt, session ID change detection and per view ViewModelStore
  • packages/expo/android/.../ClerkExpoModule.kt, Client.getSkippingClientId() after signOut()
  • packages/expo/android/.../ClerkUserProfileActivity.kt, Client.getSkippingClientId() after UserProfile sign out
  • packages/expo/android/.../ClerkUserProfileExpoView.kt, Client.getSkippingClientId() after inline UserProfile sign out

How to test

iOS:

  1. Build and run the NativeComponentQuickstart app on the iOS simulator.
  2. Sign in with Google from the main sign in screen, expect a successful sign in.
  3. Sign out, then sign in with Google from the forgot password screen, expect a successful sign in (this was the original bug).
  4. Sign out via the authenticated screen button, expect a clean return to the sign in screen and the ability to sign in again.
  5. Sign in, open UserProfileView, sign out from there, expect a clean return to the sign in screen.
  6. Verify modal presentAuth() and presentUserProfile() flows still work.

Android:

  1. Build and run the NativeComponentQuickstart app on an Android emulator.
  2. Sign in with Google from the main sign in screen (uses Credential Manager).
  3. Sign in with Google from the forgot password screen (uses OAuth redirect via Custom Tabs).
  4. Sign out via the authenticated screen button, then sign back in.
  5. Sign in, open UserProfileView, sign out from there, then sign back in. Verify the user is not stuck on the "Get help" screen.
  6. Repeat sign in and sign out cycles to confirm no state leakage.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

…password

The inline AuthView embedded as a child UIHostingController in React Native's
view hierarchy disrupts ASWebAuthenticationSession callbacks during OAuth flows.
SSO from the forgot-password screen would silently fail because the OAuth
callback couldn't properly update Clerk.shared.client in the embedded context.

This changes ClerkAuthNativeView to present the AuthView as a full-screen modal
(matching the working presentAuth() behavior) instead of embedding it inline.
Also adds retry logic for modal presentation after sign-out to handle cases
where a previous modal (e.g., UserProfileView) is still dismissing.
@chriscanin chriscanin self-assigned this Apr 7, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 7, 2026

🦋 Changeset detected

Latest commit: 6b1c872

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

This PR includes changesets to release 1 package
Name Type
@clerk/expo Patch

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

@chriscanin chriscanin added the expo label Apr 7, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Apr 10, 2026 9:03pm

Request Review

Replace the unconditional 0.3s asyncAfter delay with presentWhenReady(),
which presents the auth modal synchronously when possible. The fixed delay
caused a visible white flash on initial mount because ClerkAuthNativeView
is an empty UIView while waiting to present.

When a previous modal (e.g., UserProfileView sign-out) is still dismissing,
use UIViewController.transitionCoordinator to wait for the animation to
complete instead of polling. Falls back to a one-frame DispatchQueue.main.async
retry only when no coordinator is available yet.
@chriscanin chriscanin marked this pull request as ready for review April 7, 2026 19:54
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

iOS: Inline auth view now presents the auth UI controller as a full-screen modal (created via a completion-based factory), uses safe presentation timing, dismisses the modal on removal, consolidates event emission with de-duplication and suppression for cancelled results, and triggers module-level auth-state events for sign-in completions. ClerkAuthWrapperViewController reports a signIn result when the session id changed on disappearance, otherwise reports cancelled. Android: per-view ViewModelStoreOwner added and cleared on detach; auth completion now guards against duplicate emits by comparing session id to an initial id. Shared: sign-out flows call Client.getSkippingClientId() after sign-out (errors logged). A patch changeset was added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(expo): inline AuthView OAuth + Android sign-out state cleanup' accurately and concisely summarizes the two main fixes in the changeset: iOS OAuth handling and Android sign-out state issues.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the motivation, implementation details, affected files, and testing steps for both iOS and Android fixes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 7, 2026

Open in StackBlitz

@clerk/agent-toolkit

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

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/dev-cli

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 6b1c872

@chriscanin
Copy link
Copy Markdown
Member Author

!snapshot

@github-actions

This comment has been minimized.

Three related Android fixes for the inline AuthView:

1. Detect new sign-ins by session ID change (not null-to-value).
   ClerkAuthExpoView's initialSessionId is captured at construction, but the
   view can be instantiated before signOut has finished clearing local state,
   causing it to capture a stale session ID. Switching to ID inequality lets
   subsequent sign-ins fire the auth-completed event correctly.

2. Per-view ViewModelStore for ClerkAuthExpoView.
   The clerk-android AuthView's navigation ViewModel was scoped to the
   MainActivity, so its navigation state (e.g. "Get help" destination)
   persisted across mount/unmount cycles within the same activity. Each
   ClerkAuthExpoView instance now provides its own ViewModelStoreOwner so
   the AuthView gets a fresh ViewModel scope per mount.

3. Refresh client from server after sign-out.
   Clerk.auth.signOut() only clears the active session, not the in-progress
   Clerk.client.signIn. After sign-out (whether via the JS bridge signOut(),
   the inline UserProfile view, or the modal UserProfile activity), call
   Client.getSkippingClientId() to fetch a brand-new client. The skipping
   variant is required because Client.get() echoes back the same client_id
   header, returning the same client with the stale signIn still attached.
@chriscanin chriscanin changed the title fix(expo): present inline AuthView as modal to fix OAuth from forgot-… fix(expo): inline AuthView OAuth + Android sign-out state cleanup Apr 8, 2026
@chriscanin
Copy link
Copy Markdown
Member Author

!snapshot

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Hey @chriscanin - the snapshot version command generated the following package versions:

Package Version
@clerk/agent-toolkit 0.3.12-snapshot.v20260408154640
@clerk/astro 3.0.12-snapshot.v20260408154640
@clerk/backend 3.2.8-snapshot.v20260408154640
@clerk/chrome-extension 3.1.9-snapshot.v20260408154640
@clerk/clerk-js 6.5.1-snapshot.v20260408154640
@clerk/dev-cli 0.1.1-snapshot.v20260408154640
@clerk/expo 3.1.9-snapshot.v20260408154640
@clerk/expo-passkeys 1.0.10-snapshot.v20260408154640
@clerk/express 2.1.0-snapshot.v20260408154640
@clerk/fastify 3.1.10-snapshot.v20260408154640
@clerk/hono 0.1.10-snapshot.v20260408154640
@clerk/localizations 4.3.3-snapshot.v20260408154640
@clerk/msw 0.0.10-snapshot.v20260408154640
@clerk/nextjs 7.0.12-snapshot.v20260408154640
@clerk/nuxt 2.1.2-snapshot.v20260408154640
@clerk/react 6.2.1-snapshot.v20260408154640
@clerk/react-router 3.0.12-snapshot.v20260408154640
@clerk/shared 4.5.1-snapshot.v20260408154640
@clerk/tanstack-react-start 1.0.12-snapshot.v20260408154640
@clerk/testing 2.0.12-snapshot.v20260408154640
@clerk/ui 1.4.1-snapshot.v20260408154640
@clerk/upgrade 2.0.3-snapshot.v20260408154640
@clerk/vue 2.0.11-snapshot.v20260408154640

Tip: Use the snippet copy button below to quickly install the required packages.
@clerk/agent-toolkit

npm i @clerk/agent-toolkit@0.3.12-snapshot.v20260408154640 --save-exact

@clerk/astro

npm i @clerk/astro@3.0.12-snapshot.v20260408154640 --save-exact

@clerk/backend

npm i @clerk/backend@3.2.8-snapshot.v20260408154640 --save-exact

@clerk/chrome-extension

npm i @clerk/chrome-extension@3.1.9-snapshot.v20260408154640 --save-exact

@clerk/clerk-js

npm i @clerk/clerk-js@6.5.1-snapshot.v20260408154640 --save-exact

@clerk/dev-cli

npm i @clerk/dev-cli@0.1.1-snapshot.v20260408154640 --save-exact

@clerk/expo

npm i @clerk/expo@3.1.9-snapshot.v20260408154640 --save-exact

@clerk/expo-passkeys

npm i @clerk/expo-passkeys@1.0.10-snapshot.v20260408154640 --save-exact

@clerk/express

npm i @clerk/express@2.1.0-snapshot.v20260408154640 --save-exact

@clerk/fastify

npm i @clerk/fastify@3.1.10-snapshot.v20260408154640 --save-exact

@clerk/hono

npm i @clerk/hono@0.1.10-snapshot.v20260408154640 --save-exact

@clerk/localizations

npm i @clerk/localizations@4.3.3-snapshot.v20260408154640 --save-exact

@clerk/msw

npm i @clerk/msw@0.0.10-snapshot.v20260408154640 --save-exact

@clerk/nextjs

npm i @clerk/nextjs@7.0.12-snapshot.v20260408154640 --save-exact

@clerk/nuxt

npm i @clerk/nuxt@2.1.2-snapshot.v20260408154640 --save-exact

@clerk/react

npm i @clerk/react@6.2.1-snapshot.v20260408154640 --save-exact

@clerk/react-router

npm i @clerk/react-router@3.0.12-snapshot.v20260408154640 --save-exact

@clerk/shared

npm i @clerk/shared@4.5.1-snapshot.v20260408154640 --save-exact

@clerk/tanstack-react-start

npm i @clerk/tanstack-react-start@1.0.12-snapshot.v20260408154640 --save-exact

@clerk/testing

npm i @clerk/testing@2.0.12-snapshot.v20260408154640 --save-exact

@clerk/ui

npm i @clerk/ui@1.4.1-snapshot.v20260408154640 --save-exact

@clerk/upgrade

npm i @clerk/upgrade@2.0.3-snapshot.v20260408154640 --save-exact

@clerk/vue

npm i @clerk/vue@2.0.11-snapshot.v20260408154640 --save-exact

Copy link
Copy Markdown

@seanperez29 seanperez29 left a comment

Choose a reason for hiding this comment

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

From codex review locally (not sure if a true issue):

presentWhenReady() queues deferred callbacks that are not invalidated when removeFromSuperview() runs. Because dismissAuthModal() clears presentedAuthVC, a stale queued callback can later pass the guard and call present(...), surfacing an orphaned fullscreen auth modal after the React Native view has already been removed. The fix is to invalidate pending retries on unmount, for example with an isInvalidated flag checked at the top of presentWhenReady()

- Bump clerk-android to 1.0.11 which handles Client.getSkippingClientId()
  in SignOutService.signOut() (clerk/clerk-android#587)
- Remove our bridge-level getSkippingClientId() calls (now redundant)
- Use Clerk.getDeviceToken() instead of raw SharedPreferences read for
  getClientToken(), fixing compatibility with encrypted storage in 1.0.11
Addresses Sean's review: removeFromSuperview() clears presentedAuthVC
but queued DispatchQueue.main.async and transitionCoordinator callbacks
from presentWhenReady() can still fire. The nil presentedAuthVC passes
the guard, causing present() on an orphaned auth modal. Adding an
isInvalidated flag checked at the top of presentWhenReady() prevents
stale callbacks from presenting after the view is removed.
@chriscanin
Copy link
Copy Markdown
Member Author

@seanperez29 both of these have now been addressed.

I added an isInvalidated flag to ClerkAuthNativeView that gets set when removeFromSuperview() runs and is checked at the top of presentWhenReady(). Without this, queued callbacks from DispatchQueue.main.async or transitionCoordinator.animate could fire after the view was removed, passing the presentedAuthVC guard and presenting an orphaned fullscreen auth modal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants