Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/expo-organization-switcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@clerk/expo': minor
---

Add `<OrganizationSwitcher />` native component to `@clerk/expo/native`.

Renders a fully native organization switcher inline in your React Native view hierarchy — SwiftUI on iOS and Jetpack Compose on Android — backed by the prebuilt `OrganizationSwitcher` views in `clerk-ios` and `clerk-android`. Tapping the switcher opens the platform's native overview / account-list / manage sheets, including the full `OrganizationProfileView` flow (members, domains, invitations, action confirmations) on platforms where it has shipped.

```tsx
import { OrganizationSwitcher } from '@clerk/expo/native'

<OrganizationSwitcher
onOrganizationChanged={({ organizationId }) => {
// re-query useOrganization() or refetch as needed
}}
/>
```

Active-organization changes are surfaced via the `onOrganizationChanged` callback so consumers can react in JS.

Requires the underlying native SDKs to include their `OrganizationSwitcher` prebuilt views — `clerk-ios` ≥ the release containing [clerk/clerk-ios#411](https://github.com/clerk/clerk-ios/pull/411) and `clerk-android` ≥ 1.0.17.
10 changes: 8 additions & 2 deletions packages/expo/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ ext {
credentialsVersion = "1.3.0"
googleIdVersion = "1.1.1"
kotlinxCoroutinesVersion = "1.7.3"
clerkAndroidApiVersion = "1.0.16"
clerkAndroidUiVersion = "1.0.16"
// OrganizationSwitcher + OrganizationProfileView surface depends on the
// clerk-android org suite merged 2026-05-13..16 (PRs #628–#638). v1.0.16
// (2026-05-11) predates that work — bump to v1.0.17 once tagged. For
// local testing against unreleased main, publish a snapshot via
// `./gradlew publishToMavenLocal` from a clerk-android worktree and pin
// these to your snapshot version.
clerkAndroidApiVersion = "1.0.17"
clerkAndroidUiVersion = "1.0.17"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package expo.modules.clerk

import android.content.Context
import android.content.res.Configuration
import android.util.Log
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.clerk.api.Clerk
import com.clerk.ui.organizationswitcher.OrganizationSwitcher
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.events.RCTEventEmitter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

private const val TAG = "ClerkOrgSwitcherView"

class ClerkOrganizationSwitcherNativeView(context: Context) : FrameLayout(context) {
var hidePersonal: Boolean = false

private val activity = ClerkAuthNativeView.findActivity(context)

private var recomposer: Recomposer? = null
private var recomposerJob: kotlinx.coroutines.Job? = null

private val composeView = ComposeView(context).also { view ->
activity?.let { act ->
view.setViewTreeLifecycleOwner(act)
view.setViewTreeViewModelStoreOwner(act)
view.setViewTreeSavedStateRegistryOwner(act)

val recomposerContext = AndroidUiDispatcher.Main
val newRecomposer = Recomposer(recomposerContext)
recomposer = newRecomposer
view.setParentCompositionContext(newRecomposer)
val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob())
recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job]
scope.launch {
newRecomposer.runRecomposeAndApplyChanges()
}
}
addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}

override fun onDetachedFromWindow() {
recomposer?.cancel()
recomposerJob?.cancel()
super.onDetachedFromWindow()
}

fun setupView() {
Log.d(TAG, "setupView - hidePersonal: $hidePersonal")

composeView.setContent {
// Track the last-known active organization id so we only emit the JS event
// when it actually changes. The native composable's onOrganizationChanged
// callback is a Unit lambda; we read the new id off the session flow.
val session by Clerk.sessionFlow.collectAsStateWithLifecycle()
var lastOrgId by remember { mutableStateOf(Clerk.session?.lastActiveOrganizationId) }

LaunchedEffect(session?.lastActiveOrganizationId) {
val currentId = session?.lastActiveOrganizationId
if (currentId != lastOrgId) {
lastOrgId = currentId
sendEvent("organizationChanged", mapOf("organizationId" to currentId))
}
}

val content = @androidx.compose.runtime.Composable {
// React Native apps don't follow Android system dark mode by default,
// so the surrounding RN UI renders light even when the OS is in
// dark mode. ClerkMaterialTheme's color picker reads
// isSystemInDarkTheme() — pin LocalConfiguration's night flag off
// so it matches what the host app actually displays.
// TODO: surface a `colorScheme` prop so consumers can opt into
// following the host app's Appearance API state instead.
val baseConfig = LocalConfiguration.current
val lightConfig = remember(baseConfig) {
Configuration(baseConfig).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
Configuration.UI_MODE_NIGHT_NO
}
}
CompositionLocalProvider(LocalConfiguration provides lightConfig) {
OrganizationSwitcher(
modifier = Modifier.fillMaxWidth(),
clerkTheme = Clerk.customTheme,
// Consumers should compose <UserButton /> from @clerk/expo/native
// separately when they want one — keeps parity with iOS, which
// doesn't bundle a UserButton in its OrganizationSwitcher.
showUserButton = false,
onOrganizationChanged = {
// The composable signals completion; we read the new id off
// the session flow via the LaunchedEffect above to get an
// actual id payload.
Log.d(TAG, "Org switch completed")
},
)
}
}

if (activity != null) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides activity,
LocalLifecycleOwner provides activity,
LocalSavedStateRegistryOwner provides activity,
) {
content()
}
} else {
content()
}
}
}

private fun sendEvent(type: String, data: Map<String, Any?>) {
val reactContext = context as? ReactContext ?: return
val eventData = Arguments.createMap().apply {
putString("type", type)
val jsonString = try {
org.json.JSONObject(data).toString()
} catch (e: Exception) {
"{}"
}
putString("data", jsonString)
}
reactContext.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(id, "onOrganizationEvent", eventData)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package expo.modules.clerk

import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.ClerkOrganizationSwitcherViewManagerInterface

class ClerkOrganizationSwitcherViewManager : SimpleViewManager<ClerkOrganizationSwitcherNativeView>(),
ClerkOrganizationSwitcherViewManagerInterface<ClerkOrganizationSwitcherNativeView> {

override fun getName(): String = "ClerkOrganizationSwitcherView"

override fun createViewInstance(reactContext: ThemedReactContext): ClerkOrganizationSwitcherNativeView {
return ClerkOrganizationSwitcherNativeView(reactContext)
}

@ReactProp(name = "hidePersonal")
override fun setHidePersonal(view: ClerkOrganizationSwitcherNativeView, hidePersonal: Boolean) {
view.hidePersonal = hidePersonal
view.setupView()
}

override fun getExportedCustomBubblingEventTypeConstants(): MutableMap<String, Any>? {
return MapBuilder.builder<String, Any>()
.put("onOrganizationEvent", MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onOrganizationEvent")
))
.build() as MutableMap<String, Any>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ClerkPackage : TurboReactPackage() {
return listOf(
ClerkAuthViewManager(),
ClerkUserProfileViewManager(),
ClerkOrganizationSwitcherViewManager(),
)
}
}
3 changes: 2 additions & 1 deletion packages/expo/ios/ClerkExpo.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Pod::Spec.new do |s|
# because it uses `import ClerkKit` which is only available via SPM in the app target.
s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m",
"ClerkAuthViewManager.swift", "ClerkAuthViewManager.m",
"ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m"
"ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m",
"ClerkOrganizationSwitcherViewManager.swift", "ClerkOrganizationSwitcherViewManager.m"

install_modules_dependencies(s)
end
83 changes: 83 additions & 0 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public protocol ClerkViewFactoryProtocol {
// Inline rendering — returns UIViewController to preserve SwiftUI lifecycle
func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController?
func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController?
func createOrganizationSwitcherView(hidePersonal: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController?

// SDK operations
func configure(publishableKey: String, bearerToken: String?) async throws
Expand Down Expand Up @@ -461,3 +462,85 @@ public class ClerkUserProfileNativeView: UIView {
hostingController?.view.frame = bounds
}
}

// MARK: - Inline View: ClerkOrganizationSwitcherNativeView

public class ClerkOrganizationSwitcherNativeView: UIView {
private var hostingController: UIViewController?
private var currentHidePersonal: Bool = false
private var hasInitialized: Bool = false

@objc var onOrganizationEvent: RCTBubblingEventBlock?

@objc var hidePersonal: NSNumber? {
didSet {
currentHidePersonal = hidePersonal?.boolValue ?? false
if hasInitialized { updateView() }
}
}

override init(frame: CGRect) {
super.init(frame: frame)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override public func didMoveToWindow() {
super.didMoveToWindow()
if window != nil && !hasInitialized {
hasInitialized = true
updateView()
}
}

private func updateView() {
hostingController?.view.removeFromSuperview()
hostingController?.removeFromParent()
hostingController = nil

guard let factory = clerkViewFactory else { return }

guard let returnedController = factory.createOrganizationSwitcherView(
hidePersonal: currentHidePersonal,
onEvent: { [weak self] eventName, data in
let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data()
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
self?.onOrganizationEvent?(["type": eventName, "data": jsonString])
}
) else { return }

if let parentVC = findViewController() {
parentVC.addChild(returnedController)
returnedController.view.frame = bounds
returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
returnedController.view.backgroundColor = .clear
addSubview(returnedController.view)
returnedController.didMove(toParent: parentVC)
hostingController = returnedController
} else {
returnedController.view.frame = bounds
returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
returnedController.view.backgroundColor = .clear
addSubview(returnedController.view)
hostingController = returnedController
}
}

private func findViewController() -> UIViewController? {
var responder: UIResponder? = self
while let nextResponder = responder?.next {
if let vc = nextResponder as? UIViewController {
return vc
}
responder = nextResponder
}
return nil
}

override public func layoutSubviews() {
super.layoutSubviews()
hostingController?.view.frame = bounds
}
}
8 changes: 8 additions & 0 deletions packages/expo/ios/ClerkOrganizationSwitcherViewManager.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(ClerkOrganizationSwitcherViewManager, RCTViewManager)

RCT_EXPORT_VIEW_PROPERTY(hidePersonal, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(onOrganizationEvent, RCTBubblingEventBlock)

@end
13 changes: 13 additions & 0 deletions packages/expo/ios/ClerkOrganizationSwitcherViewManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React

@objc(ClerkOrganizationSwitcherViewManager)
class ClerkOrganizationSwitcherViewManager: RCTViewManager {

override static func requiresMainQueueSetup() -> Bool {
return true
}

override func view() -> UIView! {
return ClerkOrganizationSwitcherNativeView()
}
}
Loading
Loading