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..1f8e43f489a 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,6 @@ interface Spec { signOut(): Promise; } -export default requireNativeModule('ClerkGoogleSignIn'); +// Optional so getNativeModule() surfaces its actionable error rather than +// throwing at import time. +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..bbebc8f11c9 --- /dev/null +++ b/packages/expo/src/specs/__tests__/androidSpecs.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + available: {} as Record, +})); + +// Only returns a module when one is registered under the exact name requested. +vi.mock('expo', () => ({ + requireNativeModule: (name: string) => { + const nativeModule = mocks.available[name]; + if (!nativeModule) { + throw new Error(`Cannot find native module '${name}'`); + } + return nativeModule; + }, + 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', () => { + beforeEach(() => { + vi.resetModules(); + mocks.available = {}; + }); + + 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); + }); +});