Como rodar o primeiro build iOS de um app Expo num monorepo pnpm
14 min de leituraEscrito 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.

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 installnão existe. @babel/coreebabel-preset-exponasdevDependenciesdo app. O pnpm comnodeLinker: hoistedpode 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-propertiescombuildReactNativeFromSource: trueeusePrecompiledModules: false. Se oreact-nativefor uma versão que mudou a ABI do JSI (como a 0.86.0 comIRuntime), oExpoModulesCore.xcframeworkprebuilt é incompatível. Desabilitar e compilar do source alinha os símbolos.nativeAppIds.iosno config YAML do ambiente de development. Sem isso, oGADApplicationIdentifierfica vazio noInfo.pliste o AdMob crasheia no boot.GoogleService-Info.plistgerado com credenciais válidas (ou mock). O Firebase validaGOOGLE_APP_IDeAPI_KEYna inicialização. Placeholders literais no.envproduzem um plist inválido.- Crash report em
~/Library/Logs/DiagnosticReports/. Quando o app crashe no boot do simulador, o.ipsmostra 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