Skip to content

Commit e238e49

Browse files
committed
feat(expo): add useUserProfileModal hook, remove callback props, pin iOS SDK
- Add useUserProfileModal() imperative hook for native modal presentation - Remove onDismiss from UserProfileView (now purely inline component) - Remove onPress, onSignOut, style from UserButton (fills parent shape) - Pin clerk-ios SPM dependency to exact version (matches Android approach)
1 parent 44bee1d commit e238e49

5 files changed

Lines changed: 186 additions & 263 deletions

File tree

packages/expo/app.plugin.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ const withClerkIOS = config => {
104104
isa: 'XCRemoteSwiftPackageReference',
105105
repositoryURL: CLERK_IOS_REPO,
106106
requirement: {
107-
kind: 'upToNextMajorVersion',
108-
minimumVersion: CLERK_IOS_VERSION,
107+
kind: 'exactVersion',
108+
version: CLERK_IOS_VERSION,
109109
},
110110
};
111111

@@ -553,10 +553,28 @@ const withClerkGoogleSignIn = config => {
553553
* Native modules are registered via react-native.config.js and standard
554554
* React Native autolinking (RCTViewManager / ReactPackage).
555555
*/
556-
const withClerkExpo = config => {
556+
/**
557+
* Write ClerkKeychainService to Info.plist when keychainService is provided.
558+
* This allows extension apps (watch, widget, app clip) to share the same
559+
* keychain entry as the main app by using a custom service identifier.
560+
*/
561+
const withClerkKeychainService = (config, { keychainService } = {}) => {
562+
if (!keychainService) {
563+
return config;
564+
}
565+
566+
return withInfoPlist(config, modConfig => {
567+
modConfig.modResults.ClerkKeychainService = keychainService;
568+
console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`);
569+
return modConfig;
570+
});
571+
};
572+
573+
const withClerkExpo = (config, props = {}) => {
557574
config = withClerkIOS(config);
558575
config = withClerkGoogleSignIn(config);
559576
config = withClerkAndroid(config);
577+
config = withClerkKeychainService(config, props);
560578
return config;
561579
};
562580

packages/expo/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * from './useOAuth';
1818
export * from './useAuth';
1919
export * from './useNativeSession';
2020
export * from './useNativeAuthEvents';
21+
export * from './useUserProfileModal';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useClerk } from '@clerk/react';
2+
import { useCallback, useRef } from 'react';
3+
import { Platform } from 'react-native';
4+
5+
import NativeClerkModule from '../specs/NativeClerkModule';
6+
7+
// Check if native module is supported on this platform
8+
const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
9+
10+
// Raw result from the native module (may vary by platform)
11+
type NativeSessionResult = {
12+
sessionId?: string;
13+
session?: { id: string };
14+
};
15+
16+
// Safely get the native module
17+
let ClerkExpo: typeof NativeClerkModule | null = null;
18+
if (isNativeSupported) {
19+
try {
20+
ClerkExpo = NativeClerkModule;
21+
} catch {
22+
ClerkExpo = null;
23+
}
24+
}
25+
26+
export interface UseUserProfileModalReturn {
27+
/**
28+
* Present the native user profile modal.
29+
*
30+
* The returned promise resolves when the modal is dismissed.
31+
* If the user signed out from within the profile modal,
32+
* the JS SDK session is automatically cleared.
33+
*/
34+
presentUserProfile: () => Promise<void>;
35+
36+
/**
37+
* Whether the native module supports presenting the profile modal.
38+
*/
39+
isAvailable: boolean;
40+
}
41+
42+
/**
43+
* Imperative hook for presenting the native user profile modal.
44+
*
45+
* Call `presentUserProfile()` from a button's `onPress` to show the native
46+
* profile management screen (SwiftUI on iOS, Jetpack Compose on Android).
47+
* The promise resolves when the modal is dismissed.
48+
*
49+
* Sign-out is detected automatically — if the user signs out from within
50+
* the profile modal, the JS SDK session is cleared so `useAuth()` updates
51+
* reactively.
52+
*
53+
* @example
54+
* ```tsx
55+
* import { useUserProfileModal } from '@clerk/expo';
56+
*
57+
* function MyScreen() {
58+
* const { presentUserProfile } = useUserProfileModal();
59+
*
60+
* return (
61+
* <TouchableOpacity onPress={presentUserProfile}>
62+
* <Text>Manage Profile</Text>
63+
* </TouchableOpacity>
64+
* );
65+
* }
66+
* ```
67+
*/
68+
export function useUserProfileModal(): UseUserProfileModalReturn {
69+
const clerk = useClerk();
70+
const presentingRef = useRef(false);
71+
72+
const presentUserProfile = useCallback(async () => {
73+
if (presentingRef.current) {
74+
return;
75+
}
76+
77+
if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
78+
return;
79+
}
80+
81+
presentingRef.current = true;
82+
try {
83+
await ClerkExpo.presentUserProfile({
84+
dismissable: true,
85+
});
86+
87+
// Check if native session still exists after modal closes
88+
// If session is null, user signed out from the native UI
89+
const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null;
90+
const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id);
91+
92+
if (!hasNativeSession) {
93+
// Clear native session explicitly (may already be cleared, but ensure it)
94+
try {
95+
await ClerkExpo.signOut?.();
96+
} catch {
97+
// May already be signed out
98+
}
99+
100+
// Sign out from JS SDK to update isSignedIn state
101+
if (clerk?.signOut) {
102+
try {
103+
await clerk.signOut();
104+
} catch {
105+
// Best effort
106+
}
107+
}
108+
}
109+
} catch {
110+
// Modal was dismissed by the user — not an error
111+
} finally {
112+
presentingRef.current = false;
113+
}
114+
}, [clerk]);
115+
116+
return {
117+
presentUserProfile,
118+
isAvailable: isNativeSupported && !!ClerkExpo?.presentUserProfile,
119+
};
120+
}

packages/expo/src/native/UserButton.tsx

Lines changed: 21 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useClerk, useUser } from '@clerk/react';
22
import { useEffect, useRef, useState } from 'react';
3-
import type { StyleProp, ViewStyle } from 'react-native';
43
import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
54

65
import NativeClerkModule from '../specs/NativeClerkModule';
@@ -36,65 +35,16 @@ interface NativeUser {
3635
/**
3736
* Props for the UserButton component.
3837
*/
39-
export interface UserButtonProps {
40-
/**
41-
* Custom style for the button container.
42-
*/
43-
style?: StyleProp<ViewStyle>;
44-
45-
/**
46-
* Callback fired when the user button is pressed.
47-
*
48-
* This is called immediately when the button is tapped, before the
49-
* profile modal is presented. Use this for analytics or custom behavior.
50-
*/
51-
onPress?: () => void;
52-
53-
/**
54-
* Callback fired when the user signs out from the profile modal.
55-
*
56-
* This is called after:
57-
* 1. The native session is cleared
58-
* 2. The JS SDK session is cleared
59-
*
60-
* After this callback, `useAuth()` will return `isSignedIn: false`.
61-
*/
62-
onSignOut?: () => void;
63-
}
38+
export interface UserButtonProps {}
6439

6540
/**
6641
* A pre-built native button component that displays the user's avatar and opens their profile.
6742
*
6843
* `UserButton` renders a circular button showing the user's profile image (or initials if
69-
* no image is available). When tapped, it presents the {@link UserProfileView} modal for
70-
* account management.
71-
*
72-
* This component is powered by:
73-
* - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
74-
* - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
75-
*
76-
* ## Features
77-
*
78-
* - **Profile Image**: Displays the user's profile photo from their Clerk account
79-
* - **Initials Fallback**: Shows user's initials when no profile image is set
80-
* - **Profile Modal**: Opens {@link UserProfileView} with full account management
81-
* - **Sign Out Handling**: Properly syncs sign-out between native and JS SDKs
44+
* no image is available). When tapped, it presents the native profile management modal.
8245
*
83-
* ## Avatar Display
84-
*
85-
* The button displays the user's avatar in this order of preference:
86-
* 1. User's profile image from Clerk (if available)
87-
* 2. First letter of first name + first letter of last name
88-
* 3. "U" as a fallback
89-
*
90-
* ## Styling
91-
*
92-
* The button is 36x36 pixels by default with circular border radius.
93-
* You can customize the size using the `style` prop:
94-
*
95-
* ```tsx
96-
* <UserButton style={{ width: 44, height: 44 }} />
97-
* ```
46+
* Sign-out is detected automatically and synced with the JS SDK, causing `useAuth()` to
47+
* update reactively. Use `useAuth()` in a `useEffect` to react to sign-out.
9848
*
9949
* @example Basic usage in a header
10050
* ```tsx
@@ -110,29 +60,26 @@ export interface UserButtonProps {
11060
* }
11161
* ```
11262
*
113-
* @example With sign-out handling
63+
* @example Reacting to sign-out
11464
* ```tsx
115-
* <UserButton
116-
* onSignOut={() => router.replace('/sign-in')}
117-
* style={{ width: 40, height: 40 }}
118-
* />
119-
* ```
65+
* import { UserButton } from '@clerk/expo/native';
66+
* import { useAuth } from '@clerk/expo';
12067
*
121-
* @example With press tracking
122-
* ```tsx
123-
* <UserButton
124-
* onPress={() => analytics.track('profile_opened')}
125-
* onSignOut={() => {
126-
* analytics.track('signed_out');
127-
* router.replace('/sign-in');
128-
* }}
129-
* />
68+
* export default function Header() {
69+
* const { isSignedIn } = useAuth();
70+
*
71+
* useEffect(() => {
72+
* if (!isSignedIn) router.replace('/sign-in');
73+
* }, [isSignedIn]);
74+
*
75+
* return <UserButton style={{ width: 40, height: 40 }} />;
76+
* }
13077
* ```
13178
*
13279
* @see {@link UserProfileView} The profile view that opens when tapped
13380
* @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation
13481
*/
135-
export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
82+
export function UserButton(_props: UserButtonProps) {
13683
const [nativeUser, setNativeUser] = useState<NativeUser | null>(null);
13784
const presentingRef = useRef(false);
13885
const clerk = useClerk();
@@ -181,8 +128,6 @@ export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
181128
return;
182129
}
183130

184-
onPress?.();
185-
186131
if (!isNativeSupported || !ClerkExpo?.presentUserProfile) {
187132
return;
188133
}
@@ -225,8 +170,6 @@ export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
225170
}
226171
}
227172
}
228-
229-
onSignOut?.();
230173
}
231174
} catch {
232175
// Modal was dismissed by the user — not an error
@@ -248,7 +191,7 @@ export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
248191
// Show fallback when native modules aren't available
249192
if (!isNativeSupported || !ClerkExpo) {
250193
return (
251-
<View style={[styles.button, style]}>
194+
<View style={styles.button}>
252195
<Text style={styles.text}>?</Text>
253196
</View>
254197
);
@@ -257,7 +200,7 @@ export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
257200
return (
258201
<TouchableOpacity
259202
onPress={handlePress}
260-
style={[styles.button, style]}
203+
style={styles.button}
261204
>
262205
{user?.imageUrl ? (
263206
<Image
@@ -275,9 +218,8 @@ export function UserButton({ onPress, onSignOut, style }: UserButtonProps) {
275218

276219
const styles = StyleSheet.create({
277220
button: {
278-
width: 36,
279-
height: 36,
280-
borderRadius: 18,
221+
width: '100%',
222+
height: '100%',
281223
overflow: 'hidden',
282224
},
283225
avatar: {
@@ -289,7 +231,6 @@ const styles = StyleSheet.create({
289231
avatarImage: {
290232
width: '100%',
291233
height: '100%',
292-
borderRadius: 18,
293234
},
294235
avatarText: {
295236
color: 'white',

0 commit comments

Comments
 (0)