From c112e5ffe8ccb2306540716005db3eda6ebaf883 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 21:05:08 -0700 Subject: [PATCH 1/2] fix(expo): Resolve Android native modules optionally to avoid Expo Go crash --- .../fix-android-optional-native-module.md | 5 ++++ .../specs/NativeClerkGoogleSignIn.android.ts | 5 ++-- .../src/specs/NativeClerkModule.android.ts | 5 ++-- .../src/specs/__tests__/androidSpecs.test.ts | 23 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-android-optional-native-module.md create mode 100644 packages/expo/src/specs/__tests__/androidSpecs.test.ts diff --git a/.changeset/fix-android-optional-native-module.md b/.changeset/fix-android-optional-native-module.md new file mode 100644 index 00000000000..8c6ce3c930f --- /dev/null +++ b/.changeset/fix-android-optional-native-module.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix a crash on Android in Expo Go where rendering `` failed with `Cannot find native module 'ClerkExpo'`, even for JavaScript-only flows that use no native components. diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts index 4ad961835a5..3e31081dea0 100644 --- a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts +++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts @@ -1,4 +1,4 @@ -import { requireNativeModule } from 'expo'; +import { requireOptionalNativeModule } from 'expo'; type NativeMap = Record; @@ -10,4 +10,5 @@ interface Spec { signOut(): Promise; } -export default requireNativeModule('ClerkGoogleSignIn'); +// Optional so getNativeModule() can surface its actionable error instead. +export default requireOptionalNativeModule('ClerkGoogleSignIn'); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 2cf67749368..cced94ad12a 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,4 +1,4 @@ -import { requireNativeModule } from 'expo'; +import { requireOptionalNativeModule } from 'expo'; interface Spec { // Exposed by Expo Modules EventEmitter for internal native client change events. @@ -14,4 +14,5 @@ interface Spec { ): Promise; } -export default requireNativeModule('ClerkExpo'); +// Optional so it resolves to null in Expo Go instead of throwing at import time. +export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo/src/specs/__tests__/androidSpecs.test.ts b/packages/expo/src/specs/__tests__/androidSpecs.test.ts new file mode 100644 index 00000000000..5ac01e9ac56 --- /dev/null +++ b/packages/expo/src/specs/__tests__/androidSpecs.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test, vi } from 'vitest'; + +import ClerkGoogleSignInSpec from '../NativeClerkGoogleSignIn.android'; +import ClerkExpoSpec from '../NativeClerkModule.android'; + +// Simulates Expo Go on Android, where the native modules are not compiled in: +// requireNativeModule throws, requireOptionalNativeModule resolves to null. +vi.mock('expo', () => ({ + requireNativeModule: (name: string) => { + throw new Error(`Cannot find native module '${name}'`); + }, + requireOptionalNativeModule: () => null, +})); + +describe('android native module specs', () => { + test('NativeClerkModule resolves to null instead of throwing at import time', () => { + expect(ClerkExpoSpec).toBeNull(); + }); + + test('NativeClerkGoogleSignIn resolves to null instead of throwing at import time', () => { + expect(ClerkGoogleSignInSpec).toBeNull(); + }); +}); From 28810861f355808c50099bf0329963427f8b5ca8 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 21:21:59 -0700 Subject: [PATCH 2/2] chore: address coderabbit comment and clean up comments --- .../specs/NativeClerkGoogleSignIn.android.ts | 3 +- .../src/specs/__tests__/androidSpecs.test.ts | 50 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts index 3e31081dea0..1f8e43f489a 100644 --- a/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts +++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts @@ -10,5 +10,6 @@ interface Spec { signOut(): Promise; } -// Optional so getNativeModule() can surface its actionable error instead. +// Optional so getNativeModule() surfaces its actionable error rather than +// throwing at import time. export default requireOptionalNativeModule('ClerkGoogleSignIn'); diff --git a/packages/expo/src/specs/__tests__/androidSpecs.test.ts b/packages/expo/src/specs/__tests__/androidSpecs.test.ts index 5ac01e9ac56..bbebc8f11c9 100644 --- a/packages/expo/src/specs/__tests__/androidSpecs.test.ts +++ b/packages/expo/src/specs/__tests__/androidSpecs.test.ts @@ -1,23 +1,51 @@ -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; -import ClerkGoogleSignInSpec from '../NativeClerkGoogleSignIn.android'; -import ClerkExpoSpec from '../NativeClerkModule.android'; +const mocks = vi.hoisted(() => ({ + available: {} as Record, +})); -// Simulates Expo Go on Android, where the native modules are not compiled in: -// requireNativeModule throws, requireOptionalNativeModule resolves to null. +// Only returns a module when one is registered under the exact name requested. vi.mock('expo', () => ({ requireNativeModule: (name: string) => { - throw new Error(`Cannot find native module '${name}'`); + const nativeModule = mocks.available[name]; + if (!nativeModule) { + throw new Error(`Cannot find native module '${name}'`); + } + return nativeModule; }, - requireOptionalNativeModule: () => null, + requireOptionalNativeModule: (name: string) => mocks.available[name] ?? null, })); +// Path is a variable because TypeScript cannot resolve the platform suffix in a +// dynamic import. +const importSpec = async (path: string) => (await import(path)).default; + +const importSpecs = async () => ({ + clerkExpo: await importSpec('../NativeClerkModule.android'), + googleSignIn: await importSpec('../NativeClerkGoogleSignIn.android'), +}); + describe('android native module specs', () => { - test('NativeClerkModule resolves to null instead of throwing at import time', () => { - expect(ClerkExpoSpec).toBeNull(); + beforeEach(() => { + vi.resetModules(); + mocks.available = {}; }); - test('NativeClerkGoogleSignIn resolves to null instead of throwing at import time', () => { - expect(ClerkGoogleSignInSpec).toBeNull(); + test('resolve to null instead of throwing when not compiled in (Expo Go)', async () => { + const { clerkExpo, googleSignIn } = await importSpecs(); + + expect(clerkExpo).toBeNull(); + expect(googleSignIn).toBeNull(); + }); + + test('resolve the module registered under the expected name (development build)', async () => { + const clerkExpoModule = { configure: vi.fn() }; + const googleSignInModule = { signIn: vi.fn() }; + mocks.available = { ClerkExpo: clerkExpoModule, ClerkGoogleSignIn: googleSignInModule }; + + const { clerkExpo, googleSignIn } = await importSpecs(); + + expect(clerkExpo).toBe(clerkExpoModule); + expect(googleSignIn).toBe(googleSignInModule); }); });