Skip to content

fix(expo): align native prebuilt component APIs#8699

Draft
mikepitre wants to merge 11 commits into
mainfrom
mike/expo-native-prebuilt-parity
Draft

fix(expo): align native prebuilt component APIs#8699
mikepitre wants to merge 11 commits into
mainfrom
mike/expo-native-prebuilt-parity

Conversation

@mikepitre
Copy link
Copy Markdown
Contributor

@mikepitre mikepitre commented May 28, 2026

Summary

This PR aligns the Expo native prebuilt component surface with the way the Clerk iOS and Android SDKs are used directly: native views render content, app code owns presentation, and auth state is observed through the provider/hooks instead of component-specific completion callbacks.

Important pieces

  • Make AuthView and UserProfileView app-presented content

    • Removes the old presentAuth / presentUserProfile helper paths, Android presentation activities, the user-profile modal hook, and the stale inline component aliases.
    • Motivation: Expo usage should mirror clerk-ios patterns like presenting AuthView() in a sheet. The prebuilt view should not decide whether it is full screen, sheet, route, or inline content.
  • Wrap the platform-native UserButton

    • Adds native UserButton view managers on iOS and Android and removes the JS avatar/button implementation.
    • Motivation: UserButton should behave like the native SDK button, including presenting the native user profile itself. JS should not reimplement the avatar, tap behavior, or profile presentation.
  • Centralize native-to-JS auth state sync in ClerkProvider

    • Moves auth completion/sign-out synchronization out of AuthView and UserProfileView.
    • Native auth UI now emits internal auth-state events and the provider syncs the native client token, reloads JS resources when needed, and activates/clears the JS session.
    • Motivation: useAuth() already owns auth state for app developers. Component-level callbacks like onComplete duplicate that responsibility and make examples clunkier.
  • Keep JS sign-out and native sign-out in sync

    • NativeSessionSync now avoids clearing a persisted native session during cold start before JS has adopted it, while still propagating a real JS sign-out to native.
    • Motivation: a native AuthView sign-in, native UserButton sign-out, and JS signOut() should all converge on one session state.
  • Trim bridge surface area

    • Removes unused UIKit/Android presentation bridge code and leaves only the native module functions/events needed for provider sync and the native view managers needed to mount prebuilt UI.
    • Motivation: smaller bridge surface, fewer Expo-only presentation semantics, and less code to keep in parity with the native SDKs.

Before / after examples

Auth presentation

Before: Expo exposed imperative native presentation helpers, so presentation was owned by the SDK bridge.

import { ClerkExpoModule } from '@clerk/expo/native-module';

await ClerkExpoModule.presentAuth({
  mode: 'signInOrUp',
  dismissable: true,
});

After: AuthView is content. The app chooses sheet, route, full screen, or inline presentation.

import { AuthView } from '@clerk/expo/native';
import { useState } from 'react';
import { Button, Modal } from 'react-native';

export function SignedOut() {
  const [isPresented, setIsPresented] = useState(false);

  return (
    <>
      <Button
        title='Sign in'
        onPress={() => setIsPresented(true)}
      />
      <Modal
        visible={isPresented}
        presentationStyle='pageSheet'
        onRequestClose={() => setIsPresented(false)}
      >
        <AuthView
          isDismissable
          onDismiss={() => setIsPresented(false)}
        />
      </Modal>
    </>
  );
}

Full-screen/root auth

Before: full-screen behavior came from native presentation-specific bridge code.

await ClerkExpoModule.presentAuth({
  mode: 'signInOrUp',
  dismissable: false,
});

After: render AuthView wherever the app wants it to live.

import { AuthView } from '@clerk/expo/native';

export function SignedOut() {
  return <AuthView />;
}

User button

Before: UserButton reimplemented avatar rendering in JS, fetched native user state, and manually called the native profile presenter.

import { UserButton } from '@clerk/expo/native';

export function Header() {
  return <UserButton />;
}

After: UserButton is backed by the platform-native Clerk button. Tapping it opens the native user profile, just like the iOS and Android SDKs.

import { UserButton } from '@clerk/expo/native';

export function SignedIn() {
  return <UserButton />;
}

User profile presentation

Before: profile presentation could be driven through Expo-specific imperative helpers.

import { useUserProfileModal } from '@clerk/expo';

export function Account() {
  const { presentUserProfile } = useUserProfileModal();

  return (
    <Button
      title='Account'
      onPress={() => void presentUserProfile()}
    />
  );
}

After: UserProfileView is content. The app owns the presentation surface.

import { UserProfileView } from '@clerk/expo/native';
import { useState } from 'react';
import { Button, Modal } from 'react-native';

export function Account() {
  const [isPresented, setIsPresented] = useState(false);

  return (
    <>
      <Button
        title='Account'
        onPress={() => setIsPresented(true)}
      />
      <Modal
        visible={isPresented}
        presentationStyle='pageSheet'
        onRequestClose={() => setIsPresented(false)}
      >
        <UserProfileView
          isDismissable
          onDismiss={() => setIsPresented(false)}
        />
      </Modal>
    </>
  );
}

Auth completion

Before: AuthView parsed native completion events and synced JS state from inside the component.

<AuthView />

After: auth completion is observed through Clerk auth state. The component stays presentation/content-only.

import { useAuth } from '@clerk/expo';

export function Root() {
  const { isLoaded, isSignedIn } = useAuth();

  if (!isLoaded) {
    return null;
  }

  return isSignedIn ? <SignedIn /> : <SignedOut />;
}

Testing

  • pnpm --filter @clerk/expo format:check
  • pnpm --filter @clerk/expo test
  • Targeted eslint on touched Expo native/provider files. The remaining output is existing React Native internal parser noise plus the existing @clerk/react/internal resolver issue when linting ClerkProvider.tsx.
  • Local clerk-expo-quickstart: npx tsc --noEmit
  • Local clerk-expo-quickstart: npx expo run:ios --device 9A1571DF-CC3D-465F-A2E1-C89EC2388B31

Notes

  • UserButton keeps a tiny 36x36 host style because React Native/Yoga does not infer the intrinsic size of the native host view. The native SDK still owns rendering and presentation.
  • The changeset is minor, not major, because these prebuilt components are still beta and this is expected beta-surface iteration rather than a stable package-level breaking release.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 28, 2026

🦋 Changeset detected

Latest commit: dca9e99

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 Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 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 May 29, 2026 3:08pm

Request Review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: dca9e99

private var pendingProfilePromise: Promise? = null
val event = Arguments.createMap().apply {
putString("type", type)
if (sessionId == null) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're putting session id in this map either way. Could we simplify the expression?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, agreed. I simplified this to write the nullable sessionId directly with putString("sessionId", sessionId), which keeps the same JS shape without the explicit null branch.

}
}

val content = @androidx.compose.runtime.Composable {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: could we import this package? Should be able to do @Composable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, done. I imported Composable and changed the local lambda to val userButtonContent: @Composable () -> Unit = { ... } so this reads like the other Compose wrappers.

}

val content = @androidx.compose.runtime.Composable {
MaterialTheme {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Would be curious what this is actually doing. It may be needed I'm just curious

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the Compose host setup for rendering Clerk Android UI inside a React Native view. The Clerk Compose components expect lifecycle/view model/saved-state owners, but RN-hosted views do not reliably inherit those locals, so we provide the Activity owners explicitly. I added a short comment here to make that intent clearer.

Copy link
Copy Markdown
Member

@chriscanin chriscanin left a comment

Choose a reason for hiding this comment

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

Hi Mike, things are looking really good in here, I have tested each screen thourouhgly side by side with the native ios quickstart application. This review is for iOS only right now. I am happy to test the android, but I wanted to put this out there first before moving on.

In my testing I found 3 (minor) issues that could be considered not 1:1:

  1. No grabber pill on the top of the RN Modal for sign in, the user button sheet has one.
  2. After sign-out, AuthView modal auto-opens instead of returning to the unauthenticated Sign In button state.
  3. Phone number sign-in/up tab tapping Use phone number does nothing, but this is a known issue: #8659
    Side note: Docs and quickstarts will need updates to go along with this branch. I am happy to provide those once we are reviewed and satisfied with this PR.

I have also left 3 code comments / questions in this review as well.

hasInitialized = true
presentAuthModal()
updateView()
} else if window == nil && hasInitialized && currentDismissable && !didCompleteAuthentication && !dismissalEventSent {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"dismissed" is suppressed when didCompleteAuthentication == true, so a successful sign-in never fires onDismiss. When AuthView is wrapped in a <Modal>, the consumer's visible state stays true after sign-in.

On the next sign-out the modal re-opens on its own.

Can we emit "dismissed" on auth success too?

private var currentDismissable: Bool = false
private var hasInitialized: Bool = false
private var didSignOut = false
private var dismissalEventSent = false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The default for isDismissable changed from true to false for both ClerkAuthNativeView and ClerkUserProfileNativeView. This is a behavioral break for anyone relying on the old default to render the dismiss button.

This will not affect people in prod, but developers may be confused on what may be causing issues.

Can we add an explicit "Breaking" callout to the changeset / changelog so it shows up in release notes?

fatalError("init(coder:) has not been implemented")
}

override public func didMoveToWindow() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ClerkUserButtonNativeView adds its hosting controller as a child VC (addChild + didMove(toParent:)) but never detaches it so there's no window == nil branch and no removeFromSuperview override, unlike ClerkAuthNativeView / ClerkUserProfileNativeView.

A child VC isn't auto-removed from its parent when its view leaves the window, so on unmount the controller stays retained and its SwiftUI keeps living. This isn't about emitting a "dismissed" event as I see UserButton is event-less (as it should be). It's purely the teardown/detach for lifecycle parity.

Is it worth adding detachHostingController() here to match the other two views?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants