# Troubleshooting the Expo app | Expo Supabase SaaS Kit

> Diagnose and fix the failures you will actually hit in apps/native — stale Metro caches, expo-doctor false positives, Android networking, iOS signing, env-var crashes and i18n generator errors.

*Canonical: https://makerkit.dev/docs/react-native-supabase/troubleshooting*

---

This page is organised by the **symptom you see**, not by subsystem. Each entry states what appears on screen or in the terminal, why the kit produces it, and what to do. Everything here is specific to `apps/native` in this repository — where a fix belongs to another page, it links there rather than restating it.

If you have not yet got the app running once, start with `docs/native/running-locally.mdoc`; several symptoms below are just a missing prerequisite.

## Triage table

| What you see | Go to |
| --- | --- |
| `Unable to resolve module …`, or code that changed on disk does not change in the app | [Metro serves something stale](#metro-serves-something-stale) |
| `pnpm typecheck` errors that do not match the code; unknown route names after a rename | [Typecheck disagrees with the code](#typecheck-disagrees-with-the-code) |
| `expo-doctor` reports duplicate `react` / `react-dom` | [expo-doctor duplicate dependency warning](#expo-doctor-flags-react-react-dom-as-duplicates) |
| `Invalid hook call` | [Two copies of React in the bundle](#invalid-hook-call) |
| Requests fail on the Android emulator but work on the iOS simulator | [The app cannot reach Supabase or the API](#the-app-cannot-reach-supabase-or-the-api) |
| Avatars render on iOS but not Android, or a broken avatar on web after an Android upload | [Images load on one platform only](#images-load-on-one-platform-only) |
| A `formSheet` or `modal` route is blank or clipped, with no error | [A sheet or modal renders blank or clipped](#a-sheet-or-modal-route-renders-blank-or-clipped) |
| The app crashes at launch with `Missing EXPO_PUBLIC_…` | [Environment variable errors](#environment-variable-errors) |
| `No code signing certificates` on `expo run:ios` | [iOS build fails signing](#ios-build-fails-with-no-code-signing-certificates) |
| `xcodebuild` exits 70 with `Unable to find a destination matching …` | [The simulator exists but Xcode will not build to it](#the-simulator-exists-but-xcode-will-not-build-to-it) |
| Auth emails never arrive; GoTrue 500 after a template edit; native auth breaks after enabling captcha | [Auth and email failures](#auth-and-email-failures) |
| `i18n:native — N problem(s) found`, or `⟦some.key⟧` on screen | [i18n generator and runtime errors](#i18n-generator-and-runtime-errors) |
| A newly installed native module is not found at runtime | [A new native module does not work](#a-new-native-module-does-not-work) |
| Every `catalog:expo` reference turned into a literal version | [The catalog pinning was rewritten](#the-catalog-pinning-was-rewritten) |
| `useAuth must be used inside <AuthProvider>` and similar | [Provider-boundary errors](#provider-boundary-errors) |
| The app drops you back to sign-in on its own | [The app signs you out unexpectedly](#the-app-signs-you-out-unexpectedly) |

---

## Metro serves something stale

**Symptom.** `Unable to resolve module <name> from …` for a package that is plainly in `node_modules`. Or an edit you made does not appear, or an old version of a module is bundled after you switched branches.

**Cause.** Metro caches both transforms and module resolution. A dependency add or remove, an edit to `apps/native/metro.config.js`, or a branch switch that moves dependencies leaves that cache describing a tree that no longer exists.

**Fix.**

```bash
pnpm start:native:clear
```

That maps to `expo start --clear` in `apps/native/package.json` (with the i18n generator run first, as every start script does).

The rule of thumb: **clear the cache when the error disagrees with what is on disk.** If the module really is missing from `node_modules`, clearing achieves nothing — run `pnpm install` instead. Neither cache command belongs in a normal edit-run loop.

## Typecheck disagrees with the code

**Symptom.** `pnpm typecheck` reports errors that do not correspond to anything in the source, or expo-router route names are stale after you renamed or moved a file under `apps/native/app/`.

**Cause.** Two generated artefacts feed the typecheck. `apps/native/tsconfig.json` includes `.expo/types/**/*.ts` — the expo-router route types Metro writes — and sets `tsBuildInfoFile` to `node_modules/.cache/tsbuildinfo.json` for incremental builds. Either can outlive the change that invalidated it.

**Fix.**

```bash
pnpm clean:native:cache
```

It runs `git clean -xdf .turbo .expo node_modules/.cache` inside `apps/native`, so it drops the Turbo cache, the generated `.expo` types and the tsc build info — and **keeps `node_modules`**. Reinstalling dependencies is not part of this fix. If you do want a full wipe, `pnpm --filter native-app clean` removes `node_modules` too; there is no root-level alias for that on purpose.

Route types come back the next time Metro runs, so start the dev server once after cleaning if you rely on typed routes.

{% alert type="info" title="Typecheck also runs the i18n check" %}
`apps/native`'s `typecheck` script is `node ../../tooling/scripts/src/i18n-native.mjs && tsc --noEmit`. A failure that mentions `messages.generated.ts` is the catalogue check, not TypeScript — see [i18n generator and runtime errors](#i18n-generator-and-runtime-errors).
{% /alert %}

## expo-doctor flags react / react-dom as duplicates

**Symptom.** `pnpm doctor:native` fails the check *"Check that no duplicate dependencies are installed"*, naming `react` and `react-dom`.

**This one is an expected false positive.** Native and web deliberately run different React versions:

| App | React version | Pinned where |
| --- | --- | --- |
| `apps/native` | `19.2.3` | `catalogs.expo` in `pnpm-workspace.yaml` — the exact version `react-native@0.86.0`'s bundled renderer was compiled against |
| `apps/web` | `19.2.8` | the root `catalog:` in `pnpm-workspace.yaml` |

Verify the numbers rather than trusting any document, including this one:

```bash
node -p "require('./apps/native/node_modules/react/package.json').version"
node -p "require('./apps/web/node_modules/react/package.json').version"
```

The two live in **separate pnpm-isolated trees**. Native's bundle only ever resolves its own copy, and `apps/native/metro.config.js` additionally forces `react` and `react-dom` to resolve from `apps/native` no matter which package imports them. Doctor's scanner walks `node_modules/.pnpm/` and counts instances across the whole store without modelling that isolation, so the count is real but the conclusion is not.

{% alert type="warning" title="Do not dismiss the whole check" %}
If a **native module** — anything with an iOS/Android side, such as `react-native-svg` or an `expo-*` package — appears in that duplicate list, it is genuine and will break the build or produce two instances of a native singleton. Investigate with `pnpm why <pkg>` and resolve it in `pnpm-workspace.yaml`.
{% /alert %}

## Invalid hook call

**Symptom.** `Invalid hook call. Hooks can only be called inside of the body of a function component.` on a screen that looks correct.

**Cause.** Two copies of React in the bundle. Metro resolves bare imports by walking up from the importing file, so importing a React-bearing `@kit/*` subpath can pull a second React in from that package's own tree.

**Fix.** `apps/native/metro.config.js` already guards against this by re-anchoring `react` and `react-dom` resolution to `apps/native/package.json`. If you hit the error, check that the `SINGLETONS` block in that file is intact, then clear the Metro cache (`pnpm start:native:clear`) so the old resolution map is discarded. If a `@kit/*` package started declaring its own `react` dependency, that is the thing to fix.

## The app cannot reach Supabase or the API

**Symptom.** Sign-in hangs or fails, and `/api/v1` queries error, on the **Android emulator** — while the iOS simulator is fine. Or the reverse on a physical device.

**What the kit already does for you.** `apps/native/features/core/dev-host.ts` exports `resolveDevHost(url)`, which rewrites `http(s)://127.0.0.1` **and** `http(s)://localhost` to `10.0.2.2` — Android's fixed alias for the host machine's loopback — but only when `__DEV__ && Platform.OS === 'android'`:

```ts
export function resolveDevHost(url: string): string {
  if (!__DEV__ || Platform.OS !== 'android') return url;

  return url.replace(
    /^(https?:\/\/)(127\.0\.0\.1|localhost)(?=[:/]|$)/i,
    '$110.0.2.2',
  );
}
```

It is wired into both `features/core/supabase.ts` and `features/core/api-client.ts`, so the committed `apps/native/.env.development` works on both platforms with no override. LAN IPs and remote URLs pass through untouched, and the `__DEV__` guard means it can never fire in a production build.

**So the usual cause is something else.** Work down this list:

| Check | How |
| --- | --- |
| Is the Supabase stack running? | `pnpm run supabase:web:start` — native points at `http://127.0.0.1:54321` |
| Is the Next.js app running? | `pnpm dev` — `/api/v1` is served by `apps/web`, not by Metro |
| Physical device? | Loopback means the phone itself. Set `EXPO_PUBLIC_SUPABASE_URL` / `EXPO_PUBLIC_API_BASE_URL` to your machine's LAN IP in `apps/native/.env.development.local` |
| Did you override the URL locally? | A non-loopback value in `.env.development.local` **defeats the rewrite by design** — that is the documented opt-out for `adb reverse` workflows. Remove the override to get the automatic behaviour back |
| Did you just edit an env file? | `EXPO_PUBLIC_*` values are inlined at build time. Restart the dev server; clear the cache if the old value survives |

Full device and environment setup is in `docs/native/running-locally.mdoc`; the API client itself is covered in `docs/native/data-fetching.mdoc`.

## Images load on one platform only

### Avatars are blank on Android, fine on iOS

**Cause.** React Native's `<Image>` fetches the URL directly — it never goes through the Supabase client, so it never gets the loopback rewrite that `features/core/supabase.ts` applies.

**Fix.** Apply `resolveImageUri` at the `<Image source={{ uri }}>` boundary. It is the null-safe variant of `resolveDevHost` and returns `undefined` for empty input, so a `uri ? … : null` guard keeps working:

```tsx
import { resolveImageUri } from '~/features/core/dev-host';

<AvatarImage source={{ uri: resolveImageUri(pictureUrl) }} />;
```

Existing call sites you can copy from: `apps/native/features/makerkit/components/workspace-avatar.tsx`, `apps/native/app/(home)/user-menu.tsx`, and `apps/native/app/(home)/(user)/(tabs)/settings/index.tsx`.

### An avatar uploaded from Android is broken on web and the iOS simulator

**Cause.** The inverse hazard. `getPublicUrl()` builds its URL from the client's host, which on Android in dev is the *rewritten* one — so an upload would persist `http://10.0.2.2:54321/…` into the **shared local database**, where the web app and the iOS simulator cannot resolve it.

**Fix.** `canonicalizeStorageHost(url)` reverses the rewrite before the URL is persisted, and `apps/native/features/makerkit/components/picture-controls.tsx` already calls it around `getPublicUrl`. If you add a new upload path, run the public URL through it before writing the value anywhere. To repair a row already stored with `10.0.2.2`, re-upload the image from a client on the canonical host, or correct the stored URL directly.

## A sheet or modal route renders blank or clipped

**Symptom.** A route presented as `formSheet`, `modal` or `transparentModal` comes up empty, or its content is cut off — while the same components render correctly on a normal screen. Nothing errors.

**Cause.** These routes wrap their content in `SafeAreaView` with `flex-1`, which is needed because the form-sheet presentation only handles its own insets on iOS — on Android the route is a full-height slide-up modal that would otherwise run under the status bar. A **direct child** of that `SafeAreaView` given `flex-1` does not stretch to fill it; it collapses to zero height and pushes the content off-screen.

**Fix.** Give the child intrinsic height instead of `flex-1`:

```tsx
<SafeAreaView edges={['top', 'bottom']} className="bg-background flex-1">
  <View className="p-6">{/* intrinsic height — not flex-1 */}</View>
</SafeAreaView>
```

When the content has to scroll, make the root a `ScrollView` rather than reaching for `flex-1` again — `app/(home)/user-menu.tsx` and `app/(home)/workspace-switcher.tsx` both do this.

`app/(home)/theme.tsx` is the intrinsic-height pattern, and `app/update-password.tsx` carries an inline comment at the point where this bit.

{% alert type="info" title="Do not add a KeyboardAvoidingView here" %}
iOS lifts a sheet for the keyboard on its own, so a `KeyboardAvoidingView` inside one fights the presentation. `KeyboardAvoidingScreen` is for headerless full-screen forms such as the auth stack — see `docs/native/forms.mdoc` for which container belongs where.
{% /alert %}

## Environment variable errors

Three errors come straight from source. The message tells you which file to edit.

| Error text | Thrown by | When | Fix |
| --- | --- | --- | --- |
| `Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_PUBLIC_KEY. Set them in apps/native/.env.development before running expo start.` | `apps/native/features/core/supabase.ts` | At module scope — the app fails to boot | Set both in `apps/native/.env.development` (or `.env.development.local`) and restart the dev server |
| `Missing EXPO_PUBLIC_API_BASE_URL. Set it in apps/native/.env.development.` | `apps/native/features/core/api-client.ts` | Inside `api()` — surfaces as a failed query, not a crash | Set `EXPO_PUBLIC_API_BASE_URL` to the URL of the running `apps/web` |
| `EXPO_PUBLIC_API_BASE_URL must use https:// in a production build.` | `apps/native/features/core/api-client.ts` | Only when `!__DEV__` | Point release builds at an HTTPS origin. The guard exists so a bearer token is never sent over cleartext in a shipped binary |

The first fires at import time because a Supabase client with no URL cannot be constructed at all; the second and third fire per request, so a misconfigured API base URL leaves the app running with every server query failing.

Because `EXPO_PUBLIC_*` values are inlined into the JS bundle, changing one in a release build requires a **new build** — not a restart, and not an over-the-air toggle. `docs/native/config-requirements.mdoc` lists every flag and what changing it forces elsewhere.

## The simulator exists but Xcode will not build to it

**Symptom.** `expo run:ios` (or `pnpm ios:native`) fails after *"Planning build"* with `0 error(s), and 0 warning(s)`, and `xcodebuild` exits with code 70:

```
xcodebuild: error: Unable to find a destination matching the provided destination specifier:
                { id:9C4265C0-A435-405A-A831-2FB552C1E4C4 }

        Ineligible destinations for the "MakerkitNative" scheme:
                { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder,
                  name:Any iOS Device, error:iOS 26.5 is not installed.
                  Please download and install the platform from Xcode > Settings > Components. }
```

The confusing part is that the simulator in that `id:` is real — it is listed by `simctl`, it boots, and you can use it from Simulator.app.

**Cause.** Since Xcode 15 the iOS **platform** is a separate download from Xcode itself. Xcode ships the SDK (`xcodebuild -showsdks` will happily list `iphonesimulator26.5`), but not the runtime support that goes with it. Until that platform is installed, Xcode's build layer drops **every** iOS destination — not just the version it is missing, but also older simulator runtimes you already have on disk.

`simctl` talks to CoreSimulator directly and never consults that layer, which is what makes the failure look contradictory: the simulator is genuinely healthy, Expo selects it through `simctl`, and then `xcodebuild` refuses the handoff.

**This is not a deployment-target problem.** `apps/native/ios` is generated with `IPHONEOS_DEPLOYMENT_TARGET = 16.4` (from `platform :ios` in the `Podfile`), so an older simulator is well within range. Lowering it, editing the scheme, or picking a different simulator changes nothing.

**Diagnose.** Run these from `apps/native/ios`:

| Command | What a broken setup shows |
| --- | --- |
| `xcodebuild -workspace MakerkitNative.xcworkspace -scheme MakerkitNative -showdestinations` | **Zero** eligible destinations — no simulators, not even an "Any iOS Simulator Device" placeholder |
| `xcodebuild -showsdks` | The SDK version Xcode expects a platform for, e.g. `iphonesimulator26.5` |
| `xcrun simctl list runtimes` | The runtimes actually installed — likely nothing matching that SDK |
| `xcrun simctl runtime match list` | Under `iphoneos<version>`, the *Chosen Runtime* build Xcode wants |

If `-showdestinations` lists simulators and only the physical-device placeholder is ineligible, you have a different problem — that placeholder is always ineligible without the device platform, and is harmless for simulator builds.

**Fix.** Install the missing platform. It is a one-time download of roughly 8–10 GB:

```bash
xcodebuild -downloadPlatform iOS
```

Or **Xcode → Settings → Components**, and install the iOS Simulator runtime matching your Xcode. The command may prompt for your password, so run it in a terminal rather than from a script. Afterwards Xcode lists simulator destinations again, including the older runtimes it supports.

{% alert type="info" title="No local Xcode? Build in the cloud instead" %}
Every local `expo run:ios` goes through `xcodebuild`, so there is no way to produce an iOS build while the platform is missing. If you do not want the download, `eas build --platform ios --profile development` compiles remotely — see `docs/native/building-shipping.mdoc`. Android and the web app are unaffected in the meantime.
{% /alert %}

## iOS build fails with "No code signing certificates"

**Symptom.** `expo run:ios` (or `pnpm ios:native`) fails signing with *"No code signing certificates"*, even for a simulator build, after you added `apple` to `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS`.

**Cause.** `expo-apple-authentication` is a permanent dependency of `apps/native` (the Sign in with Apple code imports it), and **Expo autolinks its config plugin at prebuild** — so the `com.apple.developer.applesignin` entitlement is injected by the package merely being installed, not by anything in the `plugins` array. That entitlement makes even simulator builds require a paid Apple Developer signing team.

`apps/native/app.config.ts` compensates: unless `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS` contains `apple`, it strips the entitlement back out.

```ts
if (appleEnabled) return merged;

// Apple off → remove the entitlement Expo autolinking injects
return withEntitlementsPlist(merged, (cfg) => {
  delete cfg.modResults['com.apple.developer.applesignin'];
  return cfg;
});
```

**Fix, if you do not have a paid Apple account.** Remove `apple` from `EXPO_PUBLIC_AUTH_OAUTH_PROVIDERS`, then re-prebuild **cleanly**:

```bash
rm -rf apps/native/ios
pnpm prebuild:native
```

The clean step matters: a merge prebuild keeps the stale entitlement in the existing `ios/` project, so the build keeps failing with the flag already off.

The iOS app is fully usable without Apple sign-in (password plus Google). Apple is an App Store submission gate under Guideline 4.8, not a functional requirement — see `docs/native/config-requirements.mdoc` for the full Apple checklist, and `docs/native/building-shipping.mdoc` for prebuild and EAS.

## Auth and email failures

### Auth emails never arrive on a hosted Supabase project

**Cause.** Supabase's built-in email provider caps auth emails at a handful per hour **project-wide**, and the cap is raisable only by configuring custom SMTP. The native flows (sign-up confirmation, password reset) cannot be exercised under it.

**Fix.** Configure custom SMTP in the Supabase dashboard before pointing a native email flow at a hosted project. `docs/native/config-requirements.mdoc` marks this as a blocker and links to the provider and DNS work it implies.

### GoTrue returns 500 "… ends in a non-text context" after editing a template locally

**Cause.** Not a syntax error, despite how it reads. The Supabase CLI bind-mounts the email templates into **kong**, which can serve a size-changed file truncated to its previous length. GoTrue then fails to parse the truncated body.

**Fix.** Restart kong and auth, then confirm the served byte count matches the file on disk:

```bash
KONG=supabase_kong_next-supabase-saas-kit-turbo; AUTH=supabase_auth_next-supabase-saas-kit-turbo
docker restart $KONG $AUTH
docker exec $AUTH sh -c "wget -qO- http://$KONG:8088/email/confirmation.html | wc -c"
wc -c apps/web/supabase/templates/confirm-email.html
```

If the two numbers differ, kong is still serving the stale mount.

### Native auth breaks entirely after enabling captcha in Supabase

**Cause.** GoTrue's captcha setting is a **global** toggle: it gates sign-in, OTP and password reset for *every* client of the project. The native app sends no Turnstile token — there is no native Turnstile SDK in use — so every one of those calls is rejected.

**Fix.** Turn the captcha toggle off. Rate limiting is the substitute guard; `[auth.rate_limit] email_sent` still carries upstream's permissive default, so tighten it before exposing native sign-up publicly, remembering that the same ceiling throttles web's invitations and password resets. `docs/native/config-requirements.mdoc` records this as an anti-trigger and gives the numbers.

## i18n generator and runtime errors

### `pnpm i18n:native` (or `pnpm typecheck`) fails with N problems

The generator refuses to write anything while any check fails, so a stale catalogue is never emitted. Each message names the offending key. In one line each:

| Message starts with | What it means |
| --- | --- |
| `orphan override` | Web no longer has the key you override — upstream renamed or removed it |
| `redundant override` | Web's value now equals yours; the divergence is over |
| `shadowed addition` | Upstream now ships a key you invented |
| `shape drift` / `shape conflict` | Web turned a string into a group of keys, or the reverse, and your delta still targets the old shape |
| `ICU drift` | Web's placeholder set no longer matches your override's |
| `malformed ICU` | Unbalanced braces that would throw at runtime |
| `new upstream namespace` | Web added a message file the generator does not know about |
| `missing translation` / `extra key` | A locale does not resolve to the same key set as the default locale |
| `… is never read` | A delta directory exists for a locale that is not built |
| `messages.generated.ts is out of date` | Web strings or the local delta changed without a regenerate — run `pnpm i18n:native` and commit the result |

What to do about each, the override file format, and the add-a-locale checklist are in `docs/native/i18n.mdoc`. Never hand-edit `apps/native/lib/i18n/messages.generated.ts`; the check compares it byte-for-byte against a fresh render.

### A screen shows `⟦some.key⟧`

**Cause.** A missing message key. `apps/native/lib/i18n/provider.tsx` renders `⟦${key}⟧` as the dev-only fallback and logs `[i18n] <message>` to the console; in a release build the bare key is rendered instead.

**Fix.** Add the key to `apps/native/lib/i18n/additions/<locale>/` (if web does not have it) and regenerate. If the key is built at runtime, route it through `tDynamic` from `apps/native/lib/i18n/dynamic.ts`, which forces an explicit fallback.

### Plural messages crash or render wrong for a locale you just added

**Cause.** Hermes ships without `Intl.PluralRules`. `apps/native/app/_layout.tsx` imports the polyfill and then **one locale-data file per locale**:

```ts
import '@formatjs/intl-pluralrules/polyfill-force.js';
import '@formatjs/intl-pluralrules/locale-data/en.js';
```

**Nothing enforces the second import.** Adding a locale to `LOCALES` in `tooling/scripts/src/i18n-native.mjs` without adding its `locale-data/<locale>.js` import passes every generator check and every typecheck, and fails only at runtime, only on plural messages.

**Fix.** Add the matching import. Keep the `.js` extension — the package's `exports` map declares `./locale-data/*`, so an extensionless specifier misses and Metro falls back to legacy resolution with a warning.

## A new native module does not work

**Symptom.** You installed a package, reloaded Metro, and get a "native module not found" style failure or a no-op.

**Cause.** A package with native code ships a config plugin that writes into the iOS and Android projects. Metro only rebuilds JavaScript — the native side of the app on your simulator has no idea the module exists.

**Fix.**

1. Add the dependency, pinned via the `expo` catalog in `pnpm-workspace.yaml` (see below).
2. Add it to `plugins` if the package requires explicit registration (many are autolinked).
3. `pnpm prebuild:native` — this runs `expo prebuild --clean`.
4. Rebuild and reinstall the app: `pnpm ios:native` or `pnpm android:native`.

Expo Go cannot load a custom native module; you need the dev client build. `docs/native/building-shipping.mdoc` covers dev builds.

{% alert type="warning" title="The New Architecture is on — check the package supports it" %}
React Native 0.86 runs the New Architecture, and this app inherits the SDK 57 default rather than opting in: you will not find `newArchEnabled` in `app.json`, and the generated `android/gradle.properties` that carries `newArchEnabled=true` is CNG output, so it is gitignored too. Nothing in the repository states it.

A package whose native module targets only the old architecture therefore fails even after a correct prebuild and rebuild, and the failure looks identical to the one above — a missing module or a silent no-op. Check the package's own documentation, or its New Architecture flag in the React Native Directory, **before** adding it. `expo-doctor` does not reliably catch this.
{% /alert %}

## The catalog pinning was rewritten

**Symptom.** After running `expo install --fix`, every `catalog:expo` reference in `apps/native/package.json` (and in `packages/mobile-ui`'s `peerDependencies`) has become a literal version string.

**Cause.** `--fix` writes concrete versions. Every native dependency in this repo is pinned centrally under `catalogs.expo` in `pnpm-workspace.yaml`, so `--fix` destroys the convention that makes an SDK bump a single-file change. This is why `pnpm deps:native:check` exists (`expo install --check`) and has **no `--fix` counterpart**.

**Fix.** Revert the change (`git checkout` the affected `package.json` files), then hand-edit `catalogs.expo` from the SDK's `bundledNativeModules.json` and verify:

```bash
pnpm deps:native:check
```

{% alert type="warning" title="The overrides trap on an SDK bump" %}
`pnpm-workspace.yaml` also carries an `overrides:` block (currently `expo-constants` and `react-native-svg`) that forces a single resolution where transitive dependencies pin an older patch. **pnpm `overrides` win over catalog entries**, so a stale entry there silently holds one package on the previous SDK while everything else moves. `pnpm install` accepts it without comment; only `expo-doctor` notices. Bump both blocks in lockstep.
{% /alert %}

Note also the `expo.install.exclude` block in `apps/native/package.json`: `typescript` is excluded from Expo's version check because the monorepo runs a newer TypeScript than the SDK expects. That exclusion is deliberate, not drift.

## Provider-boundary errors

**Symptom.** One of:

- `useAuth must be used inside <AuthProvider>`
- `useActiveWorkspace must be used inside <WorkspaceProvider>` (also `useSetActiveWorkspace`, `useWorkspaceSelection`)
- `useThemePreference must be used inside <ThemePreferenceProvider>`

**Cause.** The hook ran outside the part of the tree that provides its context. The auth, theme, React Query and i18n providers are mounted in `apps/native/app/_layout.tsx`; `WorkspaceProvider` is mounted lower down, in `apps/native/app/(home)/_layout.tsx`.

**Fix.** Workspace-aware hooks only work on screens inside the `(home)` group. If you need one on an `(auth)` screen, you are reaching for state that does not exist yet at that point in the flow — the routing groups and their guards are explained in `docs/native/routing-navigation.mdoc`.

## The app signs you out unexpectedly

**Symptom.** The app returns to the sign-in screen on its own, or bounces to the MFA challenge, shortly after a screen loads.

**Cause.** This is deliberate. `apps/native/lib/react-query-provider.tsx` installs a global `QueryCache` / `MutationCache` error handler: any `ApiError` with code `unauthenticated` triggers `supabase.auth.signOut()`, and `mfa-required` redirects to the MFA challenge route. Those two codes are also excluded from retries.

**Fix.** The redirect is the correct response to those codes — look at why the API returned them. The common local causes are an expired session, or `apps/native` and `apps/web` pointing at **different Supabase projects**, so the JWT the app holds is not valid for the API it is calling. Compare `EXPO_PUBLIC_SUPABASE_URL` in `apps/native/.env.development` with `apps/web`'s Supabase URL. Authentication state is covered in `docs/native/authentication.mdoc`.

## Debugging with an AI agent

The repository ships `AGENTS.md` files — including one for `apps/native` — so Claude Code, Cursor or Codex already have the kit's native conventions in context. That makes an agent good at the *mechanical* half of this page: running the diagnose commands, reading the generated `ios/` project, comparing env files across apps. It is much less good at knowing which of the fixes below is safe here, so the prompts are written to make it check before it acts.

`docs/installation/ai-agents.mdoc` covers agent setup and the instruction-file hierarchy.

**Triage an unknown failure.** The most useful default — it forces a match against this page before anything is changed:

```
Read docs/native/troubleshooting.mdoc, then diagnose this failure:

<paste the full terminal output, not just the last line>

Identify which section matches. Run that section's diagnose commands and
show me the output before proposing a fix. If nothing matches, say so
rather than guessing — do not clear caches or reinstall dependencies
speculatively.
```

**An iOS build that never reaches compilation.** Failures at the *"Planning build"* stage are almost always destination or signing, not code:

```
`pnpm ios:native` fails before compiling:

<paste output>

Run the diagnose table from "the simulator exists but Xcode will not build
to it" in docs/native/troubleshooting.mdoc, and tell me which row matches.
Note that apps/native/ios is generated — do not edit it.
```

**Decide whether a warning is real.** Several checks in this kit report known false positives:

```
`pnpm doctor:native` reports:

<paste output>

Is this the documented duplicate-React false positive, or a genuine
duplicate? Check whether any package listed has an iOS or Android side,
and verify the installed versions with pnpm why before answering.
```

**Reproduce the environment before blaming the code.** Most "the app cannot reach the backend" reports are configuration:

```
Sign-in fails on the Android emulator but works on the iOS simulator.
Check apps/native/.env.development and any .env.development.local against
what apps/native/features/core/dev-host.ts actually rewrites, and tell me
whether an override is defeating the automatic rewrite.
```

{% alert type="warning" title="Constrain the agent on these four things" %}
An agent reaching for a plausible general-purpose fix will break kit-specific conventions. Paste these into the prompt, or keep them in your `AGENTS.md`:

- **Never run `expo install --fix`** — it rewrites every `catalog:expo` pin to a literal version. See [the catalog pinning was rewritten](#the-catalog-pinning-was-rewritten).
- **Never hand-edit `apps/native/lib/i18n/messages.generated.ts`** — it is compared byte-for-byte against a fresh render, so an edit fails the check it appears to fix.
- **Never edit `apps/native/ios/` or `apps/native/android/`** — both are prebuild output and are deleted by the next `expo prebuild --clean`.
- **Do not clear caches speculatively.** `start:clear` and `clean:native:cache` fix a specific class of symptom; used at random they hide the real cause and cost a rebuild.
{% /alert %}

Finally, an agent is a good way to *write up* a problem you cannot solve. Ask it to summarise what was tried, with the diagnose-command output included, before you open an issue — that turns a "it doesn't build" report into something reproducible.

## Still stuck

Two diagnostics are worth running before anything else:

```bash
pnpm doctor:native       # expo-doctor — walks the full native dependency graph
pnpm deps:native:check   # expo install --check — dependency versions vs the installed SDK
```

Read `doctor:native` output with the [duplicate-React caveat](#expo-doctor-flags-react-react-dom-as-duplicates) in mind; everything else it reports is worth acting on.

Where to look next, in order:

| Question | Look at |
| --- | --- |
| Is this feature even built? | `docs/native/covered-features.mdoc` — the authority on status |
| Is something misconfigured, and what does changing it force elsewhere? | `docs/native/config-requirements.mdoc` |
| Is my environment or device set up correctly? | `docs/native/running-locally.mdoc` |
| Which layer owns this code? | `docs/native/project-structure.mdoc` |
| What does this API endpoint return? | `apps/web/app/api/v1/AGENTS.md`, plus `docs/native/data-fetching.mdoc` |

The source files that explain most native-only behaviour are small and heavily commented — `apps/native/features/core/dev-host.ts`, `apps/native/features/core/api-client.ts`, `apps/native/features/core/supabase.ts`, `apps/native/app.config.ts` and `apps/native/metro.config.js`. Reading the comment above the function you are debugging is usually faster than reasoning about the symptom.
