Spoiler: passei horas suspeitando do framework. O problema era uma única linha no meu
.env.
Se você usa Expo + EAS Update e bateu de cara com um destes sintomas, talvez esse post te poupe meio dia de cabeça:
- Você publica uma OTA com
eas update --branch production - O app baixa o update (
isUpdatePending: true, banner aparece) - O usuário toca pra reiniciar
- O app fecha, reabre… e continua na versão antiga
updateIdcontinua sendo o do bundle embeddedisUpdatePendingviroufalseisEmergencyLaunchestáfalse(sem rastro de rollback)nativeLogsvazio- Nada nos logs do Sentry
Parece bug do expo-updates, né? Foi exatamente o que eu pensei.
O caminho errado que eu tomei
Encontrei a issue #42922 no repositório do Expo: “Expo Updates Issue”. Título genérico, sintomas vagamente parecidos com os meus, e — bingo — eu estava na mesma versão (SDK 54, expo-updates 0.29.16, new architecture, React Native 0.81.5).
Achei que tinha encontrado meu bug. Implementei o workaround mais comentado pela comunidade: o “double-reload” — detectar isUpdatePending && isEmbeddedLaunch no startup e chamar reloadAsync() automaticamente após 2 segundos. Counter no AsyncStorage limitando a 2 tentativas pra evitar loop infinito.
Buildei. Submeti pro Play Store. Testei.
Não funcionou.
Reli a issue #42922 com mais calma. Estava CLOSED. A solução do único comentário relevante era: “rodar eas update:configure pra adicionar channel no eas.json”. Não era nem o mesmo problema — era config de canal. Eu já tinha channel: "production" configurado.
Comecei a pesquisar SDK 55 upgrade. React Native 0.83. React 19.2. Era uma upgrade grande, com risco real de quebrar react-native-reanimated, expo-router, Sentry. Estimativa: 4-8 horas. Estava prestes a comprometer com isso quando resolvi investigar mais 30 minutos antes.
A descoberta
Comparei os bundles. O JS embarcado no APK (via eas build) usava as URLs corretas de produção. O JS publicado via OTA (via eas update)… eu não tinha como inspecionar diretamente, mas comecei a suspeitar.
Olhei o .env local:
EXPO_PUBLIC_API_URL=https://api.exemplo.com
EXPO_PUBLIC_CHAT_URL=http://localhost:3500
EXPO_PUBLIC_SENTRY_DSN=https://...
Olhei o eas.json:
{
"build": {
"production": {
"env": {
"EXPO_PUBLIC_API_URL": "https://api.exemplo.com",
"EXPO_PUBLIC_CHAT_URL": "https://chat.exemplo.com",
"EXPO_PUBLIC_SENTRY_DSN": "https://..."
}
}
}
}
Os arquivos têm EXPO_PUBLIC_CHAT_URL diferentes. O .env aponta pra localhost:3500 (porque uso o chat-server localmente em desenvolvimento). O eas.json aponta pro endereço de produção.
Foi aí que caiu a ficha:
eas build --profile productionlê env vars deeas.json? URL de produção entra no bundle nativoeas update --branch production(sem--environment) lê env vars do.envlocal ? URL de localhost entra no bundle JS publicado pra produção
O que acontecia exatamente
- Usuário com a versão atual instalada (build nativo, URL correta) abria o app normalmente
- Auto-check do expo-updates baixava a OTA em background
- Banner “Atualização disponível” aparecia
- Usuário tocava ?
reloadAsync()? bundle OTA carregava - No bootstrap, meu hook chamava a função de conexão do chat que tentava abrir um Socket.IO em
http://localhost:3500 - Conexão falhava de forma fatal no JS runtime
expo-updatesdetectava a falha e fazia rollback silencioso pro embedded- App reabria com a versão antiga
- Cache da OTA limpo (
isUpdatePending: false) isEmergencyLaunchficavafalseporque o rollback foi tão limpo que não escalou como emergency
O usuário via o app “não atualizando”, mas funcionando. Eu via dados que apontavam pra bug de framework. Na verdade era o meu próprio código JS crashando porque tentava bater em localhost no celular de quem instalou o app.
A solução
Duas coisas, juntas:
1. Migrar as env vars EXPO_PUBLIC_* pro sistema do EAS:
eas env:create production --name EXPO_PUBLIC_API_URL --value "https://api.exemplo.com" --visibility plaintext --type string
eas env:create production --name EXPO_PUBLIC_CHAT_URL --value "https://chat.exemplo.com" --visibility plaintext --type string
eas env:create production --name EXPO_PUBLIC_SENTRY_DSN --value "https://..." --visibility plaintext --type string
2. Sempre passar --environment production no eas update:
eas update --branch production --environment production --platform android --message "..."
eas update --branch production --environment production --platform ios --message "..."
Republiquei a OTA com o flag. Funcionou de primeira. Footer mudou de ota.0 pra ota.2. Chat conectou normal. Sem crash. Sem rollback.
Lições
1. eas build e eas update leem env vars de fontes diferentes por default.
eas build lê do eas.json (seção build.production.env). eas update lê do .env local. Se essas duas fontes divergem (e é provável que divirjam, porque .env geralmente tem valores de dev), seu OTA vai pra produção com config de dev.
2. Falha silenciosa não significa falha do framework.
Eu ficava olhando o isEmergencyLaunch: false e achando “tá tudo OK, mas o reload não aplica — só pode ser bug do reload”. O fato de o expo-updates não ter sinalizado emergency não significava que o bundle aplicou. Significava que o crash foi tratado tão rápido que não escalou como emergency.
3. Antes de implementar workaround pra “bug de framework”, verifique se o sintoma é realmente do framework.
Eu implementei um workaround inteiro (double-reload com counter no AsyncStorage). Buildei. Submeti. Testei. Nada disso teria funcionado porque o problema nem estava no reloadAsync — estava no bundle JS que ele aplicava. O workaround só atrasou o diagnóstico real.
4. Issues fechadas com sintoma parecido nem sempre são o seu bug.
A #42922 estava CLOSED. Eu li o título, vi as versões batendo, e assumi que era o mesmo problema. Não era. Sempre confira o status da issue e o conteúdo do fix.
5. Documente as pegadinhas no próprio repo, perto do código.
Tinha um aviso sobre essa exata pegadinha no meu docs/VERSIONING.md da release anterior. Eu mesmo escrevi. Não li antes de rodar OTA dessa vez. Reforcei o aviso depois do incidente.
TL;DR
Se sua OTA do Expo está “aplicando mas não aplicando” e os dados de diagnóstico parecem inocentes, antes de pesquisar bug do framework, verifique se seu OTA bundle não está empacotando URLs de localhost do .env local.
O fix é uma flag: --environment production.
Espero que esse post poupe horas de alguém.