From 3f2f058e0ffacb5f0d043df70e195e3b852932b2 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 6 Apr 2026 13:03:01 -0700 Subject: [PATCH 01/12] feat(expo): add native component theming via Expo config plugin Adds support for customizing native Clerk UI components (sign-in, sign-up, user profile) on both iOS and Android via a JSON theme configuration file referenced in the Expo plugin config: ["@clerk/expo", { "theme": "./clerk-theme.json" }] The JSON schema supports: - colors: 15 semantic color tokens (primary, background, danger, etc.) - darkColors: dark mode color overrides (iOS uses @Environment colorScheme, Android uses ClerkTheme.darkColors) - fonts: fontFamily string or per-style overrides (iOS only) - design: borderRadius Plugin changes: - Reads and validates the JSON at prebuild time - iOS: Embeds theme in Info.plist; removes UIUserInterfaceStyle when darkColors is present to enable system dark mode - Android: Copies JSON to app assets directory Native changes: - iOS: Parses theme from Info.plist, builds light/dark ClerkTheme objects, applies via .environment(\.clerkTheme) with colorScheme switching - Android: Parses theme from assets JSON, sets Clerk.customTheme - Both: AuthView now uses Clerk.customTheme instead of null --- .../expo/modules/clerk/ClerkAuthActivity.kt | 2 +- .../expo/modules/clerk/ClerkAuthExpoView.kt | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 80 +++++++++ packages/expo/app.plugin.js | 115 ++++++++++++ packages/expo/ios/ClerkViewFactory.swift | 165 +++++++++++++++++- 5 files changed, 353 insertions(+), 11 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt index acd934830de..1c8049adba6 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt @@ -275,7 +275,7 @@ class ClerkAuthActivity : ComponentActivity() { // Client is ready, show AuthView AuthView( modifier = Modifier.fillMaxSize(), - clerkTheme = null // Use default theme, or pass custom + clerkTheme = Clerk.customTheme ) } else -> { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt index 60280542e27..a479e205085 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -105,7 +105,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { ) { AuthView( modifier = Modifier.fillMaxSize(), - clerkTheme = null + clerkTheme = Clerk.customTheme ) } } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index e4d15f6a963..42ffea6feae 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -4,8 +4,13 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.clerk.api.Clerk import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.ui.ClerkColors +import com.clerk.api.ui.ClerkDesign +import com.clerk.api.ui.ClerkTheme import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -18,6 +23,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import org.json.JSONObject private const val TAG = "ClerkExpoModule" @@ -78,6 +84,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : .apply() } + loadThemeFromAssets() Clerk.initialize(reactApplicationContext, pubKey) // Wait for initialization to complete with timeout @@ -367,4 +374,77 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : promise.resolve(result) } + + // MARK: - Theme Loading + + private fun loadThemeFromAssets() { + try { + val jsonString = reactApplicationContext.assets + .open("clerk_theme.json") + .bufferedReader() + .use { it.readText() } + val json = JSONObject(jsonString) + Clerk.customTheme = parseClerkTheme(json) + } catch (e: java.io.FileNotFoundException) { + // No theme file provided — use defaults + } catch (e: Exception) { + debugLog(TAG, "Failed to load clerk_theme.json: ${e.message}") + } + } + + private fun parseClerkTheme(json: JSONObject): ClerkTheme { + val colors = json.optJSONObject("colors")?.let { parseColors(it) } + val darkColors = json.optJSONObject("darkColors")?.let { parseColors(it) } + val design = json.optJSONObject("design")?.let { parseDesign(it) } + return ClerkTheme( + colors = colors, + darkColors = darkColors, + design = design + ) + } + + private fun parseColors(json: JSONObject): ClerkColors { + return ClerkColors( + primary = json.optStringColor("primary"), + background = json.optStringColor("background"), + input = json.optStringColor("input"), + danger = json.optStringColor("danger"), + success = json.optStringColor("success"), + warning = json.optStringColor("warning"), + foreground = json.optStringColor("foreground"), + mutedForeground = json.optStringColor("mutedForeground"), + primaryForeground = json.optStringColor("primaryForeground"), + inputForeground = json.optStringColor("inputForeground"), + neutral = json.optStringColor("neutral"), + border = json.optStringColor("border"), + ring = json.optStringColor("ring"), + muted = json.optStringColor("muted"), + shadow = json.optStringColor("shadow") + ) + } + + private fun parseDesign(json: JSONObject): ClerkDesign { + return ClerkDesign( + fontFamily = json.optString("fontFamily", null), + borderRadius = if (json.has("borderRadius")) json.getDouble("borderRadius").dp else null + ) + } + + private fun parseHexColor(hex: String): Color? { + val cleaned = hex.removePrefix("#") + return try { + when (cleaned.length) { + 6 -> Color(android.graphics.Color.parseColor("#FF$cleaned")) + 8 -> Color(android.graphics.Color.parseColor("#$cleaned")) + else -> null + } + } catch (e: Exception) { + null + } + } + + private fun JSONObject.optStringColor(key: String): Color? { + val value = optString(key, null) ?: return null + return parseHexColor(value) + } } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 758e80b5692..97a8b5a5ea1 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -585,6 +585,120 @@ const withClerkAppleSignIn = config => { }); }; +/** + * Apply a custom theme to Clerk native components (iOS + Android). + * + * Accepts a `theme` prop pointing to a JSON file with optional keys: + * - colors: { primary, background, input, danger, success, warning, + * foreground, mutedForeground, primaryForeground, inputForeground, + * neutral, border, ring, muted, shadow } (hex color strings) + * - darkColors: same keys as colors (for dark mode) + * - design: { fontFamily: string, borderRadius: number } + * + * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". + * When darkColors is present, removes UIUserInterfaceStyle to allow + * system dark mode. + * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. + */ +const VALID_COLOR_KEYS = [ + 'primary', + 'background', + 'input', + 'danger', + 'success', + 'warning', + 'foreground', + 'mutedForeground', + 'primaryForeground', + 'inputForeground', + 'neutral', + 'border', + 'ring', + 'muted', + 'shadow', +]; + +const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; + +function validateThemeJson(theme) { + const validateColors = (colors, label) => { + if (!colors || typeof colors !== 'object') return; + for (const [key, value] of Object.entries(colors)) { + if (!VALID_COLOR_KEYS.includes(key)) { + console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); + continue; + } + if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { + throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); + } + } + }; + + if (theme.colors) validateColors(theme.colors, 'colors'); + if (theme.darkColors) validateColors(theme.darkColors, 'darkColors'); + + if (theme.design) { + if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { + throw new Error(`Clerk theme: design.fontFamily must be a string`); + } + if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { + throw new Error(`Clerk theme: design.borderRadius must be a number`); + } + } +} + +const withClerkTheme = (config, props = {}) => { + const { theme } = props; + if (!theme) return config; + + // Resolve the theme file path relative to the project root + const themePath = path.resolve(theme); + if (!fs.existsSync(themePath)) { + console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`); + return config; + } + + let themeJson; + try { + themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8')); + validateThemeJson(themeJson); + } catch (e) { + throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`); + } + + // iOS: Embed theme in Info.plist under "ClerkTheme" + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkTheme = themeJson; + console.log('✅ Embedded Clerk theme in Info.plist'); + + // When darkColors is provided, remove UIUserInterfaceStyle to allow + // the system to switch between light and dark mode automatically. + if (themeJson.darkColors) { + delete modConfig.modResults.UIUserInterfaceStyle; + console.log('✅ Removed UIUserInterfaceStyle to enable system dark mode'); + } + + return modConfig; + }); + + // Android: Copy theme JSON to assets + config = withDangerousMod(config, [ + 'android', + async config => { + const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets'); + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + const destPath = path.join(assetsDir, 'clerk_theme.json'); + fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n'); + console.log('✅ Copied Clerk theme to Android assets'); + return config; + }, + ]); + + return config; +}; + const withClerkExpo = (config, props = {}) => { const { appleSignIn = true } = props; config = withClerkIOS(config); @@ -594,6 +708,7 @@ const withClerkExpo = (config, props = {}) => { config = withClerkGoogleSignIn(config); config = withClerkAndroid(config); config = withClerkKeychainService(config, props); + config = withClerkTheme(config, props); return config; }; diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 38b64c29edb..f46d0134dcd 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -18,6 +18,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false + /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. + var lightTheme: ClerkTheme? + var darkTheme: ClerkTheme? + private enum KeychainKey { static let jsClientJWT = "__clerk_client_jwt" static let nativeDeviceToken = "clerkDeviceToken" @@ -43,6 +47,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { // Register this factory with the ClerkExpo module public static func register() { + shared.loadThemes() clerkViewFactory = shared } @@ -152,6 +157,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { let wrapper = ClerkAuthWrapperViewController( mode: Self.authMode(from: mode), dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, completion: completion ) return wrapper @@ -163,6 +170,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { ) -> UIViewController? { let wrapper = ClerkProfileWrapperViewController( dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, completion: completion ) return wrapper @@ -179,6 +188,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, onEvent: onEvent ) ) @@ -191,6 +202,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, onEvent: onEvent ) ) @@ -226,6 +239,91 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } } + // MARK: - Theme Parsing + + /// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes. + func loadThemes() { + guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else { + return + } + + // Build light theme from top-level "colors" and "design" + let lightColors = (themeDictionary["colors"] as? [String: String]).flatMap { parseColors(from: $0) } + let design = (themeDictionary["design"] as? [String: Any]).flatMap { parseDesign(from: $0) } + let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) } + + if lightColors != nil || design != nil || fonts != nil { + lightTheme = ClerkTheme(colors: lightColors, design: design, fonts: fonts) + } + + // Build dark theme from "darkColors" (inherits same design/fonts) + if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] { + let darkColors = parseColors(from: darkColorsDict) + if darkColors != nil || design != nil || fonts != nil { + darkTheme = ClerkTheme(colors: darkColors, design: design, fonts: fonts) + } + } + } + + private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? { + var hasAny = false + var colors = ClerkTheme.Colors() + + if let v = dict["primary"].flatMap({ colorFromHex($0) }) { colors.primary = v; hasAny = true } + if let v = dict["background"].flatMap({ colorFromHex($0) }) { colors.background = v; hasAny = true } + if let v = dict["input"].flatMap({ colorFromHex($0) }) { colors.input = v; hasAny = true } + if let v = dict["danger"].flatMap({ colorFromHex($0) }) { colors.danger = v; hasAny = true } + if let v = dict["success"].flatMap({ colorFromHex($0) }) { colors.success = v; hasAny = true } + if let v = dict["warning"].flatMap({ colorFromHex($0) }) { colors.warning = v; hasAny = true } + if let v = dict["foreground"].flatMap({ colorFromHex($0) }) { colors.foreground = v; hasAny = true } + if let v = dict["mutedForeground"].flatMap({ colorFromHex($0) }) { colors.mutedForeground = v; hasAny = true } + if let v = dict["primaryForeground"].flatMap({ colorFromHex($0) }) { colors.primaryForeground = v; hasAny = true } + if let v = dict["inputForeground"].flatMap({ colorFromHex($0) }) { colors.inputForeground = v; hasAny = true } + if let v = dict["neutral"].flatMap({ colorFromHex($0) }) { colors.neutral = v; hasAny = true } + if let v = dict["border"].flatMap({ colorFromHex($0) }) { colors.border = v; hasAny = true } + if let v = dict["ring"].flatMap({ colorFromHex($0) }) { colors.ring = v; hasAny = true } + if let v = dict["muted"].flatMap({ colorFromHex($0) }) { colors.muted = v; hasAny = true } + if let v = dict["shadow"].flatMap({ colorFromHex($0) }) { colors.shadow = v; hasAny = true } + + return hasAny ? colors : nil + } + + private func colorFromHex(_ hex: String) -> Color? { + var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.hasPrefix("#") { cleaned.removeFirst() } + + var rgb: UInt64 = 0 + guard Scanner(string: cleaned).scanHexInt64(&rgb) else { return nil } + + switch cleaned.count { + case 6: + return Color( + red: Double((rgb >> 16) & 0xFF) / 255.0, + green: Double((rgb >> 8) & 0xFF) / 255.0, + blue: Double(rgb & 0xFF) / 255.0 + ) + case 8: + return Color( + red: Double((rgb >> 24) & 0xFF) / 255.0, + green: Double((rgb >> 16) & 0xFF) / 255.0, + blue: Double((rgb >> 8) & 0xFF) / 255.0, + opacity: Double(rgb & 0xFF) / 255.0 + ) + default: + return nil + } + } + + private func parseFonts(from dict: [String: Any]) -> ClerkTheme.Fonts? { + guard let fontFamily = dict["fontFamily"] as? String, !fontFamily.isEmpty else { return nil } + return ClerkTheme.Fonts(fontFamily: fontFamily) + } + + private func parseDesign(from dict: [String: Any]) -> ClerkTheme.Design? { + guard let radius = dict["borderRadius"] as? Double else { return nil } + return ClerkTheme.Design(borderRadius: CGFloat(radius)) + } + private func makeHostingController(rootView: Content) -> UIViewController { let hostingController = UIHostingController(rootView: rootView) hostingController.view.backgroundColor = .clear @@ -329,9 +427,9 @@ class ClerkAuthWrapperViewController: UIHostingController private var authEventTask: Task? private var completionCalled = false - init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { + init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable) + let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) super.init(rootView: view) self.modalPresentationStyle = .fullScreen subscribeToAuthEvents() @@ -393,10 +491,20 @@ class ClerkAuthWrapperViewController: UIHostingController struct ClerkAuthWrapperView: View { let mode: AuthView.Mode let dismissable: Bool + let lightTheme: ClerkTheme? + let darkTheme: ClerkTheme? + + @Environment(\.colorScheme) private var colorScheme var body: some View { - AuthView(mode: mode, isDismissable: dismissable) + let view = AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } } } @@ -407,9 +515,9 @@ class ClerkProfileWrapperViewController: UIHostingController? private var completionCalled = false - init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { + init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable) + let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) super.init(rootView: view) self.modalPresentationStyle = .fullScreen subscribeToAuthEvents() @@ -454,10 +562,20 @@ class ClerkProfileWrapperViewController: UIHostingController Void // Track initial session to detect new sign-ins (same approach as Android) @State private var initialSessionId: String? = Clerk.shared.session?.id @State private var eventSent = false + @Environment(\.colorScheme) private var colorScheme + private func sendAuthCompleted(sessionId: String, type: String) { guard !eventSent, sessionId != initialSessionId else { return } eventSent = true onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) } - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) + private var themedAuthView: some View { + let view = AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + return Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } + } + } + + var body: some View { + themedAuthView // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. .onChange(of: Clerk.shared.session?.id) { _, newSessionId in @@ -512,11 +646,24 @@ struct ClerkInlineAuthWrapperView: View { struct ClerkInlineProfileWrapperView: View { let dismissable: Bool + let lightTheme: ClerkTheme? + let darkTheme: ClerkTheme? let onEvent: (String, [String: Any]) -> Void + @Environment(\.colorScheme) private var colorScheme + var body: some View { - UserProfileView(isDismissable: dismissable) + let view = UserProfileView(isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + let themedView = Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } + } + themedView .task { for await event in Clerk.shared.auth.events { switch event { From 221e30c298bc5cf028c490058b12a340f5a239a5 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 7 Apr 2026 18:23:58 -0700 Subject: [PATCH 02/12] fix(expo): apply Android theme after Clerk.initialize and use real ClerkDesign signature Two fixes needed to make the Android theme actually take effect: 1. Call loadThemeFromAssets() AFTER Clerk.initialize() instead of before. Clerk.initialize() accepts a `theme` parameter that defaults to null and assigns it to Clerk.customTheme on every call, which was wiping out the theme we just loaded. 2. Use the real ClerkDesign(borderRadius: Dp) constructor signature. The previous code passed nonexistent fontFamily and nullable borderRadius parameters that don't compile against clerk-android-ui. --- .../main/java/expo/modules/clerk/ClerkExpoModule.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 42ffea6feae..c93a70462ac 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -84,8 +84,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : .apply() } - loadThemeFromAssets() Clerk.initialize(reactApplicationContext, pubKey) + // Must be set AFTER Clerk.initialize() because initialize() + // resets customTheme to its `theme` parameter (default null). + loadThemeFromAssets() // Wait for initialization to complete with timeout try { @@ -424,10 +426,11 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun parseDesign(json: JSONObject): ClerkDesign { - return ClerkDesign( - fontFamily = json.optString("fontFamily", null), - borderRadius = if (json.has("borderRadius")) json.getDouble("borderRadius").dp else null - ) + return if (json.has("borderRadius")) { + ClerkDesign(borderRadius = json.getDouble("borderRadius").toFloat().dp) + } else { + ClerkDesign() + } } private fun parseHexColor(hex: String): Color? { From b5af733345d4cc4be9d2a378b22a596135e5887d Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Wed, 8 Apr 2026 10:28:13 -0700 Subject: [PATCH 03/12] chore(expo): add changeset for native component theming --- .changeset/expo-native-component-theming.md | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/expo-native-component-theming.md diff --git a/.changeset/expo-native-component-theming.md b/.changeset/expo-native-component-theming.md new file mode 100644 index 00000000000..bbb82c15fa4 --- /dev/null +++ b/.changeset/expo-native-component-theming.md @@ -0,0 +1,24 @@ +--- +'@clerk/expo': minor +--- + +Add native component theming via the Expo config plugin. You can now customize the appearance of Clerk's native components (``, ``, ``) on iOS and Android by passing a `theme` prop to the plugin pointing at a JSON file: + +```json +{ + "expo": { + "plugins": [ + ["@clerk/expo", { "theme": "./clerk-theme.json" }] + ] + } +} +``` + +The JSON theme supports: + +- `colors` — 15 semantic color tokens (`primary`, `background`, `input`, `danger`, `success`, `warning`, `foreground`, `mutedForeground`, `primaryForeground`, `inputForeground`, `neutral`, `border`, `ring`, `muted`, `shadow`) as 6- or 8-digit hex strings. +- `darkColors` — same shape as `colors`; applied automatically when the system is in dark mode. +- `design.borderRadius` — number, applied to both platforms. +- `design.fontFamily` — string, **iOS only**. + +Theme JSON is validated at prebuild. On iOS the theme is embedded into `Info.plist` (and `UIUserInterfaceStyle` is removed when `darkColors` is present, so the system can switch modes). On Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. From 19bc65a8f51ce0238c7a9cedb3182921fa806f83 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 10 Apr 2026 13:45:23 -0700 Subject: [PATCH 04/12] fix(expo): reject 3-digit hex in theme validation and fix Android 8-digit hex ordering - Remove 3-digit hex (#RGB) from the prebuild validation regex. Both native parsers only handle 6- or 8-digit hex, so 3-digit values would pass validation but be silently ignored at runtime. - Convert RRGGBBAA to AARRGGBB on Android before passing to parseColor. iOS already parses 8-digit hex as RRGGBBAA, so without this conversion the same color token could render differently on each platform. --- .../src/main/java/expo/modules/clerk/ClerkExpoModule.kt | 7 ++++++- packages/expo/app.plugin.js | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index c93a70462ac..20a1307b37b 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -438,7 +438,12 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : return try { when (cleaned.length) { 6 -> Color(android.graphics.Color.parseColor("#FF$cleaned")) - 8 -> Color(android.graphics.Color.parseColor("#$cleaned")) + // Theme JSON uses RRGGBBAA; Android parseColor expects AARRGGBB + 8 -> { + val rrggbb = cleaned.substring(0, 6) + val aa = cleaned.substring(6, 8) + Color(android.graphics.Color.parseColor("#$aa$rrggbb")) + } else -> null } } catch (e: Exception) { diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 97a8b5a5ea1..f06ccc48313 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -618,7 +618,7 @@ const VALID_COLOR_KEYS = [ 'shadow', ]; -const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; +const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; function validateThemeJson(theme) { const validateColors = (colors, label) => { From 3c703efb8cbecf3e6ff5648d682e8f99a1dc81a7 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 13 Apr 2026 11:19:10 -0700 Subject: [PATCH 05/12] fix(expo): stop removing UIUserInterfaceStyle when darkColors is present The plugin was deleting UIUserInterfaceStyle from Info.plist whenever the theme JSON included darkColors, overriding the developer's app-wide userInterfaceStyle setting from app.json. This broke apps that intentionally pin to light or dark mode. The plugin now only embeds the theme data. Dark mode switching is controlled by the developer via Expo's existing userInterfaceStyle config. --- packages/expo/app.plugin.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index f06ccc48313..605a2181e09 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -670,14 +670,6 @@ const withClerkTheme = (config, props = {}) => { config = withInfoPlist(config, modConfig => { modConfig.modResults.ClerkTheme = themeJson; console.log('✅ Embedded Clerk theme in Info.plist'); - - // When darkColors is provided, remove UIUserInterfaceStyle to allow - // the system to switch between light and dark mode automatically. - if (themeJson.darkColors) { - delete modConfig.modResults.UIUserInterfaceStyle; - console.log('✅ Removed UIUserInterfaceStyle to enable system dark mode'); - } - return modConfig; }); From 665952e890ffeaf2af514556f95cddd49cd57f65 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 13 Apr 2026 11:19:59 -0700 Subject: [PATCH 06/12] chore(expo): update changeset to reflect UIUserInterfaceStyle change --- .changeset/expo-native-component-theming.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/expo-native-component-theming.md b/.changeset/expo-native-component-theming.md index bbb82c15fa4..e2b4bc311b1 100644 --- a/.changeset/expo-native-component-theming.md +++ b/.changeset/expo-native-component-theming.md @@ -21,4 +21,4 @@ The JSON theme supports: - `design.borderRadius` — number, applied to both platforms. - `design.fontFamily` — string, **iOS only**. -Theme JSON is validated at prebuild. On iOS the theme is embedded into `Info.plist` (and `UIUserInterfaceStyle` is removed when `darkColors` is present, so the system can switch modes). On Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. +Theme JSON is validated at prebuild. On iOS the theme is embedded into `Info.plist`; on Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. The plugin does not modify your app's `userInterfaceStyle` setting — control light/dark mode via `"userInterfaceStyle"` in `app.json`. From 64521d0805a95871ab958ff27fc5c0db4a5c35c6 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 13 Apr 2026 12:45:26 -0700 Subject: [PATCH 07/12] fix(expo): fix iOS theme parsing for clerk-ios 1.0.0 API - Use ClerkTheme init with non-optional params (nil-coalesce to .default) - Reorder arguments: fonts before design to match ClerkTheme.init signature - Mark register() and loadThemes() as @MainActor (ClerkTheme is @MainActor) - Construct Colors via init instead of mutating after creation, so derived tokens (primaryPressed, border opacity, etc.) are calculated correctly --- packages/expo/ios/ClerkViewFactory.swift | 48 ++++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index f46d0134dcd..d30bb5f4ea7 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -46,7 +46,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } // Register this factory with the ClerkExpo module - public static func register() { + @MainActor public static func register() { shared.loadThemes() clerkViewFactory = shared } @@ -242,7 +242,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { // MARK: - Theme Parsing /// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes. - func loadThemes() { + @MainActor func loadThemes() { guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else { return } @@ -253,39 +253,39 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) } if lightColors != nil || design != nil || fonts != nil { - lightTheme = ClerkTheme(colors: lightColors, design: design, fonts: fonts) + lightTheme = ClerkTheme(colors: lightColors ?? .default, fonts: fonts ?? .default, design: design ?? .default) } // Build dark theme from "darkColors" (inherits same design/fonts) if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] { let darkColors = parseColors(from: darkColorsDict) if darkColors != nil || design != nil || fonts != nil { - darkTheme = ClerkTheme(colors: darkColors, design: design, fonts: fonts) + darkTheme = ClerkTheme(colors: darkColors ?? .default, fonts: fonts ?? .default, design: design ?? .default) } } } private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? { - var hasAny = false - var colors = ClerkTheme.Colors() - - if let v = dict["primary"].flatMap({ colorFromHex($0) }) { colors.primary = v; hasAny = true } - if let v = dict["background"].flatMap({ colorFromHex($0) }) { colors.background = v; hasAny = true } - if let v = dict["input"].flatMap({ colorFromHex($0) }) { colors.input = v; hasAny = true } - if let v = dict["danger"].flatMap({ colorFromHex($0) }) { colors.danger = v; hasAny = true } - if let v = dict["success"].flatMap({ colorFromHex($0) }) { colors.success = v; hasAny = true } - if let v = dict["warning"].flatMap({ colorFromHex($0) }) { colors.warning = v; hasAny = true } - if let v = dict["foreground"].flatMap({ colorFromHex($0) }) { colors.foreground = v; hasAny = true } - if let v = dict["mutedForeground"].flatMap({ colorFromHex($0) }) { colors.mutedForeground = v; hasAny = true } - if let v = dict["primaryForeground"].flatMap({ colorFromHex($0) }) { colors.primaryForeground = v; hasAny = true } - if let v = dict["inputForeground"].flatMap({ colorFromHex($0) }) { colors.inputForeground = v; hasAny = true } - if let v = dict["neutral"].flatMap({ colorFromHex($0) }) { colors.neutral = v; hasAny = true } - if let v = dict["border"].flatMap({ colorFromHex($0) }) { colors.border = v; hasAny = true } - if let v = dict["ring"].flatMap({ colorFromHex($0) }) { colors.ring = v; hasAny = true } - if let v = dict["muted"].flatMap({ colorFromHex($0) }) { colors.muted = v; hasAny = true } - if let v = dict["shadow"].flatMap({ colorFromHex($0) }) { colors.shadow = v; hasAny = true } - - return hasAny ? colors : nil + let hasAny = dict.values.contains { colorFromHex($0) != nil } + guard hasAny else { return nil } + + return ClerkTheme.Colors( + primary: dict["primary"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryColor, + background: dict["background"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBackgroundColor, + input: dict["input"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputColor, + danger: dict["danger"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultDangerColor, + success: dict["success"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultSuccessColor, + warning: dict["warning"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultWarningColor, + foreground: dict["foreground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultForegroundColor, + mutedForeground: dict["mutedForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedForegroundColor, + primaryForeground: dict["primaryForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryForegroundColor, + inputForeground: dict["inputForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputForegroundColor, + neutral: dict["neutral"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultNeutralColor, + ring: dict["ring"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultRingColor, + muted: dict["muted"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedColor, + shadow: dict["shadow"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultShadowColor, + border: dict["border"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBorderColor + ) } private func colorFromHex(_ hex: String) -> Color? { From 2490b3507d2d0376772dfdbbc9b3864418f8f388 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Mon, 13 Apr 2026 15:07:14 -0700 Subject: [PATCH 08/12] fix(expo): strict object validation in theme JSON and add tests - validateThemeJson now throws when colors, darkColors, or design is present but not a plain object (string, array, number all rejected). - null values are treated as absent (not an error). - Add vitest tests covering valid themes, shape validation, hex format rejection (3-digit, no hash), unknown key warnings, and design field types. --- packages/expo/app.plugin.js | 17 +- .../src/__tests__/appPlugin.theme.test.js | 156 ++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 packages/expo/src/__tests__/appPlugin.theme.test.js diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 605a2181e09..0fb05026c5b 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -620,9 +620,15 @@ const VALID_COLOR_KEYS = [ const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function validateThemeJson(theme) { const validateColors = (colors, label) => { - if (!colors || typeof colors !== 'object') return; + if (!isPlainObject(colors)) { + throw new Error(`Clerk theme: ${label} must be an object`); + } for (const [key, value] of Object.entries(colors)) { if (!VALID_COLOR_KEYS.includes(key)) { console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); @@ -634,10 +640,13 @@ function validateThemeJson(theme) { } }; - if (theme.colors) validateColors(theme.colors, 'colors'); - if (theme.darkColors) validateColors(theme.darkColors, 'darkColors'); + if (theme.colors != null) validateColors(theme.colors, 'colors'); + if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); - if (theme.design) { + if (theme.design != null) { + if (!isPlainObject(theme.design)) { + throw new Error(`Clerk theme: design must be an object`); + } if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { throw new Error(`Clerk theme: design.fontFamily must be a string`); } diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js new file mode 100644 index 00000000000..5686fa60953 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// The plugin is plain CJS — load the validation function by evaluating +// just the relevant pieces. We re-declare the constants and function +// exactly as they appear in app.plugin.js so the test stays in sync. +// ------------------------------------------------------------------ + +const VALID_COLOR_KEYS = [ + 'primary', + 'background', + 'input', + 'danger', + 'success', + 'warning', + 'foreground', + 'mutedForeground', + 'primaryForeground', + 'inputForeground', + 'neutral', + 'border', + 'ring', + 'muted', + 'shadow', +]; + +const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateThemeJson(theme) { + const validateColors = (colors, label) => { + if (!isPlainObject(colors)) { + throw new Error(`Clerk theme: ${label} must be an object`); + } + for (const [key, value] of Object.entries(colors)) { + if (!VALID_COLOR_KEYS.includes(key)) { + console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); + continue; + } + if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { + throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); + } + } + }; + + if (theme.colors != null) validateColors(theme.colors, 'colors'); + if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); + + if (theme.design != null) { + if (!isPlainObject(theme.design)) { + throw new Error(`Clerk theme: design must be an object`); + } + if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { + throw new Error(`Clerk theme: design.fontFamily must be a string`); + } + if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { + throw new Error(`Clerk theme: design.borderRadius must be a number`); + } + } +} + +// ------------------------------------------------------------------ + +describe('validateThemeJson', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('accepts a valid full theme', () => { + expect(() => + validateThemeJson({ + colors: { primary: '#6C47FF', background: '#FFFFFF' }, + darkColors: { primary: '#8B6FFF' }, + design: { borderRadius: 12, fontFamily: 'Inter' }, + }), + ).not.toThrow(); + }); + + test('accepts an empty theme (no keys)', () => { + expect(() => validateThemeJson({})).not.toThrow(); + }); + + test('accepts theme with only design', () => { + expect(() => validateThemeJson({ design: { borderRadius: 8 } })).not.toThrow(); + }); + + // --- colors / darkColors shape validation --- + + test('throws when colors is a string', () => { + expect(() => validateThemeJson({ colors: 'red' })).toThrow('colors must be an object'); + }); + + test('throws when colors is an array', () => { + expect(() => validateThemeJson({ colors: ['#FF0000'] })).toThrow('colors must be an object'); + }); + + test('accepts colors: null (treated as absent)', () => { + expect(() => validateThemeJson({ colors: null })).not.toThrow(); + }); + + test('throws when darkColors is a number', () => { + expect(() => validateThemeJson({ darkColors: 42 })).toThrow('darkColors must be an object'); + }); + + // --- design shape validation --- + + test('throws when design is a string', () => { + expect(() => validateThemeJson({ design: 'round' })).toThrow('design must be an object'); + }); + + test('throws when design is an array', () => { + expect(() => validateThemeJson({ design: [12] })).toThrow('design must be an object'); + }); + + test('accepts design: null (treated as absent)', () => { + expect(() => validateThemeJson({ design: null })).not.toThrow(); + }); + + // --- hex color validation --- + + test('throws for invalid hex color (no hash)', () => { + expect(() => validateThemeJson({ colors: { primary: 'FF0000' } })).toThrow('invalid hex color'); + }); + + test('throws for 3-digit hex color', () => { + expect(() => validateThemeJson({ colors: { primary: '#FFF' } })).toThrow('invalid hex color'); + }); + + test('accepts 6-digit hex', () => { + expect(() => validateThemeJson({ colors: { primary: '#FF00AA' } })).not.toThrow(); + }); + + test('accepts 8-digit hex (with alpha)', () => { + expect(() => validateThemeJson({ colors: { shadow: '#00000080' } })).not.toThrow(); + }); + + // --- unknown keys --- + + test('warns on unknown color keys but does not throw', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(() => validateThemeJson({ colors: { customColor: '#FF0000' } })).not.toThrow(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unknown color key "customColor"')); + }); + + // --- design field types --- + + test('throws when fontFamily is a number', () => { + expect(() => validateThemeJson({ design: { fontFamily: 42 } })).toThrow('design.fontFamily must be a string'); + }); + + test('throws when borderRadius is a string', () => { + expect(() => validateThemeJson({ design: { borderRadius: '12' } })).toThrow('design.borderRadius must be a number'); + }); +}); From 578ac313926ef58887e92eeae4c080d81754d817 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Tue, 14 Apr 2026 11:37:59 -0500 Subject: [PATCH 09/12] fix(expo): add top-level object validation and remove stale JSDoc Add isPlainObject guard at the top of validateThemeJson to reject null, arrays, and non-object values that would crash or silently pass validation. Remove stale JSDoc comment about UIUserInterfaceStyle removal which no longer matches the implementation. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/expo/app.plugin.js | 6 ++++-- .../expo/src/__tests__/appPlugin.theme.test.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 0fb05026c5b..bdae5a88a65 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -596,8 +596,6 @@ const withClerkAppleSignIn = config => { * - design: { fontFamily: string, borderRadius: number } * * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". - * When darkColors is present, removes UIUserInterfaceStyle to allow - * system dark mode. * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. */ const VALID_COLOR_KEYS = [ @@ -625,6 +623,10 @@ function isPlainObject(value) { } function validateThemeJson(theme) { + if (!isPlainObject(theme)) { + throw new Error('Clerk theme: theme JSON must be a plain object'); + } + const validateColors = (colors, label) => { if (!isPlainObject(colors)) { throw new Error(`Clerk theme: ${label} must be an object`); diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js index 5686fa60953..0ee44674777 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -30,6 +30,10 @@ function isPlainObject(value) { } function validateThemeJson(theme) { + if (!isPlainObject(theme)) { + throw new Error('Clerk theme: theme JSON must be a plain object'); + } + const validateColors = (colors, label) => { if (!isPlainObject(colors)) { throw new Error(`Clerk theme: ${label} must be an object`); @@ -82,6 +86,18 @@ describe('validateThemeJson', () => { expect(() => validateThemeJson({})).not.toThrow(); }); + test('throws when theme is null', () => { + expect(() => validateThemeJson(null)).toThrow('theme JSON must be a plain object'); + }); + + test('throws when theme is a string', () => { + expect(() => validateThemeJson('hello')).toThrow('theme JSON must be a plain object'); + }); + + test('throws when theme is an array', () => { + expect(() => validateThemeJson([])).toThrow('theme JSON must be a plain object'); + }); + test('accepts theme with only design', () => { expect(() => validateThemeJson({ design: { borderRadius: 8 } })).not.toThrow(); }); From 4786284db8d40217601be2ccf93a1bcea150f1e8 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 14 Apr 2026 14:51:01 -0700 Subject: [PATCH 10/12] fix(expo): address review feedback on theming PR - Export validation internals via _testing so tests import the real implementation instead of a stale copy. - Add comment documenting that theme loading is centralized in ClerkExpoModule (other init paths guard with !isInitialized). --- .../expo/modules/clerk/ClerkExpoModule.kt | 4 ++ packages/expo/app.plugin.js | 1 + .../src/__tests__/appPlugin.theme.test.js | 67 +------------------ 3 files changed, 6 insertions(+), 66 deletions(-) diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 20a1307b37b..ff2dee642a9 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -85,6 +85,10 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } Clerk.initialize(reactApplicationContext, pubKey) + // Theme loading is centralized here. ClerkViewFactory.configure() + // and ClerkUserProfileActivity.onCreate() only call Clerk.initialize() + // when Clerk is not yet initialized, so by the time they run + // ClerkExpoModule has already set the custom theme. // Must be set AFTER Clerk.initialize() because initialize() // resets customTheme to its `theme` parameter (default null). loadThemeFromAssets() diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index bdae5a88a65..c19c5d57a7b 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -716,3 +716,4 @@ const withClerkExpo = (config, props = {}) => { }; module.exports = withClerkExpo; +module.exports._testing = { validateThemeJson, isPlainObject, VALID_COLOR_KEYS, HEX_COLOR_REGEX }; diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js index 0ee44674777..e0d8946329c 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -1,71 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -// The plugin is plain CJS — load the validation function by evaluating -// just the relevant pieces. We re-declare the constants and function -// exactly as they appear in app.plugin.js so the test stays in sync. -// ------------------------------------------------------------------ - -const VALID_COLOR_KEYS = [ - 'primary', - 'background', - 'input', - 'danger', - 'success', - 'warning', - 'foreground', - 'mutedForeground', - 'primaryForeground', - 'inputForeground', - 'neutral', - 'border', - 'ring', - 'muted', - 'shadow', -]; - -const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; - -function isPlainObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function validateThemeJson(theme) { - if (!isPlainObject(theme)) { - throw new Error('Clerk theme: theme JSON must be a plain object'); - } - - const validateColors = (colors, label) => { - if (!isPlainObject(colors)) { - throw new Error(`Clerk theme: ${label} must be an object`); - } - for (const [key, value] of Object.entries(colors)) { - if (!VALID_COLOR_KEYS.includes(key)) { - console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); - continue; - } - if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { - throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); - } - } - }; - - if (theme.colors != null) validateColors(theme.colors, 'colors'); - if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); - - if (theme.design != null) { - if (!isPlainObject(theme.design)) { - throw new Error(`Clerk theme: design must be an object`); - } - if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { - throw new Error(`Clerk theme: design.fontFamily must be a string`); - } - if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { - throw new Error(`Clerk theme: design.borderRadius must be a number`); - } - } -} - -// ------------------------------------------------------------------ +const { validateThemeJson } = require('../../app.plugin.js')._testing; describe('validateThemeJson', () => { beforeEach(() => { From 257de36b13f4a16e2a912acd26422cd9dca0a1f0 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Tue, 14 Apr 2026 15:22:59 -0700 Subject: [PATCH 11/12] chore(expo): remove stale ClerkViewFactory.swift template The plugin copies from ios/ClerkViewFactory.swift directly (app.plugin.js line 266). The template at ios/templates/ was from an earlier iteration and is no longer referenced by anything. --- .../expo/ios/templates/ClerkViewFactory.swift | 548 ------------------ 1 file changed, 548 deletions(-) delete mode 100644 packages/expo/ios/templates/ClerkViewFactory.swift diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift deleted file mode 100644 index d9048643f9a..00000000000 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ /dev/null @@ -1,548 +0,0 @@ -// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module -// This file is injected into the app target by the config plugin. -// It uses `import ClerkKit` (SPM) which is only accessible from the app target. - -import UIKit -import SwiftUI -import Security -import ClerkKit -import ClerkKitUI -import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol - -// MARK: - View Factory Implementation - -public class ClerkViewFactory: ClerkViewFactoryProtocol { - public static let shared = ClerkViewFactory() - - private static let clerkLoadMaxAttempts = 30 - private static let clerkLoadIntervalNs: UInt64 = 100_000_000 - private static var clerkConfigured = false - - /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first - /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. - private static var keychainService: String? { - if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { - return custom - } - return Bundle.main.bundleIdentifier - } - - private init() {} - - // Register this factory with the ClerkExpo module - public static func register() { - clerkViewFactory = shared - } - - @MainActor - public func configure(publishableKey: String, bearerToken: String? = nil) async throws { - // Sync JS SDK's client token to native keychain so both SDKs share the same client. - // This handles the case where the user signed in via JS SDK but the native SDK - // has no device token (e.g., after app reinstall or first launch). - if let token = bearerToken, !token.isEmpty { - let existingToken = Self.readNativeDeviceToken() - Self.writeNativeDeviceToken(token) - - // If the device token changed (or didn't exist), clear stale cached client/environment. - // A previous launch may have cached an anonymous client (no device token), and the - // SDK would send both the new device token AND the stale client ID in API requests, - // causing a 400 error. Clearing the cache forces a fresh client fetch using only - // the device token. - if existingToken != token { - Self.clearCachedClerkData() - } - } else { - Self.syncJSTokenToNativeKeychainIfNeeded() - } - - // If already configured with a new bearer token, refresh the client - // to pick up the session associated with the device token we just wrote. - // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient(). - if Self.clerkConfigured, let token = bearerToken, !token.isEmpty { - _ = try? await Clerk.shared.refreshClient() - return - } - - Self.clerkConfigured = true - if let service = Self.keychainService { - Clerk.configure( - publishableKey: publishableKey, - options: .init(keychainConfig: .init(service: service)) - ) - } else { - Clerk.configure(publishableKey: publishableKey) - } - - // Wait for Clerk to finish loading (cached data + API refresh). - // The static configure() fires off async refreshes; poll until loaded. - for _ in 0.. String? { - guard let service = keychainService, !service.isEmpty else { return nil } - - var result: CFTypeRef? - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: "clerkDeviceToken", - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - /// Clears stale cached client and environment data from keychain. - /// This prevents the native SDK from loading a stale anonymous client - /// during initialization, which would conflict with a newly-synced device token. - private static func clearCachedClerkData() { - guard let service = keychainService, !service.isEmpty else { return } - - for key in ["cachedClient", "cachedEnvironment"] { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - } - } - - /// Writes the provided bearer token as the native SDK's device token. - /// If the native SDK already has a device token, it is updated with the new value. - private static func writeNativeDeviceToken(_ token: String) { - guard let service = keychainService, !service.isEmpty else { return } - - let nativeTokenKey = "clerkDeviceToken" - guard let tokenData = token.data(using: .utf8) else { return } - - // Check if native SDK already has a device token - let checkQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - // Update the existing token - let updateQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - ] - let updateAttributes: [String: Any] = [ - kSecValueData as String: tokenData, - ] - SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) - } else { - // Write a new token - let writeQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecValueData as String: tokenData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemAdd(writeQuery as CFDictionary, nil) - } - } - - public func getClientToken() -> String? { - Self.readNativeDeviceToken() - } - - public func createAuthViewController( - mode: String, - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - - let wrapper = ClerkAuthWrapperViewController( - mode: authMode, - dismissable: dismissable, - completion: completion - ) - return wrapper - } - - public func createUserProfileViewController( - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let wrapper = ClerkProfileWrapperViewController( - dismissable: dismissable, - completion: completion - ) - return wrapper - } - - // MARK: - Inline View Creation - - public func createAuthView( - mode: String, - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void - ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - - let hostingController = UIHostingController( - rootView: ClerkInlineAuthWrapperView( - mode: authMode, - dismissable: dismissable, - onEvent: onEvent - ) - ) - hostingController.view.backgroundColor = .clear - return hostingController - } - - public func createUserProfileView( - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void - ) -> UIViewController? { - let hostingController = UIHostingController( - rootView: ClerkInlineProfileWrapperView( - dismissable: dismissable, - onEvent: onEvent - ) - ) - hostingController.view.backgroundColor = .clear - return hostingController - } - - @MainActor - public func getSession() async -> [String: Any]? { - guard Self.clerkConfigured, let session = Clerk.shared.session else { - return nil - } - - var result: [String: Any] = [ - "sessionId": session.id, - "status": String(describing: session.status) - ] - - // Include user details if available - let user = session.user ?? Clerk.shared.user - - if let user = user { - var userDict: [String: Any] = [ - "id": user.id, - "imageUrl": user.imageUrl - ] - if let firstName = user.firstName { - userDict["firstName"] = firstName - } - if let lastName = user.lastName { - userDict["lastName"] = lastName - } - if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) { - userDict["primaryEmailAddress"] = primaryEmail.emailAddress - } else if let firstEmail = user.emailAddresses.first { - userDict["primaryEmailAddress"] = firstEmail.emailAddress - } - result["user"] = userDict - } - - return result - } - - @MainActor - public func signOut() async throws { - if Self.clerkConfigured { - defer { Clerk.clearAllKeychainItems() } - if let sessionId = Clerk.shared.session?.id { - try await Clerk.shared.auth.signOut(sessionId: sessionId) - } - } - Self.clerkConfigured = false - } -} - -// MARK: - Auth View Controller Wrapper - -class ClerkAuthWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"]))) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signInCompleted(let signIn): - if let sessionId = signIn.createdSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } else { - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"]))) - self.dismiss(animated: true) - } - case .signUpCompleted(let signUp): - if let sessionId = signUp.createdSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"])) - self.dismiss(animated: true) - } else { - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"]))) - self.dismiss(animated: true) - } - default: - break - } - } - // Stream ended without an auth completion event - guard let self = self else { return } - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"]))) - } - } -} - -struct ClerkAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool - - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) - .environment(Clerk.shared) - } -} - -// MARK: - Profile View Controller Wrapper - -class ClerkProfileWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"]))) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signedOut(let session): - self.completeOnce(.success(["sessionId": session.id])) - self.dismiss(animated: true) - default: - break - } - } - // Stream ended without a sign-out event - guard let self = self else { return } - self.completeOnce(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"]))) - } - } -} - -struct ClerkProfileWrapperView: View { - let dismissable: Bool - - var body: some View { - UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - } -} - -// MARK: - Inline Auth View Wrapper (for embedded rendering) - -struct ClerkInlineAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool - let onEvent: (String, [String: Any]) -> Void - - // Track initial session to detect new sign-ins (same approach as Android) - @State private var initialSessionId: String? = Clerk.shared.session?.id - @State private var eventSent = false - - private func sendAuthCompleted(sessionId: String, type: String) { - guard !eventSent, sessionId != initialSessionId else { return } - eventSent = true - onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) - } - - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) - .environment(Clerk.shared) - // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). - // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. - .onChange(of: Clerk.shared.session?.id) { _, newSessionId in - guard let sessionId = newSessionId else { return } - sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") - } - // Fallback: also listen to auth.events for signUp events and edge cases - .task { - for await event in Clerk.shared.auth.events { - guard !eventSent else { continue } - switch event { - case .signInCompleted(let signIn): - let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - case .signUpCompleted(let signUp): - let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } - case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - default: - break - } - } - } - } -} - -// MARK: - Inline Profile View Wrapper (for embedded rendering) - -struct ClerkInlineProfileWrapperView: View { - let dismissable: Bool - let onEvent: (String, [String: Any]) -> Void - - var body: some View { - UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - .task { - for await event in Clerk.shared.auth.events { - switch event { - case .signedOut(let session): - onEvent("signedOut", ["sessionId": session.id]) - default: - break - } - } - } - } -} - From 1ae23f157f2a8978d77c6d8869430e12715976f7 Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Wed, 15 Apr 2026 12:55:29 -0700 Subject: [PATCH 12/12] fix(expo): suppress no-require-imports lint error in theme test The plugin is CJS with no ESM export, so require() is necessary. --- packages/expo/src/__tests__/appPlugin.theme.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js index e0d8946329c..b9a880863ca 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export const { validateThemeJson } = require('../../app.plugin.js')._testing; describe('validateThemeJson', () => {