You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
letAuthSession;letWebBrowserModule;try{[AuthSession,WebBrowserModule]=awaitPromise.all([import("expo-auth-session"),import("expo-web-browser"),]);}catch{returnerrorThrower.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:
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.
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
Bun-workspaces monorepo, @clerk/expo + expo-auth-session + expo-web-browser all correctly declared in apps/mobile/package.json.
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).
Call startSSOFlow({ strategy: 'oauth_google' }) from a screen.
Metro logs show both packages bundling successfully:
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():
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):
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.
At minimum, surface the real error:
try{[AuthSession,WebBrowserModule]=awaitPromise.all([import("expo-auth-session"),import("expo-web-browser"),]);}catch(e){returnerrorThrower.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.
Summary
useSSO/useOAuthin@clerk/expouse a dynamicimport()forexpo-auth-sessionandexpo-web-browserwrapped in an emptycatch {}. When Metro's async-chunk resolution fails (not uncommon in monorepo / bun setups, or when@expo/metro-runtimeisn't imported at the entry), every underlying error is swallowed and surfaces as the misleading:…even though both packages are installed, listed in
package.json, and being bundled by Metro.Offending code
packages/expo/src/hooks/useSSO.ts(built asdist/hooks/useSSO.js):Two problems:
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-runtimenot imported), the promise rejects and the real error never surfaces. A synchronousrequire()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.catch {}hides the real error. Even when the underlying failure isUnable to resolve module,@expo/metro-runtime is not installed, or a genuineTypeError, users see a generic "install these packages" message that sends them in the wrong direction.Environment
@clerk/expo@3.1.9expo@^55.0.11,expo-router@~55.0.10)@expo/metro-config)expo-auth-session@~55.0.13,expo-web-browser@~55.0.14both installedRepro
Bun-workspaces monorepo,
@clerk/expo+expo-auth-session+expo-web-browserall correctly declared inapps/mobile/package.json.Don't import
@expo/metro-runtimeat the entry (it's not a declared dep of@clerk/expoorexpo-routerin this setup, so it's easy to miss).Call
startSSOFlow({ strategy: 'oauth_google' })from a screen.Metro logs show both packages bundling successfully:
At runtime, the dynamic
import()insideuseSSOrejects and Clerk throws the "required for SSO" error.Root cause in our case:
@expo/metro-runtimewasn't imported at the app entry, so async chunks produced by Metro couldn't be loaded. Addingimport '@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
postinstallpatch that rewrites the dynamic imports torequire():Applied to
node_modules/@clerk/expo/dist/hooks/useSSO.jsanduseOAuth.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):
Prefer synchronous
require()on native (same spirit as fix(expo): synchronous QueryClient for React Native #8087).expo-auth-session/expo-web-browserare 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.At minimum, surface the real error:
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
fix(expo): synchronous QueryClient for React Native(same class of dynamic-import-in-Metro fix)