Skip to content

@clerk/expo: useSSO/useOAuth dynamic import() of expo-auth-session/expo-web-browser fails under Metro, masked by empty catch #8288

@sethwebster

Description

@sethwebster

Summary

useSSO / useOAuth in @clerk/expo use a dynamic import() for expo-auth-session and expo-web-browser wrapped in an empty catch {}. When Metro's async-chunk resolution fails (not uncommon in monorepo / bun setups, or when @expo/metro-runtime isn't imported at the entry), every underlying error is swallowed and surfaces as the misleading:

expo-auth-session and expo-web-browser are required for SSO. Install them: npx expo install expo-auth-session expo-web-browser

…even though both packages are installed, listed in package.json, and being bundled by Metro.

Offending code

packages/expo/src/hooks/useSSO.ts (built as dist/hooks/useSSO.js):

let AuthSession;
let WebBrowserModule;
try {
  [AuthSession, WebBrowserModule] = await Promise.all([
    import("expo-auth-session"),
    import("expo-web-browser"),
  ]);
} catch {
  return errorThrower.throw(
    "expo-auth-session and expo-web-browser are required for SSO. Install them: npx expo install expo-auth-session expo-web-browser"
  );
}

Two problems:

  1. Dynamic import() from a published bundle is fragile under Metro. It forces Metro to emit async chunks; if the runtime loader isn't set up (e.g. @expo/metro-runtime not imported), the promise rejects and the real error never surfaces. A synchronous require() would be bundled into the main chunk and "just work". This is the same class of issue fixed for the RN QueryClient in fix(expo): synchronous QueryClient for React Native #8087 — dynamic import from inside the published bundle broken under Metro, sync setup resolves it.
  2. catch {} hides the real error. Even when the underlying failure is Unable to resolve module, @expo/metro-runtime is not installed, or a genuine TypeError, users see a generic "install these packages" message that sends them in the wrong direction.

Environment

  • @clerk/expo@3.1.9
  • Expo SDK 55 (expo@^55.0.11, expo-router@~55.0.10)
  • Metro (default @expo/metro-config)
  • expo-auth-session@~55.0.13, expo-web-browser@~55.0.14 both installed
  • bun workspaces monorepo
  • iOS (dev client + simulator)

Repro

  1. Bun-workspaces monorepo, @clerk/expo + expo-auth-session + expo-web-browser all correctly declared in apps/mobile/package.json.

  2. Don't import @expo/metro-runtime at the entry (it's not a declared dep of @clerk/expo or expo-router in this setup, so it's easy to miss).

  3. Call startSSOFlow({ strategy: 'oauth_google' }) from a screen.

  4. Metro logs show both packages bundling successfully:

    iOS Bundled 321ms …/expo-auth-session/build/index.js (1018 modules)
    iOS Bundled 347ms …/expo-web-browser/build/WebBrowser.js (1145 modules)
    
  5. At runtime, the dynamic import() inside useSSO rejects and Clerk throws the "required for SSO" error.

Root cause in our case: @expo/metro-runtime wasn't imported at the app entry, so async chunks produced by Metro couldn't be loaded. Adding import '@expo/metro-runtime' at the top of the root layout fixed the runtime loading — but only after hours of wrong turns because the Clerk error pointed at package installation, not bundler/runtime.

Current workaround

We ship a postinstall patch that rewrites the dynamic imports to require():

// scripts/patch-clerk-expo-sso.mjs
const SNIPPET_FROM =
  '[AuthSession, WebBrowserModule] = await Promise.all([import("expo-auth-session"), import("expo-web-browser")]);';
const SNIPPET_TO = `AuthSession = require("expo-auth-session");
      WebBrowserModule = require("expo-web-browser");`;

Applied to node_modules/@clerk/expo/dist/hooks/useSSO.js and useOAuth.js. This eliminates the problem entirely on native because Metro inlines both modules into the main bundle instead of emitting async chunks.

Proposed fix

Either (or both):

  1. Prefer synchronous require() on native (same spirit as fix(expo): synchronous QueryClient for React Native #8087). expo-auth-session / expo-web-browser are tiny and already listed as optional peer deps — nothing is gained by deferring them. require() is Metro-safe and removes the async-chunk failure mode entirely.

  2. At minimum, surface the real error:

    try {
      [AuthSession, WebBrowserModule] = await Promise.all([
        import("expo-auth-session"),
        import("expo-web-browser"),
      ]);
    } catch (e) {
      return errorThrower.throw(
        \`expo-auth-session and expo-web-browser are required for SSO. If they are installed, this usually means Metro failed to resolve the dynamic import() — ensure \\\`@expo/metro-runtime\\\` is imported at your app entry. Underlying error: \${e?.message ?? e}\`
      );
    }

    The catch {} is actively hostile to debugging — it converts every possible failure mode (bundler, runtime, native module, version mismatch) into a single wrong-suggestion error.

Happy to open a PR if the direction is agreed.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions