From 380a2916c354b6da022addbf5489ae00f52b8fa7 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 14 Jul 2026 13:01:25 -0700 Subject: [PATCH 1/2] feat(expo): support custom logos in native AuthView --- .changeset/expo-native-custom-logo.md | 5 ++ .../expo/modules/clerk/ClerkAuthViewModule.kt | 82 +++++++++++++++++++ packages/expo/ios/ClerkAuthNativeView.swift | 59 +++++++++++++ packages/expo/ios/ClerkNativeBridge.swift | 60 +++++++++++++- packages/expo/src/native/AuthView.tsx | 13 ++- packages/expo/src/native/AuthView.types.ts | 11 +++ .../src/native/__tests__/AuthView.test.tsx | 9 ++ 7 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .changeset/expo-native-custom-logo.md diff --git a/.changeset/expo-native-custom-logo.md b/.changeset/expo-native-custom-logo.md new file mode 100644 index 00000000000..ab866391405 --- /dev/null +++ b/.changeset/expo-native-custom-logo.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Add a `logo` prop to the native `AuthView`, allowing Expo apps to replace the dashboard-configured logo with custom React Native content on Android and iOS. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 86fff6fe55d..36dc06a2b88 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -2,9 +2,15 @@ package expo.modules.clerk import android.content.Context import android.util.Log +import android.view.View +import android.view.ViewGroup import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.clerk.api.Clerk @@ -26,6 +32,21 @@ private fun debugLog(tag: String, message: String) { class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) { var isDismissible: Boolean = true var mode: String? = null + var logoView: View? = null + private set + + private var logoWidth = 0 + private var logoHeight = 0 + private val logoLayoutListener = + View.OnLayoutChangeListener { view, left, top, right, bottom, _, _, _, _ -> + val width = right - left + val height = bottom - top + if (view === logoView && (width != logoWidth || height != logoHeight)) { + logoWidth = width + logoHeight = height + setupView() + } + } private val onAuthEvent by EventDispatcher() @@ -61,6 +82,9 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = Clerk.customTheme, + logo = logoView?.let { view -> + { ReactLogoView(view = view, width = logoWidth, height = logoHeight) } + }, mode = authMode(mode), isDismissible = isDismissible, onDismiss = ::sendDismissEvent, @@ -70,6 +94,25 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo ) } + fun setLogoView(view: View) { + if (logoView === view) return + logoView?.removeOnLayoutChangeListener(logoLayoutListener) + logoView = view + logoWidth = view.width + logoHeight = view.height + view.addOnLayoutChangeListener(logoLayoutListener) + setupView() + } + + fun removeLogoView(view: View) { + if (logoView !== view) return + view.removeOnLayoutChangeListener(logoLayoutListener) + logoView = null + logoWidth = 0 + logoHeight = 0 + setupView() + } + private fun sendEvent(type: String) { onAuthEvent(mapOf("type" to type)) } @@ -87,6 +130,25 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo } } +@Composable +private fun ReactLogoView(view: View, width: Int, height: Int) { + val density = LocalDensity.current + val modifier = + if (width > 0 && height > 0) { + with(density) { Modifier.size(width.toDp(), height.toDp()) } + } else { + Modifier.wrapContentSize() + } + + AndroidView( + modifier = modifier, + factory = { + (view.parent as? ViewGroup)?.removeView(view) + view + }, + ) +} + class ClerkAuthViewModule : Module() { override fun definition() = ModuleDefinition { Name("ClerkAuthView") @@ -94,6 +156,26 @@ class ClerkAuthViewModule : Module() { View(ClerkAuthNativeView::class) { Events("onAuthEvent") + GroupView { + AddChildView { parent, child, _ -> + parent.setLogoView(child) + } + GetChildCount { parent -> + if (parent.logoView == null) 0 else 1 + } + GetChildViewAt { parent, index -> + if (index == 0) parent.logoView else null + } + RemoveChildView { parent, child -> + parent.removeLogoView(child) + } + RemoveChildViewAt { parent, index -> + if (index == 0) { + parent.logoView?.let(parent::removeLogoView) + } + } + } + Prop("mode") { view: ClerkAuthNativeView, mode: String? -> view.mode = mode } diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index eca8515bc16..648f0c49ffc 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -4,6 +4,9 @@ import UIKit public class ClerkAuthNativeView: ClerkNativeViewHost { private var currentMode: String = "signInOrUp" private var currentDismissible: Bool = true + private var logoView: UIView? + private var logoSize: CGSize = .zero + private var logoBoundsObservation: NSKeyValueObservation? private var didSendDismiss = false let onAuthEvent = EventDispatcher() @@ -41,10 +44,66 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { sendDismissIfNeeded() } + override public func layoutSubviews() { + super.layoutSubviews() + guard let logoView else { return } + let newSize = logoView.bounds.size + guard newSize != logoSize else { return } + logoSize = newSize + setNeedsHostedViewUpdate() + } + +#if RCT_NEW_ARCH_ENABLED + override public func mountChildComponentView(_ childComponentView: UIView, index: Int) { + setLogoView(childComponentView) + } + + override public func unmountChildComponentView(_ childComponentView: UIView, index: Int) { + removeLogoView(childComponentView) + } +#else + override public func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + super.insertReactSubview(subview, at: atIndex) + setLogoView(subview) + } + + override public func removeReactSubview(_ subview: UIView!) { + removeLogoView(subview) + super.removeReactSubview(subview) + } +#endif + + private func setLogoView(_ view: UIView) { + guard logoView !== view else { return } + logoBoundsObservation = nil + logoView = view + logoSize = view.bounds.size + logoBoundsObservation = view.observe(\.bounds, options: [.new]) { [weak self, weak view] _, change in + guard let self, let view, let bounds = change.newValue else { return } + DispatchQueue.main.async { [weak self, weak view] in + guard let self, let view, self.logoView === view, self.logoSize != bounds.size else { return } + self.logoSize = bounds.size + self.setNeedsHostedViewUpdate() + } + } + setNeedsHostedViewUpdate() + } + + private func removeLogoView(_ view: UIView) { + guard logoView === view else { return } + logoBoundsObservation = nil + view.removeFromSuperview() + logoView = nil + logoSize = .zero + setNeedsHostedViewUpdate() + } + override func makeHostedController() -> UIViewController? { return ClerkNativeBridge.shared.makeAuthViewController( mode: currentMode, dismissible: currentDismissible, + logoView: logoView, + logoSize: logoSize, onEvent: { [weak self] event, _ in if event == .dismissed { self?.sendDismissIfNeeded() diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index fcbf1c9ecdd..b774669c23e 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -229,6 +229,8 @@ final class ClerkNativeBridge { func makeAuthViewController( mode: String, dismissible: Bool, + logoView: UIView?, + logoSize: CGSize, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { guard Self.clerkConfigured else { return nil } @@ -238,7 +240,9 @@ final class ClerkNativeBridge { mode: Self.authMode(from: mode), dismissible: dismissible, lightTheme: lightTheme, - darkTheme: darkTheme + darkTheme: darkTheme, + logoView: logoView, + logoSize: logoSize ), onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil ) @@ -465,20 +469,31 @@ struct ClerkInlineAuthWrapperView: View { let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? + let logoView: UIView? + let logoSize: CGSize @Environment(\.colorScheme) private var colorScheme - private var themedAuthView: some View { + @ViewBuilder private var themedAuthView: some View { let view = AuthView(mode: mode, isDismissible: dismissible) .environment(Clerk.shared) let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme - return Group { + let themedView = Group { if let theme { view.environment(\.clerkTheme, theme) } else { view } } + + if let logoView { + themedView.clerkAppIconView { + ClerkReactLogoView(view: logoView) + .frame(width: logoSize.width, height: logoSize.height) + } + } else { + themedView + } } var body: some View { @@ -486,6 +501,45 @@ struct ClerkInlineAuthWrapperView: View { } } +private struct ClerkReactLogoView: UIViewRepresentable { + let view: UIView + + func makeUIView(context: Context) -> ClerkReactLogoContainerView { + return ClerkReactLogoContainerView(contentView: view) + } + + func updateUIView(_ uiView: ClerkReactLogoContainerView, context: Context) { + uiView.setContentView(view) + } +} + +private final class ClerkReactLogoContainerView: UIView { + private var contentView: UIView? + + init(contentView: UIView) { + super.init(frame: .zero) + setContentView(contentView) + } + + required init?(coder: NSCoder) { + return nil + } + + func setContentView(_ view: UIView) { + guard contentView !== view else { return } + contentView?.removeFromSuperview() + view.removeFromSuperview() + contentView = view + addSubview(view) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + contentView?.frame = bounds + } +} + private final class ClerkNativeHostingController: UIHostingController { private let onDismiss: (() -> Void)? private var didSendDismiss = false diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 416bcfbdef7..95a41c005fa 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -37,7 +37,7 @@ type AuthNativeEvent = NativeSyntheticEvent>; * * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation */ -export function AuthView({ mode = 'signInOrUp', isDismissible = true, onDismiss }: AuthViewProps): ReactElement { +export function AuthView({ logo, mode = 'signInOrUp', isDismissible = true, onDismiss }: AuthViewProps): ReactElement { const handleAuthEvent = useCallback( (event: AuthNativeEvent) => { if (event.nativeEvent.type === 'dismissed') { @@ -65,6 +65,15 @@ export function AuthView({ mode = 'signInOrUp', isDismissible = true, onDismiss mode={mode} isDismissible={isDismissible} onAuthEvent={handleAuthEvent} - /> + > + {logo ? ( + + {logo} + + ) : null} + ); } diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 70b0c83fca4..67aed3c18a5 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -1,3 +1,5 @@ +import type { ReactElement } from 'react'; + /** * Authentication mode that determines which flows are available to the user. * @@ -15,6 +17,15 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * state changes. */ export interface AuthViewProps { + /** + * Replaces the dashboard-configured logo with custom React Native content. + * + * The native authentication UI does not apply sizing, spacing, or accessibility + * attributes to this content. The provided element must define its own layout + * and accessibility behavior. + */ + logo?: ReactElement; + /** * Authentication mode that determines which flows are available. * diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo/src/native/__tests__/AuthView.test.tsx index 4f7c1582b74..3dbd2b9f575 100644 --- a/packages/expo/src/native/__tests__/AuthView.test.tsx +++ b/packages/expo/src/native/__tests__/AuthView.test.tsx @@ -40,4 +40,13 @@ describe('AuthView', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + + test('passes a custom logo to the native auth view as a single child', () => { + const logo = Custom logo; + + render(); + + const props = mocks.NativeClerkAuthView.mock.calls.at(-1)?.[0]; + expect(props.children.props.children).toBe(logo); + }); }); From 9387c039dcfdac9891526113d33bb47ecca48b87 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 15 Jul 2026 12:59:25 -0700 Subject: [PATCH 2/2] fix(expo): preserve native auth state during logo updates --- packages/expo/ios/ClerkAuthNativeView.swift | 35 ++++++---------- packages/expo/ios/ClerkExpo.podspec | 4 ++ packages/expo/ios/ClerkNativeBridge.swift | 41 +++++++++++++++---- .../Tests/ClerkAuthNativeViewPaperTests.swift | 30 ++++++++++++++ 4 files changed, 79 insertions(+), 31 deletions(-) create mode 100644 packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift index ce0f508ff81..6d3ac2f97cb 100644 --- a/packages/expo/ios/ClerkAuthNativeView.swift +++ b/packages/expo/ios/ClerkAuthNativeView.swift @@ -5,8 +5,7 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { private var currentMode: String = "signInOrUp" private var currentDismissible: Bool = true private var currentLogoMaxHeight: CGFloat? - private var logoView: UIView? - private var logoSize: CGSize = .zero + private let logoState = ClerkInlineAuthLogoState() private var logoBoundsObservation: NSKeyValueObservation? private var didSendDismiss = false @@ -53,11 +52,8 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { override public func layoutSubviews() { super.layoutSubviews() - guard let logoView else { return } - let newSize = logoView.bounds.size - guard newSize != logoSize else { return } - logoSize = newSize - setNeedsHostedViewUpdate() + guard let logoView = logoState.content?.view else { return } + logoState.updateSize(for: logoView) } #if RCT_NEW_ARCH_ENABLED @@ -78,39 +74,34 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { removeLogoView(subview) super.removeReactSubview(subview) } + + override public func didUpdateReactSubviews() {} #endif private func setLogoView(_ view: UIView) { - guard logoView !== view else { return } + guard logoState.content?.view !== view else { return } logoBoundsObservation = nil - logoView = view - logoSize = view.bounds.size - logoBoundsObservation = view.observe(\.bounds, options: [.new]) { [weak self, weak view] _, change in - guard let self, let view, let bounds = change.newValue else { return } + logoState.setView(view) + logoBoundsObservation = view.observe(\.bounds, options: [.new]) { [weak self, weak view] _, _ in DispatchQueue.main.async { [weak self, weak view] in - guard let self, let view, self.logoView === view, self.logoSize != bounds.size else { return } - self.logoSize = bounds.size - self.setNeedsHostedViewUpdate() + guard let self, let view, self.logoState.content?.view === view else { return } + self.logoState.updateSize(for: view) } } - setNeedsHostedViewUpdate() } private func removeLogoView(_ view: UIView) { - guard logoView === view else { return } + guard logoState.content?.view === view else { return } logoBoundsObservation = nil view.removeFromSuperview() - logoView = nil - logoSize = .zero - setNeedsHostedViewUpdate() + logoState.removeView(view) } override func makeHostedController() -> UIViewController? { return ClerkNativeBridge.shared.makeAuthViewController( mode: currentMode, dismissible: currentDismissible, - logoView: logoView, - logoSize: logoSize, + logoState: logoState, logoMaxHeight: currentLogoMaxHeight, onEvent: { [weak self] event, _ in if event == .dismissed { diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec index 4fd66f44569..837457240eb 100644 --- a/packages/expo/ios/ClerkExpo.podspec +++ b/packages/expo/ios/ClerkExpo.podspec @@ -58,5 +58,9 @@ Pod::Spec.new do |s| "ClerkUserProfileNativeView.swift", "ClerkUserButtonNativeView.swift" + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'Tests/**/*.swift' + end + install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 9c40ea73b29..24fc52e1d7f 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -16,6 +16,32 @@ extension Notification.Name { static let clerkNativeSDKDidConfigure = Notification.Name("com.clerk.expo.native-sdk.did-configure") } +@Observable +final class ClerkInlineAuthLogoState { + struct Content { + let view: UIView + let size: CGSize + } + + private(set) var content: Content? + + func setView(_ view: UIView) { + content = Content(view: view, size: view.bounds.size) + } + + func updateSize(for view: UIView) { + guard content?.view === view else { return } + let currentSize = view.bounds.size + guard content?.size != currentSize else { return } + content = Content(view: view, size: currentSize) + } + + func removeView(_ view: UIView) { + guard content?.view === view else { return } + content = nil + } +} + private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)? @@ -229,8 +255,7 @@ final class ClerkNativeBridge { func makeAuthViewController( mode: String, dismissible: Bool, - logoView: UIView?, - logoSize: CGSize, + logoState: ClerkInlineAuthLogoState, logoMaxHeight: CGFloat?, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { @@ -242,8 +267,7 @@ final class ClerkNativeBridge { dismissible: dismissible, lightTheme: lightTheme, darkTheme: darkTheme, - logoView: logoView, - logoSize: logoSize, + logoState: logoState, logoMaxHeight: logoMaxHeight ), onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil @@ -471,8 +495,7 @@ struct ClerkInlineAuthWrapperView: View { let dismissible: Bool let lightTheme: ClerkTheme? let darkTheme: ClerkTheme? - let logoView: UIView? - let logoSize: CGSize + let logoState: ClerkInlineAuthLogoState let logoMaxHeight: CGFloat? @Environment(\.colorScheme) private var colorScheme @@ -489,10 +512,10 @@ struct ClerkInlineAuthWrapperView: View { } } - if let logoView { + if let logo = logoState.content { themedView.clerkAppIconView { - ClerkReactLogoView(view: logoView) - .frame(width: logoSize.width, height: logoSize.height) + ClerkReactLogoView(view: logo.view) + .frame(width: logo.size.width, height: logo.size.height) } } else if let logoMaxHeight { themedView.clerkAppIcon(maxHeight: logoMaxHeight) diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift new file mode 100644 index 00000000000..58d5efc73b5 --- /dev/null +++ b/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift @@ -0,0 +1,30 @@ +#if !RCT_NEW_ARCH_ENABLED +import ExpoModulesCore +import UIKit +import XCTest +import ClerkExpo + +final class ClerkAuthNativeViewPaperTests: XCTestCase { + @MainActor + func testAddingAndRemovingLogoAfterMountKeepsReactChildOutOfHostHierarchy() { + let window = UIWindow(frame: UIScreen.main.bounds) + let viewController = UIViewController() + let authView = ClerkAuthNativeView(appContext: nil) + window.rootViewController = viewController + viewController.view.addSubview(authView) + window.makeKeyAndVisible() + + let logoView = UIView(frame: CGRect(x: 0, y: 0, width: 120, height: 40)) + authView.insertReactSubview(logoView, at: 0) + authView.didUpdateReactSubviews() + + XCTAssertFalse(logoView.superview === authView) + + authView.removeReactSubview(logoView) + authView.didUpdateReactSubviews() + + XCTAssertNil(logoView.superview) + window.isHidden = true + } +} +#endif