Pular para o conteúdo
← Voltar para o blog
Expo e React Native iOS

Como rodar o primeiro build iOS de um app Expo num monorepo pnpm

14 min de leitura

Escrito por: Jonathan Reis em

O Android buildava com `pnpm sudoku:apk:dev` há semanas. Peguei um iPhone emprestado e precisava fazer o mesmo Sudoku rodar no iOS. O caminho é `expo prebuild`, `pod install`, `xcodebuild Release`, `simctl install` — mas três coisas entre o prebuild e o simulador travam o build num monorepo pnpm, e nenhuma delas aparece no Android.

Imagem de prévia OpenGraph deste artigo. Como rodar o primeiro build iOS de um app Expo num monorepo pnpm

O Android buildava com pnpm sudoku:apk:dev há semanas. O bundle JS era embutido no APK via Gradle, sem Metro em runtime, e o app rodava no simulador. Peguei um iPhone emprestado e precisava fazer o mesmo Sudoku rodar no iOS. O caminho é expo prebuild, pod install, xcodebuild Release, simctl install — mas três coisas entre o prebuild e o simulador travam o build num monorepo pnpm, e nenhuma dela aparece no Android.

Este post é o passo a passo de como eu fiz, incluindo onde cada armadilha aparece e como resolver cada uma nas fontes versionadas (não em arquivos gerados que somem no próximo prebuild).

O ponto de partida

O app é um Sudoku em Expo SDK 56 + React Native 0.86.0, num monorepo pnpm com nodeLinker: hoisted. Ele usa plugins nativos: Firebase, Sentry, AdMob, expo-sqlite, expo-audio. O Android buildava com pnpm sudoku:apk:dev, que roda expo prebuild --platform android + gradlew assembleRelease, embute o bundle JS no APK e instala no dispositivo.

No iOS, o equivalente é: gerar o projeto Xcode nativo com expo prebuild --platform ios, instalar os pods com pod install, compilar com xcodebuild -configuration Release, e instalar no simulador com simctl install. O bundle JS é embutido no .app pela build phase “Bundle React Native code and images” do Xcode — sem Metro em runtime, igual ao Android.

Passo 1: instalar CocoaPods

O macOS tinha Xcode 26.6 mas não tinha CocoaPods. Sem ele, o pod install não existe e o .xcworkspace não é gerado.

brew install cocoapods

Passo 2: gerar o projeto iOS com expo prebuild

pnpm --filter sudoku config:generate
pnpm --filter sudoku exec expo prebuild --platform ios --no-install

Isso cria a pasta apps/sudoku/ios/ com o projeto Xcode nativo. O --no-install pula o pod install automático (vou rodar separado pra ter controle).

Antes disso, precisei garantir que o GoogleService-Info.plist do Firebase existisse. O app.config.js referencia ./GoogleService-Info.plist, e o prebuild copia esse arquivo para dentro do projeto iOS. Sem ele, o build falha. Eu rodo:

sh scripts/sudoku-native.sh firebase-ios

Esse script lê SUDOKU_FIREBASE_IOS_APP_ID e SUDOKU_FIREBASE_IOS_API_KEY do apps/sudoku/.env e gera o plist. Mais abaixo explico o que acontece quando o .env tem placeholders.

Passo 3: instalar os pods

cd apps/sudoku/ios && pod install

121 pods instalados, DailySudoku.xcworkspace criado. Esse é o arquivo que se abre no Xcode (não o .xcodeproj).

Armadilha 1: o babel não resolve plugins no pnpm isolado

O pod install passou. Mas quando mandei o primeiro xcodebuild Release, a build phase “Bundle React Native code and images” falhou:

Failed to construct transformer: Error: Cannot find module 'babel-preset-expo'
Require stack:
- .../.pnpm/@[email protected]/node_modules/@babel/core/lib/config/files/plugins.js

A primeira reação foi adicionar babel-preset-expo às devDependencies do apps/sudoku/package.json. O erro mudou para @babel/plugin-transform-react-jsx não encontrado. A tentação é adicionar cada plugin faltante manualmente — mas a causa raiz é mais funda.

O que acontece: o babel carrega o babel.config.js a partir do @babel/core, que vive num isolate do pnpm em .pnpm/@[email protected]/node_modules/@babel/core/. Quando o babel resolve os plugins declarados no preset (babel-preset-expo faz require('@babel/plugin-transform-react-jsx')), ele re-resolve os nomes a partir de si mesmo, não a partir do preset que os declarou. E a partir do isolate do @babel/core, os plugins não estão acessíveis como siblings — eles estão em isolates separados.

A correção é dupla e vai nas fontes versionadas:

1. Adicionar @babel/core e babel-preset-expo como devDependencies explícitas do app, em apps/sudoku/package.json:

"devDependencies": {
  "@babel/core": "^7.29.7",
  "babel-preset-expo": "~56.0.15"
}

Isso garante que o pnpm crie symlinks em apps/sudoku/node_modules/, tornando-os acessíveis à árvore de resolução do Node.

2. Manter nodeLinker: hoisted no pnpm-workspace.yaml. Com hoisted, as dependências transitivas (incluindo os @babel/plugin-*) são elevadas para a raiz node_modules/, onde o Node as encontra ao subir a árvore a partir do isolate do @babel/core.

Depois do pnpm install --force, a raiz node_modules/@babel/ passou a ter 85 packages, e o bundle JS completou: 2535 módulos, 42 assets.

Passo 4: compilar com xcodebuild Release

cd apps/sudoku/ios
xcodebuild -workspace DailySudoku.xcworkspace \
  -scheme DailySudoku \
  -configuration Release \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  -derivedDataPath build \
  SENTRY_DISABLE_AUTO_UPLOAD=1 SENTRY_ALLOW_FAILURE=true

O SENTRY_DISABLE_AUTO_UPLOAD=1 e SENTRY_ALLOW_FAILURE=true evitam que o upload de source maps do Sentry aborte o build quando não há auth token válido (não precisa em build local de dev).

Armadilha 2: ExpoModulesCore prebuilt incompatível com o Hermes do react-native 0.86.0

O bundle JS passou. O xcodebuild compilou. O simctl install funcionou. Mas quando o app lançou no simulador, crasheou imediatamente:

Symbol not found: __ZN8facebook3jsi5Value12strictEqualsERNS0_7RuntimeERKS1_S5_
Referenced from: ExpoModulesCore.framework
Expected in: hermesvm.framework

Demangleando: facebook::jsi::Value::strictEquals(Runtime&, ...). O ExpoModulesCore.framework referencia strictEquals com Runtime (classe concreta), mas o hermesvm.framework do [email protected] só tem strictEquals com IRuntime (interface abstrata). O React Native 0.86.0 mudou a hierarquia do JSI — Runtime virou IRuntime, e os símbolos mudaram de nome.

O diagnóstico veio do crash report em ~/Library/Logs/DiagnosticReports/DailySudoku-*.ips. O campo termination.reasons mostra qual símbolo falta e de qual framework ele é esperado. O nm confirmou a diferença:

nm .../hermesvm.framework/hermesvm | grep strictEquals
# __ZN8facebook3jsi5Value12strictEqualsERNS0_8IRuntimeERKS1_S5_  (IRuntime)

nm .../ExpoModulesCore.framework/ExpoModulesCore | grep strictEquals
# U __ZN8facebook3jsi5Value12strictEqualsERNS0_7RuntimeERKS1_S5_  (Runtime, undefined)

O U significa “undefined reference” — o ExpoModulesCore espera o símbolo de hermesvm, mas o nome não bate.

A causa: o ExpoModulesCore é distribuído como um xcframework pré-compilado quando EXPO_USE_PRECOMPILED_MODULES=1 (o default do Expo). Esse binário foi compilado contra um JSI mais antigo. O [email protected] já tem o JSI novo com IRuntime.

A correção vai no app.json, no plugin expo-build-properties:

[
  "expo-build-properties",
  {
    "ios": {
      "useFrameworks": "static",
      "buildReactNativeFromSource": true,
      "usePrecompiledModules": false
    }
  }
]

Isso faz o prebuild injetar EXPO_USE_PRECOMPILED_MODULES=false e ios.buildReactNativeFromSource=true no Podfile.properties.json. Depois de pod deintegrate && pod install, os logs confirmam:

[ReactNativeDependencies] Building from source: true
[ReactNativeCore] Building from source: true

A primeira build demora mais (compila o React Native e o ExpoModulesCore do source em vez de linkar prebuilts), mas os símbolos batem porque tudo é compilado contra o mesmo JSI.

No Android isso não acontece porque o Gradle compila o React Native e os módulos do Expo do source por default — não há equivalente ao ExpoModulesCore.xcframework pré-compilado.

Passo 5: instalar e lançar no simulador

xcrun simctl install booted apps/sudoku/ios/build/Build/Products/Release-iphonesimulator/DailySudoku.app
xcrun simctl launch booted com.sunstoneapps.dailysudoku

Armadilha 3: Firebase e AdMob com credenciais vazias

O app passou do dyld e do babel, mas crasheou na inicialização com:

*** Terminating app due to uncaught exception 'com.firebase.core',
reason: 'Configuration fails. It may be caused by an invalid GOOGLE_APP_ID
in GoogleService-Info.plist'

O GoogleService-Info.plist tinha sido gerado pelo sudoku-native.sh firebase-ios, que lê SUDOKU_FIREBASE_IOS_APP_ID do .env. Mas o .env tinha placeholders literais — o valor era o próprio nome da variável:

SUDOKU_FIREBASE_IOS_APP_ID="SUDOKU_FIREBASE_IOS_APP_ID"

O Firebase valida o formato do GOOGLE_APP_ID (1:numero:ios:hash) e da API_KEY (39 caracteres em formato AIzaSy...) na inicialização nativa. Com placeholders, ambos falham.

Depois de corrigir o Firebase, o AdMob crasheou com GADInvalidInitializationException porque o GADApplicationIdentifier no Info.plist estava vazio. O iosAppId do AdMob estava no app.json, mas o @apps/ads/node (que resolve os plugins do AdMob no prebuild) lê os app IDs do config YAML do ambiente, não do app.json. E o config.development.yml não tinha nativeAppIds.ios.

A correção tem duas partes, ambas nas fontes versionadas:

1. Adicionar nativeAppIds.ios no apps/sudoku/src/config.development.yml:

ads:
  nativeAppIds:
    android: ca-app-pub-3940256099942544~3347511713
    ios: ca-app-pub-3940256099942544~1458002511

Isso faz o @apps/ads/node injetar o iosAppId no plugin do AdMob durante o prebuild, e o GADApplicationIdentifier aparece no Info.plist.

2. Modificar o generate_firebase_ios no scripts/sudoku-native.sh para detectar placeholders (quando o valor é igual ao nome da variável) e gerar um mock válido:

const isPlaceholder = (name, value) => !value || value === name;

if (isPlaceholder("SUDOKU_FIREBASE_IOS_APP_ID", requiredValues.SUDOKU_FIREBASE_IOS_APP_ID)) {
  // usar mock: "1:000000000000:ios:0000000000000000"
  // e warn no console
}

Assim, outro dev que rodar sh scripts/sudoku-native.sh firebase-ios sem credenciais reais recebe um plist que não crasha o Firebase no boot. Analytics não funciona com o mock, mas o jogo é jogável.

O fluxo completo, do zero ao simulador

Depois de aplicar as três correções nas fontes versionadas, o fluxo completo de build iOS é:

# 1. Instalar CocoaPods (uma vez por máquina)
brew install cocoapods

# 2. Gerar o GoogleService-Info.plist (mock se .env tem placeholders)
sh scripts/sudoku-native.sh firebase-ios

# 3. Gerar o projeto iOS nativo
pnpm --filter sudoku config:generate
cd apps/sudoku && node ../../node_modules/expo/bin/cli prebuild --platform ios --clean --no-install

# 4. Instalar pods
cd ios && pod install

# 5. Compilar Release
xcodebuild -workspace DailySudoku.xcworkspace \
  -scheme DailySudoku -configuration Release \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  -derivedDataPath build \
  SENTRY_DISABLE_AUTO_UPLOAD=1 SENTRY_ALLOW_FAILURE=true

# 6. Instalar e lançar no simulador
xcrun simctl install booted build/Build/Products/Release-iphonesimulator/DailySudoku.app
xcrun simctl launch booted com.sunstoneapps.dailysudoku

Tudo sobrevive ao prebuild --clean porque as correções estão em app.json, config.development.yml, apps/sudoku/package.json, pnpm-workspace.yaml e scripts/sudoku-native.sh — nenhum arquivo em ios/ é editado manualmente.

Checklist antes do primeiro build iOS

  • CocoaPods instalado. Sem ele, pod install não existe.
  • @babel/core e babel-preset-expo nas devDependencies do app. O pnpm com nodeLinker: hoisted pode não expô-los na árvore de resolução do babel. Sem eles, Cannot find module 'babel-preset-expo' na build phase do Xcode.
  • expo-build-properties com buildReactNativeFromSource: true e usePrecompiledModules: false. Se o react-native for uma versão que mudou a ABI do JSI (como a 0.86.0 com IRuntime), o ExpoModulesCore.xcframework prebuilt é incompatível. Desabilitar e compilar do source alinha os símbolos.
  • nativeAppIds.ios no config YAML do ambiente de development. Sem isso, o GADApplicationIdentifier fica vazio no Info.plist e o AdMob crasheia no boot.
  • GoogleService-Info.plist gerado com credenciais válidas (ou mock). O Firebase valida GOOGLE_APP_ID e API_KEY na inicialização. Placeholders literais no .env produzem um plist inválido.
  • Crash report em ~/Library/Logs/DiagnosticReports/. Quando o app crashe no boot do simulador, o .ips mostra o símbolo exato que falta e de qual framework ele é esperado.

Conclusão

O primeiro build iOS de um app Expo num monorepo pnpm tem três pontos que travam e não aparecem no Android: o babel não resolve plugins porque o pnpm isolado não os expõe na árvore do @babel/core, o ExpoModulesCore prebuilt é compilado contra um JSI diferente do hermesvm.framework do [email protected], e o Firebase/AdMob crasheiam com credenciais vazias. Cada correção vai numa fonte versionada diferente (package.json, app.json, config.development.yml, sudoku-native.sh), e o fluxo completo — prebuild, pod install, xcodebuild, simctl — funciona sem edição manual em arquivos gerados.

Post relacionadoQuando astro check diz que falta dependência, mas o bug real é um override do pnpm8 min readEscrito por: Jonathan Reis em