diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md new file mode 100644 index 00000000000..6c3fd2fc364 --- /dev/null +++ b/.changeset/expo-native-components-moved.md @@ -0,0 +1,25 @@ +--- +'@clerk/expo': minor +--- + +The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native-components` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17. + +If you use the native components, install the new package: + +```sh +npx expo install @clerk/expo-native-components +``` + +add its config plugin alongside `@clerk/expo` in your app config: + +```json +{ + "expo": { + "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + } +} +``` + +then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native-components`, which you can also import from directly. Without `@clerk/expo-native-components` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it. + +The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native-components` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native-components` is installed, and warns otherwise. diff --git a/.changeset/introduce-expo-native.md b/.changeset/introduce-expo-native.md new file mode 100644 index 00000000000..a7a0839fb61 --- /dev/null +++ b/.changeset/introduce-expo-native.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': minor +--- + +Add `@clerk/expo-native-components`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options. diff --git a/.claude/skills/clerk-monorepo/references/package-map.md b/.claude/skills/clerk-monorepo/references/package-map.md index c5cabf3765b..47abcecfa41 100644 --- a/.claude/skills/clerk-monorepo/references/package-map.md +++ b/.claude/skills/clerk-monorepo/references/package-map.md @@ -16,32 +16,33 @@ browser directly), **adapter** (framework SDK), **ui/i18n**, **tooling**. The "B two packages whose runtime is pushed into apps pinned to older SDKs, so they carry the strict backwards-compatibility contract (see `breaking-changes.md`). -| Package | Category | BC | Purpose | -| ----------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. | -| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. | -| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. | -| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. | -| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. | -| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. | -| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. | -| `@clerk/express` | adapter | | Express middleware and server helpers. | -| `@clerk/fastify` | adapter | | Fastify plugin. | -| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). | -| `@clerk/astro` | adapter | | Astro integration (components + server utilities). | -| `@clerk/nuxt` | adapter | | Nuxt module (Vue). | -| `@clerk/vue` | adapter | | Vue 3 composables and components. | -| `@clerk/react-router` | adapter | | React Router v7 SDK. | -| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. | -| `@clerk/expo` | adapter | | React Native / Expo SDK. | -| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. | -| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. | -| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. | -| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). | -| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). | -| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). | -| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. | -| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. | +| Package | Category | BC | Purpose | +| ------------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. | +| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. | +| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. | +| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. | +| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. | +| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. | +| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. | +| `@clerk/express` | adapter | | Express middleware and server helpers. | +| `@clerk/fastify` | adapter | | Fastify plugin. | +| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). | +| `@clerk/astro` | adapter | | Astro integration (components + server utilities). | +| `@clerk/nuxt` | adapter | | Nuxt module (Vue). | +| `@clerk/vue` | adapter | | Vue 3 composables and components. | +| `@clerk/react-router` | adapter | | React Router v7 SDK. | +| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. | +| `@clerk/expo` | adapter | | React Native / Expo SDK. | +| `@clerk/expo-native-components` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. | +| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. | +| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. | +| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. | +| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). | +| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). | +| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). | +| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. | +| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. | Tests: `pnpm turbo test --filter=@clerk/` (or `pnpm --filter @clerk/ test` after a build). Most packages use vitest; `@clerk/backend` runs a multi-runtime suite (node + edge + diff --git a/.github/labeler.yml b/.github/labeler.yml index b6f18eeb545..d07fbb87c3f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -28,6 +28,10 @@ expo: - changed-files: - any-glob-to-any-file: packages/expo/** +expo-native-components: + - changed-files: + - any-glob-to-any-file: packages/expo-native-components/** + express: - changed-files: - any-glob-to-any-file: packages/express/** diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml index 25971a1ad1b..9feadb28f55 100644 --- a/.github/workflows/api-changes.yml +++ b/.github/workflows/api-changes.yml @@ -19,6 +19,7 @@ on: - 'packages/clerk-js/**' - 'packages/expo/**' - 'packages/expo-google-signin/**' + - 'packages/expo-native-components/**' - 'packages/expo-passkeys/**' - 'packages/express/**' - 'packages/fastify/**' @@ -56,6 +57,7 @@ env: --filter=@clerk/clerk-js --filter=@clerk/expo --filter=@clerk/expo-google-signin + --filter=@clerk/expo-native-components --filter=@clerk/expo-passkeys --filter=@clerk/express --filter=@clerk/fastify diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index bf96461fe88..cd69db5c340 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -11,6 +11,7 @@ on: - 'integration/tests/expo-native/**' - 'packages/expo/**' - 'packages/expo-google-signin/**' + - 'packages/expo-native-components/**' workflow_dispatch: permissions: @@ -85,6 +86,7 @@ jobs: packages/clerk-js \ packages/expo \ packages/expo-google-signin \ + packages/expo-native-components \ packages/react \ packages/shared \ "$FIXTURE_DIR" | @@ -125,6 +127,7 @@ jobs: mkdir -p "$SDK_PACK_DIR" pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR" pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" + pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies if: steps.native-build-cache.outputs.cache-hit != 'true' @@ -135,10 +138,11 @@ jobs: run: | cp "package.sdk-$EXPO_SDK.json" package.json pnpm install --no-frozen-lockfile - # [0-9] keeps this glob off the clerk-expo-google-signin tarball. + # [0-9] keeps this glob off the clerk-expo-google-signin and clerk-expo-native tarballs. SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)" GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" - pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w + NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)" + pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" -w # expo-dev-client makes even release builds boot into the dev # launcher (unreachable Metro in CI), which stalls every Maestro # flow on a blank screen. Skip it on e2e jobs only. diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json index 58e258ea523..4ea4b82bb8f 100644 --- a/integration/templates/expo-native/app.json +++ b/integration/templates/expo-native/app.json @@ -12,6 +12,6 @@ "android": { "package": "com.clerk.exponativebuildfixture" }, - "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"] + "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-native", "expo-web-browser"] } } diff --git a/package.json b/package.json index 5884e61374c..3f4c74549d6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "changeset": "changeset", "changeset:empty": "pnpm changeset --empty", "clean": "turbo run clean", - "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension", + "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native-components --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension", "dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared", "dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js", "dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve", diff --git a/packages/expo/.gitignore b/packages/expo-native-components/.gitignore similarity index 100% rename from packages/expo/.gitignore rename to packages/expo-native-components/.gitignore diff --git a/packages/expo-native-components/LICENSE b/packages/expo-native-components/LICENSE new file mode 100644 index 00000000000..66914b6af7c --- /dev/null +++ b/packages/expo-native-components/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/expo-native-components/README.md b/packages/expo-native-components/README.md new file mode 100644 index 00000000000..dceac483ebb --- /dev/null +++ b/packages/expo-native-components/README.md @@ -0,0 +1,107 @@ +

+ + + + + + +
+

@clerk/expo-native-components

+

+ +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native_components) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components) + +
+ +## Getting Started + +`@clerk/expo-native-components` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs: + +- **iOS**: [clerk-ios](https://github.com/clerk/clerk-ios) (SwiftUI) +- **Android**: [clerk-android](https://github.com/clerk/clerk-android) (Jetpack Compose) + +Install it only if you use `AuthView`, `UserProfileView`, `UserButton`, or biometric credentials. Apps that only use `@clerk/expo` do not include the Clerk native SDKs. + +### Prerequisites + +- `@clerk/expo` +- Expo 54 or later, with a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (Expo Go is not supported) +- iOS 17 or later + +### Installation + +```sh +npx expo install @clerk/expo @clerk/expo-native-components +``` + +Add the config plugin alongside `@clerk/expo` in your app config, then rebuild your native app: + +```json +{ + "expo": { + "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + } +} +``` + +The plugin accepts the following options: + +- `keychainService`: keychain service identifier to share the session with app extensions. +- `theme`: path to a JSON file with `colors`, `darkColors`, and `design` keys that styles the native components. + +## Usage + +```tsx +import { useAuth } from '@clerk/expo'; +import { AuthView } from '@clerk/expo-native-components'; + +export default function SignInScreen() { + const { isSignedIn } = useAuth(); + + if (isSignedIn) { + return null; + } + + return ; +} +``` + +For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native_components). + +## Support + +For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components). + +## Community + +Join our [Discord community](https://clerk.com/discord) to connect with other developers. + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/expo-native-components` follows good practices of security, but 100% security cannot be assured. + +`@clerk/expo-native-components` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/LICENSE) for more information. diff --git a/packages/expo/android/build.gradle b/packages/expo-native-components/android/build.gradle similarity index 93% rename from packages/expo/android/build.gradle rename to packages/expo-native-components/android/build.gradle index 8975e848f7d..facf8a66952 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo-native-components/android/build.gradle @@ -13,8 +13,9 @@ applyKotlinExpoModulesCorePlugin() group = 'com.clerk.expo' version = '1.0.0' -def clerkExpoPackageJson = new JsonSlurper().parse(new File(projectDir, "../package.json")) -def clerkExpoVersion = clerkExpoPackageJson.version.toString() +// Set by the @clerk/expo-native-components config plugin to the installed @clerk/expo version. +def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toString() ?: + new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString() // Dependency versions - centralized for easier updates // See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs @@ -97,7 +98,7 @@ try { } } catch (Exception ignored) { // Future Gradle versions with Isolated Projects may block cross-project configuration. - // In that case, users should add '@clerk/expo' to their app.json plugins array instead, + // In that case, users should add '@clerk/expo-native-components' to their app.json plugins array instead, // which applies the flag via the config plugin's withClerkAndroid. } diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo-native-components/android/src/main/AndroidManifest.xml similarity index 100% rename from packages/expo/android/src/main/AndroidManifest.xml rename to packages/expo-native-components/android/src/main/AndroidManifest.xml diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt similarity index 100% rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt similarity index 100% rename from packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt similarity index 100% rename from packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt diff --git a/packages/expo-native-components/app.plugin.d.ts b/packages/expo-native-components/app.plugin.d.ts new file mode 100644 index 00000000000..8808449dd73 --- /dev/null +++ b/packages/expo-native-components/app.plugin.d.ts @@ -0,0 +1,5 @@ +import type { ConfigPlugin } from '@expo/config-plugins'; + +declare const withClerkExpoNativeComponents: ConfigPlugin<{ keychainService?: string; theme?: string } | void>; + +export = withClerkExpoNativeComponents; diff --git a/packages/expo-native-components/app.plugin.js b/packages/expo-native-components/app.plugin.js new file mode 100644 index 00000000000..36e10092b7a --- /dev/null +++ b/packages/expo-native-components/app.plugin.js @@ -0,0 +1,333 @@ +/** + * Expo config plugin for @clerk/expo-native-components + * Configures iOS and Android for the Clerk native SDKs (clerk-ios / clerk-android) + * + * When this plugin is used: + * 1. iOS is configured with the required deployment target and metadata + * 2. Android is configured with packaging exclusions for dependencies + * + * Native modules and views are registered via Expo Modules autolinking. + */ +const { + createRunOncePlugin, + withXcodeProject, + withDangerousMod, + withInfoPlist, + withAppBuildGradle, + withGradleProperties, +} = require('@expo/config-plugins'); +const path = require('path'); +const fs = require('fs'); +const packageJson = require('./package.json'); + +const CLERK_MIN_IOS_VERSION = '17.0'; +const HOST_SDK_VERSION_GRADLE_PROPERTY = 'clerkExpo.hostSdkVersion'; + +const resolveClerkExpo = projectRoot => { + try { + const paths = [projectRoot, process.cwd()].filter(Boolean); + const clerkExpoPackageJsonPath = require.resolve('@clerk/expo/package.json', { paths }); + return { + dir: path.dirname(clerkExpoPackageJsonPath), + version: require(clerkExpoPackageJsonPath).version, + }; + } catch { + return null; + } +}; + +const withClerkIOS = (config, hostSdkVersion) => { + console.log('✅ Clerk iOS plugin loaded'); + + // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install + // This ensures ClerkExpo pod gets installed (it requires iOS 17.0) + config = withDangerousMod(config, [ + 'ios', + async config => { + const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json'); + + let properties = {}; + if (fs.existsSync(podfilePropertiesPath)) { + try { + properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8')); + } catch { + // If file exists but is invalid JSON, start fresh + } + } + + // Set the iOS deployment target + if ( + !properties['ios.deploymentTarget'] || + parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION) + ) { + properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION; + fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n'); + console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`); + } + + return config; + }, + ]); + + // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK) + config = withXcodeProject(config, config => { + const xcodeProject = config.modResults; + + try { + // Update deployment target in all build configurations + const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {}; + + for (const [uuid, buildConfig] of Object.entries(buildConfigs)) { + if (buildConfig && buildConfig.buildSettings) { + const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET; + if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) { + buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION; + } + } + } + + console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`); + } catch (error) { + console.error('❌ Error updating deployment target:', error.message); + } + + return config; + }); + + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkExpoVersion = hostSdkVersion; + return modConfig; + }); + + return config; +}; + +/** + * Add packaging exclusions to Android app build.gradle to resolve + * duplicate META-INF file conflicts from clerk-android dependencies. + */ +const withClerkAndroid = (config, hostSdkVersion) => { + console.log('✅ Clerk Android plugin loaded'); + + config = withGradleProperties(config, modConfig => { + modConfig.modResults = modConfig.modResults.filter( + item => !(item.type === 'property' && item.key === HOST_SDK_VERSION_GRADLE_PROPERTY), + ); + modConfig.modResults.push({ type: 'property', key: HOST_SDK_VERSION_GRADLE_PROPERTY, value: hostSdkVersion }); + return modConfig; + }); + + return withAppBuildGradle(config, modConfig => { + let buildGradle = modConfig.modResults.contents; + + // --- META-INF exclusion --- + if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions` + const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/); + if (packagingMatch) { + const blockName = packagingMatch[0].trim().replace(/\s*\{$/, ''); + const resourcesExclude = `${blockName} { + // Clerk Android SDK: exclude duplicate META-INF files + resources { + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + }`; + + buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude); + } else { + // No packaging block found; append one at the end of the android block + const androidBlockEnd = buildGradle.lastIndexOf('}'); + if (androidBlockEnd !== -1) { + const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`; + buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd); + } + } + console.log('✅ Clerk Android packaging exclusions added'); + } + + // --- Kotlin metadata version check skip --- + if (!buildGradle.includes('-Xskip-metadata-version-check')) { + const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/); + if (kotlinOptionsMatch) { + buildGradle = buildGradle.replace( + /kotlinOptions\s*\{/, + `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`, + ); + } else { + const androidMatch = buildGradle.match(/android\s*\{/); + if (androidMatch) { + buildGradle = buildGradle.replace( + /android\s*\{/, + `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`, + ); + } + } + console.log('✅ Clerk Android Kotlin metadata version check skip added'); + } + + modConfig.modResults.contents = buildGradle; + return modConfig; + }); +}; + +/** + * Write ClerkKeychainService to Info.plist when keychainService is provided. + * This allows extension apps (watch, widget, app clip) to share the same + * keychain entry as the main app by using a custom service identifier. + */ +const withClerkKeychainService = (config, { keychainService } = {}) => { + if (!keychainService) { + return config; + } + + return withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkKeychainService = keychainService; + console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`); + return modConfig; + }); +}; + +/** + * 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, secondaryButtonBackground, + * secondaryButtonForeground } (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". + * 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', + 'secondaryButtonBackground', + 'secondaryButtonForeground', +]; + +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 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'); + 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 withClerkExpoNativeComponents = (config, props = {}, resolve = resolveClerkExpo) => { + const clerkExpo = resolve(config._internal?.projectRoot); + if (clerkExpo && fs.existsSync(path.join(clerkExpo.dir, 'expo-module.config.json'))) { + throw new Error( + `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native-components. Upgrade @clerk/expo to a version that supports @clerk/expo-native-components.`, + ); + } + // Native requests report the @clerk/expo version in the x-clerk-host-sdk-version header. + const hostSdkVersion = clerkExpo?.version ?? packageJson.version; + + config = withClerkIOS(config, hostSdkVersion); + config = withClerkAndroid(config, hostSdkVersion); + config = withClerkKeychainService(config, props); + config = withClerkTheme(config, props); + return config; +}; + +module.exports = createRunOncePlugin(withClerkExpoNativeComponents, packageJson.name, packageJson.version); +module.exports._testing = { + withClerkExpoNativeComponents, + validateThemeJson, + isPlainObject, + VALID_COLOR_KEYS, + HEX_COLOR_REGEX, +}; diff --git a/packages/expo/expo-module.config.json b/packages/expo-native-components/expo-module.config.json similarity index 100% rename from packages/expo/expo-module.config.json rename to packages/expo-native-components/expo-module.config.json diff --git a/packages/expo/ios/ClerkAppDelegateSubscriber.swift b/packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift similarity index 100% rename from packages/expo/ios/ClerkAppDelegateSubscriber.swift rename to packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo-native-components/ios/ClerkAuthNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkAuthNativeView.swift rename to packages/expo-native-components/ios/ClerkAuthNativeView.swift diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec similarity index 100% rename from packages/expo/ios/ClerkExpo.podspec rename to packages/expo-native-components/ios/ClerkExpo.podspec diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift similarity index 100% rename from packages/expo/ios/ClerkExpoModule.swift rename to packages/expo-native-components/ios/ClerkExpoModule.swift diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift similarity index 100% rename from packages/expo/ios/ClerkNativeBridge.swift rename to packages/expo-native-components/ios/ClerkNativeBridge.swift diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo-native-components/ios/ClerkNativeViewHost.swift similarity index 100% rename from packages/expo/ios/ClerkNativeViewHost.swift rename to packages/expo-native-components/ios/ClerkNativeViewHost.swift diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo-native-components/ios/ClerkUserButtonNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkUserButtonNativeView.swift rename to packages/expo-native-components/ios/ClerkUserButtonNativeView.swift diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift similarity index 100% rename from packages/expo/ios/ClerkUserProfileNativeView.swift rename to packages/expo-native-components/ios/ClerkUserProfileNativeView.swift diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift rename to packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkNativeBridgeTests.swift rename to packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift diff --git a/packages/expo/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkNativeViewHostTests.swift rename to packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift diff --git a/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift similarity index 100% rename from packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift rename to packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift diff --git a/packages/expo-native-components/package.json b/packages/expo-native-components/package.json new file mode 100644 index 00000000000..0d6f430f012 --- /dev/null +++ b/packages/expo-native-components/package.json @@ -0,0 +1,82 @@ +{ + "name": "@clerk/expo-native-components", + "version": "0.0.1", + "description": "Native Clerk components and native SDK integration for Expo, powered by clerk-ios and clerk-android", + "keywords": [ + "react", + "react-native", + "expo", + "clerk", + "auth", + "authentication", + "native" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/expo-native-components" + }, + "license": "MIT", + "author": "Clerk", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./app.plugin.js": "./app.plugin.js", + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "android", + "ios", + "src/specs", + "expo-module.config.json", + "react-native.config.js", + "app.plugin.js", + "app.plugin.d.ts" + ], + "scripts": { + "build": "tsdown", + "build:declarations": "tsc -p tsconfig.declarations.json", + "clean": "rimraf ./dist", + "dev": "tsdown --watch", + "dev:pub": "pnpm dev -- --env.publish", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "test": "vitest run", + "test:watch": "vitest watch" + }, + "dependencies": { + "@clerk/react": "workspace:^", + "tslib": "catalog:repo" + }, + "devDependencies": { + "@expo/config-plugins": "^54.0.4", + "react-native": "^0.86.0" + }, + "peerDependencies": { + "expo": "catalog:peer-expo", + "react": "^18.0.0 || ^19.0.0", + "react-native": ">=0.75" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + }, + "codegenConfig": { + "name": "ClerkExpoSpec", + "type": "all", + "jsSrcsDir": "src/specs" + } +} diff --git a/packages/expo/react-native.config.js b/packages/expo-native-components/react-native.config.js similarity index 100% rename from packages/expo/react-native.config.js rename to packages/expo-native-components/react-native.config.js diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo-native-components/src/AuthView.tsx similarity index 86% rename from packages/expo/src/native/AuthView.tsx rename to packages/expo-native-components/src/AuthView.tsx index 3f4b51ee346..99e1d395348 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo-native-components/src/AuthView.tsx @@ -2,9 +2,9 @@ import { useCallback } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { Text, View } from 'react-native'; -import NativeClerkAuthView from '../specs/NativeClerkAuthView'; -import { isNativeSupported } from '../utils/native-module'; import type { AuthViewProps } from './AuthView.types'; +import NativeClerkAuthView from './specs/NativeClerkAuthView'; +import { isNativeSupported } from './utils/native-module'; type AuthNativeEvent = NativeSyntheticEvent>; @@ -24,7 +24,7 @@ type AuthNativeEvent = NativeSyntheticEvent>; * * @example * ```tsx - * import { AuthView } from '@clerk/expo/native'; + * import { AuthView } from '@clerk/expo-native-components'; * import { useAuth } from '@clerk/expo'; * * export default function SignInScreen() { @@ -63,7 +63,7 @@ export function AuthView({ {!isNativeSupported ? 'Native AuthView is only available on iOS and Android' - : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + : 'Native AuthView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'} ); diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts similarity index 100% rename from packages/expo/src/native/AuthView.types.ts rename to packages/expo-native-components/src/AuthView.types.ts diff --git a/packages/expo/src/native/EmbeddedNavigation.types.ts b/packages/expo-native-components/src/EmbeddedNavigation.types.ts similarity index 100% rename from packages/expo/src/native/EmbeddedNavigation.types.ts rename to packages/expo-native-components/src/EmbeddedNavigation.types.ts diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo-native-components/src/UserButton.tsx similarity index 94% rename from packages/expo/src/native/UserButton.tsx rename to packages/expo-native-components/src/UserButton.tsx index a1406f0652e..fc2504781b2 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo-native-components/src/UserButton.tsx @@ -3,8 +3,7 @@ import { useMemo, useRef } from 'react'; import type { NativeSyntheticEvent } from 'react-native'; import { StyleSheet, useWindowDimensions } from 'react-native'; -import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView'; -import { isNativeSupported } from '../utils/native-module'; +import NativeClerkUserButtonView from './specs/NativeClerkUserButtonView'; import type { NativeUserProfileNavigationHandle, UserProfileCustomDestination, @@ -16,6 +15,7 @@ import { UserProfileCustomPageHosts, useUserProfileCustomPages, } from './UserProfileCustomPages'; +import { isNativeSupported } from './utils/native-module'; type CustomizableNativeUserButtonProps = ComponentProps> & { customPages?: string; @@ -47,7 +47,7 @@ export interface UserButtonProps { * * @example * ```tsx - * import { UserButton } from '@clerk/expo/native'; + * import { UserButton } from '@clerk/expo-native-components'; * * export default function Home() { * return ( diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo-native-components/src/UserProfileCustomPages.tsx similarity index 100% rename from packages/expo/src/native/UserProfileCustomPages.tsx rename to packages/expo-native-components/src/UserProfileCustomPages.tsx diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo-native-components/src/UserProfileView.tsx similarity index 92% rename from packages/expo/src/native/UserProfileView.tsx rename to packages/expo-native-components/src/UserProfileView.tsx index 3c39d955375..eb3e59bee96 100644 --- a/packages/expo/src/native/UserProfileView.tsx +++ b/packages/expo-native-components/src/UserProfileView.tsx @@ -3,9 +3,8 @@ import { useCallback, useMemo, useRef } from 'react'; import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, Text, View } from 'react-native'; -import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; -import { isNativeSupported } from '../utils/native-module'; import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; +import NativeClerkUserProfileView from './specs/NativeClerkUserProfileView'; import type { NativeUserProfileNavigationHandle, UserProfileCustomDestination, @@ -17,6 +16,7 @@ import { UserProfileCustomPageHosts, useUserProfileCustomPages, } from './UserProfileCustomPages'; +import { isNativeSupported } from './utils/native-module'; type CustomizableNativeUserProfileProps = ComponentProps> & { customPages?: string; @@ -75,7 +75,7 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps { * * @example * ```tsx - * import { UserProfileView } from '@clerk/expo/native'; + * import { UserProfileView } from '@clerk/expo-native-components'; * import { useAuth } from '@clerk/expo'; * * export default function ProfileScreen() { @@ -128,7 +128,7 @@ export function UserProfileView({ {!isNativeSupported ? 'Native UserProfileView is only available on iOS and Android' - : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + : 'Native UserProfileView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'} ); diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo-native-components/src/__tests__/AuthView.test.tsx similarity index 94% rename from packages/expo/src/native/__tests__/AuthView.test.tsx rename to packages/expo-native-components/src/__tests__/AuthView.test.tsx index 6b3029ecdda..528e251fb2e 100644 --- a/packages/expo/src/native/__tests__/AuthView.test.tsx +++ b/packages/expo-native-components/src/__tests__/AuthView.test.tsx @@ -10,13 +10,13 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../specs/NativeClerkAuthView', () => { +vi.mock('../specs/NativeClerkAuthView', () => { return { default: mocks.NativeClerkAuthView, }; }); -vi.mock('../../utils/native-module', () => { +vi.mock('../utils/native-module', () => { return { isNativeSupported: true, }; diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo-native-components/src/__tests__/UserButton.test.tsx similarity index 94% rename from packages/expo/src/native/__tests__/UserButton.test.tsx rename to packages/expo-native-components/src/__tests__/UserButton.test.tsx index d4eaef80382..d414aa0f89f 100644 --- a/packages/expo/src/native/__tests__/UserButton.test.tsx +++ b/packages/expo-native-components/src/__tests__/UserButton.test.tsx @@ -8,14 +8,14 @@ const mocks = vi.hoisted(() => ({ nativeProps: vi.fn(), })); -vi.mock('../../specs/NativeClerkUserButtonView', () => ({ +vi.mock('../specs/NativeClerkUserButtonView', () => ({ default: React.forwardRef((props: Record, _ref) => { mocks.nativeProps(props); return null; }), })); -vi.mock('../../utils/native-module', () => ({ +vi.mock('../utils/native-module', () => ({ isNativeSupported: true, })); diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx similarity index 100% rename from packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx rename to packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx similarity index 98% rename from packages/expo/src/native/__tests__/UserProfileView.test.tsx rename to packages/expo-native-components/src/__tests__/UserProfileView.test.tsx index 34af7275e6d..619c417305e 100644 --- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx +++ b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../specs/NativeClerkUserProfileView', () => { +vi.mock('../specs/NativeClerkUserProfileView', () => { return { default: React.forwardRef((props: { children?: React.ReactNode }, ref) => { React.useImperativeHandle(ref, () => ({ navigateCustomPage: mocks.navigateCustomPage })); @@ -24,7 +24,7 @@ vi.mock('../../specs/NativeClerkUserProfileView', () => { }; }); -vi.mock('../../utils/native-module', () => { +vi.mock('../utils/native-module', () => { return { isNativeSupported: true, }; diff --git a/packages/expo-native-components/src/__tests__/appPlugin.test.js b/packages/expo-native-components/src/__tests__/appPlugin.test.js new file mode 100644 index 00000000000..bbb2d247250 --- /dev/null +++ b/packages/expo-native-components/src/__tests__/appPlugin.test.js @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { withClerkExpoNativeComponents } = require('../../app.plugin.js')._testing; + +const applyMod = (config, platform, mod, modResults) => + config.mods[platform][mod]({ ...config, modRequest: {}, modResults }); + +describe('withClerkExpoNativeComponents', () => { + let clerkExpoDir; + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + clerkExpoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(clerkExpoDir, { recursive: true, force: true }); + }); + + test('reports the installed @clerk/expo version on iOS and Android', async () => { + const config = withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({ + dir: clerkExpoDir, + version: '4.8.0', + })); + + const infoPlist = await applyMod(config, 'ios', 'infoPlist', {}); + const gradleProperties = await applyMod(config, 'android', 'gradleProperties', [ + { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.7.0' }, + ]); + + expect(infoPlist.modResults.ClerkExpoVersion).toBe('4.8.0'); + expect(gradleProperties.modResults).toEqual([ + { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.8.0' }, + ]); + }); + + test('throws when the installed @clerk/expo still bundles the native module', () => { + fs.writeFileSync(path.join(clerkExpoDir, 'expo-module.config.json'), '{}'); + + expect(() => + withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({ + dir: clerkExpoDir, + version: '4.7.1', + })), + ).toThrow('@clerk/expo@4.7.1 still bundles the Clerk native module'); + }); +}); diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js similarity index 71% rename from packages/expo/src/__tests__/appPlugin.theme.test.js rename to packages/expo-native-components/src/__tests__/appPlugin.theme.test.js index fcff7577b0e..c0f89d02ab8 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js @@ -2,54 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export const clerkPlugin = require('../../app.plugin.js'); -const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing; - -function applyInfoPlistMod(config, modResults) { - return config.mods.ios.infoPlist({ - ...config, - modRequest: {}, - modResults, - }); -} - -describe('withClerkFaceIDPermission', () => { - test('adds the configured Face ID usage description', async () => { - const config = withClerkFaceIDPermission( - { name: 'test', slug: 'test' }, - { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' }, - ); - - const result = await applyInfoPlistMod(config, {}); - - expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.'); - }); - - test('preserves an app-provided Face ID usage description', async () => { - const config = withClerkFaceIDPermission( - { name: 'test', slug: 'test' }, - { faceIDPermission: 'Clerk-provided description' }, - ); - - const result = await applyInfoPlistMod(config, { - NSFaceIDUsageDescription: 'App-provided description', - }); - - expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description'); - }); - - test('does not configure the Info.plist without an explicit permission description', () => { - const config = { name: 'test', slug: 'test' }; - - expect(withClerkFaceIDPermission(config)).toBe(config); - expect(config).not.toHaveProperty('mods'); - }); - - test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => { - expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow( - 'faceIDPermission must be a non-empty string', - ); - }); -}); +const { validateThemeJson } = clerkPlugin._testing; describe('validateThemeJson', () => { beforeEach(() => { diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx similarity index 97% rename from packages/expo/src/native/__tests__/useAuthViewState.test.tsx rename to packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx index 988f2a57729..7b16d1816ef 100644 --- a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx +++ b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types'; +import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types'; import { useAuthViewState } from '../useAuthViewState'; const mocks = vi.hoisted(() => ({ @@ -13,11 +13,11 @@ const mocks = vi.hoisted(() => ({ remove: vi.fn(), })); -vi.mock('../../hooks/useAuth', () => ({ +vi.mock('@clerk/react', () => ({ useAuth: () => mocks.auth, })); -vi.mock('../../utils/native-module', () => ({ +vi.mock('../utils/native-module', () => ({ get ClerkExpoModule() { return mocks.module; }, diff --git a/packages/expo-native-components/src/index.ts b/packages/expo-native-components/src/index.ts new file mode 100644 index 00000000000..c102a43da33 --- /dev/null +++ b/packages/expo-native-components/src/index.ts @@ -0,0 +1,49 @@ +/** + * Native UI components for Clerk authentication in Expo apps. + * + * These components provide pre-built, native authentication experiences powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * ## Installation + * + * Native components require the `@clerk/expo-native-components` config plugin in your `app.json`, alongside `@clerk/expo`: + * + * ```json + * { + * "expo": { + * "plugins": ["@clerk/expo", "@clerk/expo-native-components"] + * } + * } + * ``` + * + * Then run `npx expo prebuild` to generate native code. + * + * ## Components + * + * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline + * - {@link UserProfileView} - User profile and account management, renders inline + * - {@link UserButton} - Avatar button that opens the native user profile + * + * @module @clerk/expo-native-components + */ + +export { AuthView } from './AuthView'; +export type { AuthViewProps, AuthViewMode } from './AuthView.types'; +export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; +export { useAuthViewState } from './useAuthViewState'; +export type { UseAuthViewStateReturn } from './useAuthViewState'; +export { UserButton } from './UserButton'; +export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton'; +export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages'; +export type { + UserProfileCustomPageNavigation, + UserProfileCustomDestination, + UserProfileCustomPage, + UserProfileCustomPageIcon, + UserProfileCustomPagePlacement, + UserProfileRow, + UserProfileSection, +} from './UserProfileCustomPages'; +export { UserProfileView } from './UserProfileView'; +export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkAuthView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkAuthView.ts rename to packages/expo-native-components/src/specs/NativeClerkAuthView.ts diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.ts b/packages/expo-native-components/src/specs/NativeClerkModule.ts new file mode 100644 index 00000000000..072d011f539 --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.ts @@ -0,0 +1,6 @@ +import { requireOptionalNativeModule } from 'expo'; + +import type { Spec } from './NativeClerkModule.types'; + +// Optional so it resolves to null in Expo Go instead of throwing at import time. +export default requireOptionalNativeModule('ClerkExpo'); diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.types.ts b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts new file mode 100644 index 00000000000..9df94448a91 --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts @@ -0,0 +1,9 @@ +export type NativeAuthFlowState = { + isLoaded: boolean; + isAuthFlowComplete: boolean; +}; + +export interface Spec { + addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; + getAuthFlowState?(): Promise; +} diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.web.ts b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts new file mode 100644 index 00000000000..625a2ed22ae --- /dev/null +++ b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts @@ -0,0 +1,3 @@ +import type { Spec } from './NativeClerkModule.types'; + +export default null as Spec | null; diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserButtonView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserButtonView.ts rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserProfileView.android.ts rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts similarity index 100% rename from packages/expo/src/specs/NativeClerkUserProfileView.ts rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts diff --git a/packages/expo/src/specs/__tests__/native-view.web.test.ts b/packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts similarity index 100% rename from packages/expo/src/specs/__tests__/native-view.web.test.ts rename to packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts diff --git a/packages/expo/src/native/useAuthViewState.ts b/packages/expo-native-components/src/useAuthViewState.ts similarity index 94% rename from packages/expo/src/native/useAuthViewState.ts rename to packages/expo-native-components/src/useAuthViewState.ts index ecb793f84f1..c32f8e3e637 100644 --- a/packages/expo/src/native/useAuthViewState.ts +++ b/packages/expo-native-components/src/useAuthViewState.ts @@ -1,8 +1,8 @@ +import { useAuth } from '@clerk/react'; import { useEffect, useState } from 'react'; -import { useAuth } from '../hooks/useAuth'; -import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types'; -import { ClerkExpoModule as ClerkExpo } from '../utils/native-module'; +import type { NativeAuthFlowState } from './specs/NativeClerkModule.types'; +import { ClerkExpoModule as ClerkExpo } from './utils/native-module'; const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged'; diff --git a/packages/expo-native-components/src/utils/native-module.ts b/packages/expo-native-components/src/utils/native-module.ts new file mode 100644 index 00000000000..a416ccd8917 --- /dev/null +++ b/packages/expo-native-components/src/utils/native-module.ts @@ -0,0 +1,7 @@ +import { Platform } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; + +export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +export const ClerkExpoModule = isNativeSupported ? NativeClerkModule : null; diff --git a/packages/expo-native-components/tsconfig.declarations.json b/packages/expo-native-components/tsconfig.declarations.json new file mode 100644 index 00000000000..ac04a85ce27 --- /dev/null +++ b/packages/expo-native-components/tsconfig.declarations.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "incremental": false, + "skipLibCheck": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "sourceMap": false, + "declarationDir": "./dist" + }, + "include": ["src"], + "exclude": ["**/__tests__/**/*", "app.plugin.js"] +} diff --git a/packages/expo-native-components/tsconfig.json b/packages/expo-native-components/tsconfig.json new file mode 100644 index 00000000000..5510c6beb95 --- /dev/null +++ b/packages/expo-native-components/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "outDir": "dist", + "lib": ["es2019", "dom"], + "jsx": "react-jsx", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "importHelpers": true, + "declaration": true, + "declarationMap": false, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "target": "ES2019", + "noEmitOnError": false, + "incremental": true, + "moduleSuffixes": [".web", ".ios", ".android", ".native", ""] + }, + "include": ["src", "app.plugin.js"] +} diff --git a/packages/expo-native-components/tsconfig.test.json b/packages/expo-native-components/tsconfig.test.json new file mode 100644 index 00000000000..5635d6cd1b7 --- /dev/null +++ b/packages/expo-native-components/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + } +} diff --git a/packages/expo-native-components/tsdown.config.mts b/packages/expo-native-components/tsdown.config.mts new file mode 100644 index 00000000000..e84f7249174 --- /dev/null +++ b/packages/expo-native-components/tsdown.config.mts @@ -0,0 +1,33 @@ +import type { Options } from 'tsdown'; +import { defineConfig } from 'tsdown'; + +import { runAfterLast } from '../../scripts/utils.ts'; + +function preserveRelativeImports(id: string) { + return id.startsWith('.'); +} + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + const shouldPublish = !!overrideOptions.env?.publish; + + const options: Options = { + format: 'cjs', + fixedExtension: false, + outDir: './dist', + entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx,js}', '!./src/**/__tests__/**'], + unbundle: true, + clean: true, + minify: false, + sourcemap: true, + deps: { + // Keep relative import specifiers unchanged so Metro can apply platform-specific resolution. + neverBundle: preserveRelativeImports, + }, + define: { + __DEV__: `${isWatch}`, + }, + }; + + return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(options); +}); diff --git a/packages/expo-native-components/vitest.config.mts b/packages/expo-native-components/vitest.config.mts new file mode 100644 index 00000000000..20bc22a33db --- /dev/null +++ b/packages/expo-native-components/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { + environment: 'jsdom', + includeSource: ['**/*.{js,ts,jsx,tsx}'], + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/expo-native-components/vitest.setup.mts b/packages/expo-native-components/vitest.setup.mts new file mode 100644 index 00000000000..283c324f550 --- /dev/null +++ b/packages/expo-native-components/vitest.setup.mts @@ -0,0 +1,22 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, vi } from 'vitest'; + +// Mock globalThis.expo for expo-modules-core +if (!globalThis.expo) { + // @ts-expect-error - Mocking expo for tests + globalThis.expo = { + EventEmitter: vi.fn(), + }; +} + +// Define __DEV__ for expo-modules-core +if (typeof globalThis.__DEV__ === 'undefined') { + // @ts-expect-error - Mocking __DEV__ for tests + globalThis.__DEV__ = false; +} + +beforeAll(() => {}); + +afterEach(() => { + cleanup(); +}); diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index f8483877ff3..ecba866b513 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -1,27 +1,15 @@ /** - * Expo config plugin for @clerk/clerk-expo - * Automatically configures iOS and Android to work with Clerk native components + * Expo config plugin for @clerk/expo * * When this plugin is used: - * 1. iOS is configured with the required deployment target and metadata - * 2. Android is configured with packaging exclusions for dependencies - * - * Native modules and views are registered via Expo Modules autolinking. + * 1. Android registers the hosted auth callback intent filter + * 2. iOS gets the Sign in with Apple entitlement and, when configured, the Face ID usage description + * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs */ -const { - AndroidConfig, - withXcodeProject, - withDangerousMod, - withInfoPlist, - withAppBuildGradle, - withAndroidManifest, - withEntitlementsPlist, -} = require('@expo/config-plugins'); -const path = require('path'); -const fs = require('fs'); -const packageJson = require('./package.json'); +const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins'); -const CLERK_MIN_IOS_VERSION = '17.0'; +const CLERK_EXPO_NATIVE = '@clerk/expo-native-components'; +const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme']; const addHostedAuthIntentFilter = (mainActivity, packageName) => { const callbackHost = `${packageName}.hosted-callback`; @@ -52,80 +40,8 @@ const addHostedAuthIntentFilter = (mainActivity, packageName) => { mainActivity['intent-filter'] = intentFilters; }; -const withClerkIOS = config => { - console.log('✅ Clerk iOS plugin loaded'); - - // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install - // This ensures ClerkExpo pod gets installed (it requires iOS 17.0) - config = withDangerousMod(config, [ - 'ios', - async config => { - const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json'); - - let properties = {}; - if (fs.existsSync(podfilePropertiesPath)) { - try { - properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8')); - } catch { - // If file exists but is invalid JSON, start fresh - } - } - - // Set the iOS deployment target - if ( - !properties['ios.deploymentTarget'] || - parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION) - ) { - properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION; - fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n'); - console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`); - } - - return config; - }, - ]); - - // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK) - config = withXcodeProject(config, config => { - const xcodeProject = config.modResults; - - try { - // Update deployment target in all build configurations - const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {}; - - for (const [uuid, buildConfig] of Object.entries(buildConfigs)) { - if (buildConfig && buildConfig.buildSettings) { - const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET; - if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) { - buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION; - } - } - } - - console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`); - } catch (error) { - console.error('❌ Error updating deployment target:', error.message); - } - - return config; - }); - - config = withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkExpoVersion = packageJson.version; - return modConfig; - }); - - return config; -}; - -/** - * Add packaging exclusions to Android app build.gradle to resolve - * duplicate META-INF file conflicts from clerk-android dependencies. - */ -const withClerkAndroid = config => { - console.log('✅ Clerk Android plugin loaded'); - - config = withAndroidManifest(config, modConfig => { +const withClerkHostedAuthCallback = config => { + return withAndroidManifest(config, modConfig => { const packageName = config.android?.package; if (packageName) { const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults); @@ -133,83 +49,6 @@ const withClerkAndroid = config => { } return modConfig; }); - - return withAppBuildGradle(config, modConfig => { - let buildGradle = modConfig.modResults.contents; - - // --- META-INF exclusion --- - if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { - // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions` - const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/); - if (packagingMatch) { - const blockName = packagingMatch[0].trim().replace(/\s*\{$/, ''); - const resourcesExclude = `${blockName} { - // Clerk Android SDK: exclude duplicate META-INF files - resources { - excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] - }`; - - buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude); - } else { - // No packaging block found; append one at the end of the android block - const androidBlockEnd = buildGradle.lastIndexOf('}'); - if (androidBlockEnd !== -1) { - const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`; - buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd); - } - } - console.log('✅ Clerk Android packaging exclusions added'); - } - - // --- Kotlin metadata version check skip --- - if (!buildGradle.includes('-Xskip-metadata-version-check')) { - const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/); - if (kotlinOptionsMatch) { - buildGradle = buildGradle.replace( - /kotlinOptions\s*\{/, - `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`, - ); - } else { - const androidMatch = buildGradle.match(/android\s*\{/); - if (androidMatch) { - buildGradle = buildGradle.replace( - /android\s*\{/, - `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`, - ); - } - } - console.log('✅ Clerk Android Kotlin metadata version check skip added'); - } - - modConfig.modResults.contents = buildGradle; - return modConfig; - }); -}; - -/** - * Combined Clerk Expo plugin - * - * When this plugin is configured in app.json/app.config.js: - * 1. iOS gets the deployment target and metadata required by Clerk native views - * 2. Android gets packaging exclusions for dependency conflicts - * - * Native modules and views are registered via Expo Modules autolinking. - */ -/** - * Write ClerkKeychainService to Info.plist when keychainService is provided. - * This allows extension apps (watch, widget, app clip) to share the same - * keychain entry as the main app by using a custom service identifier. - */ -const withClerkKeychainService = (config, { keychainService } = {}) => { - if (!keychainService) { - return config; - } - - return withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkKeychainService = keychainService; - console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`); - return modConfig; - }); }; const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => { @@ -243,145 +82,75 @@ 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, secondaryButtonBackground, - * secondaryButtonForeground } (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". - * 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', - 'secondaryButtonBackground', - 'secondaryButtonForeground', -]; - -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 resolveClerkExpoNativePlugin = config => { + try { + const paths = [config._internal?.projectRoot, process.cwd()].filter(Boolean); + return require(require.resolve(`${CLERK_EXPO_NATIVE}/app.plugin.js`, { paths })); + } catch { + return null; } +}; - 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`); +const getListedPluginProps = (config, name) => { + for (const entry of config.plugins || []) { + if (entry === name) { + return {}; } - 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`); + if (Array.isArray(entry) && entry[0] === name) { + return entry[1] || {}; } } -} - -const withClerkTheme = (config, props = {}) => { - const { theme } = props; - if (!theme) return config; + return {}; +}; - // 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`); +/** + * Apply the @clerk/expo-native-components config plugin when it is installed, so apps that only list + * "@clerk/expo" keep the iOS deployment target and native SDK configuration they need. + */ +const withClerkExpoNativeComponents = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => { + const nativeProps = Object.fromEntries( + CLERK_EXPO_NATIVE_OPTIONS.filter(option => props[option] !== undefined).map(option => [option, props[option]]), + ); + const nativeOptionNames = Object.keys(nativeProps) + .map(option => `"${option}"`) + .join(', '); + + if (config._internal?.pluginHistory?.[CLERK_EXPO_NATIVE]) { + if (nativeOptionNames) { + console.warn( + `⚠️ Clerk: The following "@clerk/expo" plugin options are ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran: ${nativeOptionNames}. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`, + ); + } 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}`); + const clerkExpoNativePlugin = resolvePlugin(config); + if (!clerkExpoNativePlugin) { + if (nativeOptionNames) { + console.warn( + `⚠️ Clerk: The following "@clerk/expo" plugin options require ${CLERK_EXPO_NATIVE} and are ignored: ${nativeOptionNames}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`, + ); + } + return config; } - // iOS: Embed theme in Info.plist under "ClerkTheme" - config = withInfoPlist(config, modConfig => { - modConfig.modResults.ClerkTheme = themeJson; - console.log('✅ Embedded Clerk theme in Info.plist'); - 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; + return clerkExpoNativePlugin(config, { ...nativeProps, ...getListedPluginProps(config, CLERK_EXPO_NATIVE) }); }; const withClerkExpo = (config, props = {}) => { const { appleSignIn = true } = props; - config = withClerkIOS(config); if (appleSignIn !== false) { config = withClerkAppleSignIn(config); } - config = withClerkAndroid(config); - config = withClerkKeychainService(config, props); + config = withClerkHostedAuthCallback(config); config = withClerkFaceIDPermission(config, props); - config = withClerkTheme(config, props); + config = withClerkExpoNativeComponents(config, props); return config; }; module.exports = withClerkExpo; module.exports._testing = { addHostedAuthIntentFilter, + withClerkExpoNativeComponents, withClerkFaceIDPermission, - validateThemeJson, - isPlainObject, - VALID_COLOR_KEYS, - HEX_COLOR_REGEX, }; diff --git a/packages/expo/native/index.d.ts b/packages/expo/native/index.d.ts new file mode 100644 index 00000000000..9d8fb781ea9 --- /dev/null +++ b/packages/expo/native/index.d.ts @@ -0,0 +1 @@ +export * from '@clerk/expo-native-components'; diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json index 6ae24b71af4..928da2cc7c8 100644 --- a/packages/expo/native/package.json +++ b/packages/expo/native/package.json @@ -1,4 +1,4 @@ { "main": "../dist/native/index.js", - "types": "../dist/native/index.d.ts" + "types": "./index.d.ts" } diff --git a/packages/expo/package.json b/packages/expo/package.json index 95aa54c6c84..095cc4ddba5 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -30,7 +30,7 @@ }, "./app.plugin.js": "./app.plugin.js", "./native": { - "types": "./dist/native/index.d.ts", + "types": "./native/index.d.ts", "default": "./dist/native/index.js" }, "./web": { @@ -85,8 +85,6 @@ "types": "./dist/index.d.ts", "files": [ "dist", - "android", - "ios", "native", "web", "local-credentials", @@ -99,9 +97,6 @@ "hosted-auth", "experimental", "legacy", - "src/specs", - "expo-module.config.json", - "react-native.config.js", "app.plugin.js", "app.plugin.d.ts" ], @@ -127,6 +122,7 @@ }, "devDependencies": { "@clerk/expo-google-signin": "workspace:*", + "@clerk/expo-native-components": "workspace:*", "@clerk/expo-passkeys": "workspace:*", "@expo/config-plugins": "^54.0.4", "@types/base-64": "^1.0.2", @@ -142,6 +138,7 @@ }, "peerDependencies": { "@clerk/expo-google-signin": ">=0.1.0", + "@clerk/expo-native-components": ">=0.1.0", "@clerk/expo-passkeys": ">=0.0.6", "expo": "catalog:peer-expo", "expo-apple-authentication": ">=7.0.0", @@ -159,6 +156,9 @@ "@clerk/expo-google-signin": { "optional": true }, + "@clerk/expo-native-components": { + "optional": true + }, "@clerk/expo-passkeys": { "optional": true }, @@ -192,10 +192,5 @@ }, "publishConfig": { "access": "public" - }, - "codegenConfig": { - "name": "ClerkExpoSpec", - "type": "all", - "jsSrcsDir": "src/specs" } } diff --git a/packages/expo/src/__tests__/appPlugin.test.js b/packages/expo/src/__tests__/appPlugin.test.js new file mode 100644 index 00000000000..55f9e5abdc3 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.test.js @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { withClerkExpoNativeComponents, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing; + +function applyInfoPlistMod(config, modResults) { + return config.mods.ios.infoPlist({ + ...config, + modRequest: {}, + modResults, + }); +} + +describe('withClerkFaceIDPermission', () => { + test('adds the configured Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' }, + ); + + const result = await applyInfoPlistMod(config, {}); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.'); + }); + + test('preserves an app-provided Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Clerk-provided description' }, + ); + + const result = await applyInfoPlistMod(config, { + NSFaceIDUsageDescription: 'App-provided description', + }); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description'); + }); + + test('does not configure the Info.plist without an explicit permission description', () => { + const config = { name: 'test', slug: 'test' }; + + expect(withClerkFaceIDPermission(config)).toBe(config); + expect(config).not.toHaveProperty('mods'); + }); + + test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => { + expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow( + 'faceIDPermission must be a non-empty string', + ); + }); +}); + +describe('withClerkExpoNativeComponents', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('applies the @clerk/expo-native-components plugin with its options when it is installed', () => { + const nativePlugin = vi.fn(config => ({ ...config, applied: true })); + const config = { name: 'test', slug: 'test' }; + + const result = withClerkExpoNativeComponents( + config, + { keychainService: 'com.example.shared', theme: './theme.json', appleSignIn: false }, + () => nativePlugin, + ); + + expect(result.applied).toBe(true); + expect(nativePlugin).toHaveBeenCalledWith(config, { keychainService: 'com.example.shared', theme: './theme.json' }); + }); + + test('prefers options passed to an explicitly listed @clerk/expo-native-components plugin', () => { + const nativePlugin = vi.fn(config => config); + const config = { + name: 'test', + slug: 'test', + plugins: ['@clerk/expo', ['@clerk/expo-native-components', { theme: './native-theme.json' }]], + }; + + withClerkExpoNativeComponents( + config, + { keychainService: 'com.example.shared', theme: './theme.json' }, + () => nativePlugin, + ); + + expect(nativePlugin).toHaveBeenCalledWith(config, { + keychainService: 'com.example.shared', + theme: './native-theme.json', + }); + }); + + test('does not apply the @clerk/expo-native-components plugin twice', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const nativePlugin = vi.fn(config => config); + const config = { + name: 'test', + slug: 'test', + _internal: { + pluginHistory: { '@clerk/expo-native-components': { name: '@clerk/expo-native-components', version: '0.0.1' } }, + }, + }; + + expect(withClerkExpoNativeComponents(config, {}, () => nativePlugin)).toBe(config); + expect(nativePlugin).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + + withClerkExpoNativeComponents(config, { theme: './theme.json' }, () => nativePlugin); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Pass them to the "@clerk/expo-native-components" plugin instead'), + ); + }); + + test('leaves the config untouched when @clerk/expo-native-components is not installed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { name: 'test', slug: 'test' }; + + expect(withClerkExpoNativeComponents(config, {}, () => null)).toBe(config); + expect(warn).not.toHaveBeenCalled(); + }); + + test('warns with install instructions when native-only options are passed without @clerk/expo-native-components', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = { name: 'test', slug: 'test' }; + + expect(withClerkExpoNativeComponents(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native-components')); + }); +}); diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index dee295ff1a5..0c15d195c86 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ useClerk: vi.fn(), setActive: vi.fn(), synchronizeNativeClientToJs: vi.fn(), + isNativeModuleInstalled: true, nativeModule: { getTrustedDeviceAvailability: vi.fn(), listTrustedDevices: vi.fn(), @@ -38,7 +39,9 @@ vi.mock('@clerk/react', () => ({ })); vi.mock('../../utils/native-module', () => ({ - ClerkExpoModule: mocks.nativeModule, + get ClerkExpoModule() { + return mocks.isNativeModuleInstalled ? mocks.nativeModule : null; + }, })); vi.mock('react-native', () => ({ @@ -539,13 +542,25 @@ describe('useBiometricCredentials on iOS', () => { }); }); + test('explains how to install @clerk/expo-native-components when the native module is missing', async () => { + mocks.isNativeModuleInstalled = false; + + try { + await expect(renderBiometricCredentials().signIn()).rejects.toThrow( + 'Biometric credentials require the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`', + ); + } finally { + mocks.isNativeModuleInstalled = true; + } + }); + test('explains that the development client must contain the native methods', async () => { const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); try { await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo.', + 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', ); } finally { Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts index 3e92e5287be..65af8878503 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts @@ -14,6 +14,9 @@ import type { const DEFAULT_POLICY = 'biometry_current_set'; +const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS = + 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.'; + function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform { return platform === 'ios' || platform === 'android' ? platform : 'unknown'; } @@ -25,15 +28,21 @@ function toBiometricCredentialStatus(status: string): BiometricCredentialStatus function getNativeModule(): NativeBiometricCredentialModule { const nativeModule = ClerkExpoModule; + if (!nativeModule) { + return errorThrower.throw( + `Biometric credentials require the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`, + ); + } + if ( - !nativeModule?.getTrustedDeviceAvailability || + !nativeModule.getTrustedDeviceAvailability || !nativeModule.listTrustedDevices || !nativeModule.enrollTrustedDevice || !nativeModule.revokeTrustedDevice || !nativeModule.signInWithTrustedDevice ) { return errorThrower.throw( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo.', + 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', ); } @@ -86,7 +95,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom const nativeModule = getNativeModule(); if (typeof nativeModule.reverifyWithBiometrics !== 'function') { return errorThrower.throw( - 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.', + 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.', ); } const level = params?.level ?? 'first_factor'; diff --git a/packages/expo/src/native/__tests__/index.test.ts b/packages/expo/src/native/__tests__/index.test.ts new file mode 100644 index 00000000000..f7904bb2a8b --- /dev/null +++ b/packages/expo/src/native/__tests__/index.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const importNativeEntry = () => import('../index'); + +describe('@clerk/expo/native', () => { + afterEach(() => { + vi.doUnmock('../loadClerkExpoNative'); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + describe('when @clerk/expo-native-components is not installed', () => { + beforeEach(() => { + vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => null })); + }); + + test.each(['AuthView', 'UserButton', 'UserProfileView'] as const)( + 'rendering %s throws an error with install instructions', + async name => { + const Component = (await importNativeEntry())[name] as (props: object) => unknown; + + expect(() => Component({})).toThrow( + /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native-components package\. Install it with `npx expo install @clerk\/expo-native-components`/, + ); + }, + ); + + test.each(['useAuthViewState', 'useUserProfileCustomPageNavigation'] as const)( + 'calling %s throws an error with install instructions', + async name => { + const hook = (await importNativeEntry())[name] as () => unknown; + + expect(() => hook()).toThrow(`\`${name}\` is unavailable.`); + expect(() => hook()).toThrow('add "@clerk/expo-native-components" to the plugins array in your app config'); + }, + ); + }); + + test('re-exports @clerk/expo-native-components when it is installed', async () => { + const clerkExpoNative = { + AuthView: vi.fn(() => null), + UserButton: vi.fn(() => null), + UserProfileView: vi.fn(() => null), + useAuthViewState: vi.fn(), + useUserProfileCustomPageNavigation: vi.fn(), + }; + vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => clerkExpoNative })); + + const nativeEntry = await importNativeEntry(); + + expect(nativeEntry.AuthView).toBe(clerkExpoNative.AuthView); + expect(nativeEntry.UserButton).toBe(clerkExpoNative.UserButton); + expect(nativeEntry.UserProfileView).toBe(clerkExpoNative.UserProfileView); + expect(nativeEntry.useAuthViewState).toBe(clerkExpoNative.useAuthViewState); + expect(nativeEntry.useUserProfileCustomPageNavigation).toBe(clerkExpoNative.useUserProfileCustomPageNavigation); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 2a62047dd85..67acaa9ab68 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -1,49 +1,27 @@ -/** - * Native UI components for Clerk authentication in Expo apps. - * - * These components provide pre-built, native authentication experiences powered by: - * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios - * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android - * - * ## Installation - * - * Native components require the `@clerk/expo` plugin to be configured in your `app.json`: - * - * ```json - * { - * "expo": { - * "plugins": ["@clerk/expo"] - * } - * } - * ``` - * - * Then run `npx expo prebuild` to generate native code. - * - * ## Components - * - * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline - * - {@link UserProfileView} - User profile and account management, renders inline - * - {@link UserButton} - Avatar button that opens the native user profile - * - * @module @clerk/expo/native - */ +import { errorThrower } from '../errorThrower'; +import type { ClerkExpoNativeModule } from './loadClerkExpoNative'; +import { loadClerkExpoNative } from './loadClerkExpoNative'; -export { AuthView } from './AuthView'; -export type { AuthViewProps, AuthViewMode } from './AuthView.types'; -export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; -export { useAuthViewState } from './useAuthViewState'; -export type { UseAuthViewStateReturn } from './useAuthViewState'; -export { UserButton } from './UserButton'; -export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton'; -export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages'; -export type { - UserProfileCustomPageNavigation, - UserProfileCustomDestination, - UserProfileCustomPage, - UserProfileCustomPageIcon, - UserProfileCustomPagePlacement, - UserProfileRow, - UserProfileSection, -} from './UserProfileCustomPages'; -export { UserProfileView } from './UserProfileView'; -export type { UserProfileViewProps } from './UserProfileView'; +// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native-components. + +const CLERK_EXPO_NATIVE_MISSING_MESSAGE = + 'Native components have moved to the @clerk/expo-native-components package. ' + + 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, ' + + 'then rebuild your native app. You can then import them from "@clerk/expo-native-components".'; + +const clerkExpoNative = loadClerkExpoNative(); + +function resolveExport(name: K) { + return ( + clerkExpoNative?.[name] ?? + function ClerkExpoNativeMissing(): never { + return errorThrower.throw(`\`${name}\` is unavailable. ${CLERK_EXPO_NATIVE_MISSING_MESSAGE}`); + } + ); +} + +export const AuthView = resolveExport('AuthView'); +export const UserButton = resolveExport('UserButton'); +export const UserProfileView = resolveExport('UserProfileView'); +export const useAuthViewState = resolveExport('useAuthViewState'); +export const useUserProfileCustomPageNavigation = resolveExport('useUserProfileCustomPageNavigation'); diff --git a/packages/expo/src/native/loadClerkExpoNative.ts b/packages/expo/src/native/loadClerkExpoNative.ts new file mode 100644 index 00000000000..194b0bed1d9 --- /dev/null +++ b/packages/expo/src/native/loadClerkExpoNative.ts @@ -0,0 +1,18 @@ +type ClerkExpoNativeExport = (...args: never[]) => unknown; + +export type ClerkExpoNativeModule = Partial< + Record< + 'AuthView' | 'UserButton' | 'UserProfileView' | 'useAuthViewState' | 'useUserProfileCustomPageNavigation', + ClerkExpoNativeExport + > +>; + +export function loadClerkExpoNative(): ClerkExpoNativeModule | null { + try { + // Synchronous require() in try/catch so Metro treats @clerk/expo-native-components as an optional dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@clerk/expo-native-components') as ClerkExpoNativeModule; + } catch { + return null; + } +} diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5f40ed5287c..5cdb87dc6ae 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1163,7 +1163,7 @@ export function useNativeClientBootstrap({ if (__DEV__) { console.debug( `[ClerkProvider] Native Clerk module not available. ` + - `To enable native features, add "@clerk/expo" to your app.json plugins array.`, + `To enable native features, install @clerk/expo-native-components and add "@clerk/expo-native-components" to your app.json plugins array.`, ); } } else if (__DEV__) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2b32a7f6a..d37b3a454fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -662,6 +662,9 @@ importers: '@clerk/expo-google-signin': specifier: workspace:* version: link:../expo-google-signin + '@clerk/expo-native-components': + specifier: workspace:* + version: link:../expo-native-components '@clerk/expo-passkeys': specifier: workspace:* version: link:../expo-passkeys @@ -708,6 +711,28 @@ importers: specifier: ~54.0.36 version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) + packages/expo-native-components: + dependencies: + '@clerk/react': + specifier: workspace:^ + version: link:../react + expo: + specifier: catalog:peer-expo + version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) + react: + specifier: 18.3.1 + version: 18.3.1 + tslib: + specifier: catalog:repo + version: 2.8.1 + devDependencies: + '@expo/config-plugins': + specifier: ^54.0.4 + version: 54.0.5 + react-native: + specifier: ^0.86.0 + version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10) + packages/expo-passkeys: dependencies: '@clerk/shared':