O bug do expo-updates que não era do expo-updates: como o `.env` local quebrou minha OTA em produção

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
  • updateId continua sendo o do bundle embedded
  • isUpdatePending virou false
  • isEmergencyLaunch está false (sem rastro de rollback)
  • nativeLogs vazio
  • 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 production lê env vars de eas.json ? URL de produção entra no bundle nativo
  • eas update --branch production (sem --environment) lê env vars do .env local ? URL de localhost entra no bundle JS publicado pra produção

O que acontecia exatamente

  1. Usuário com a versão atual instalada (build nativo, URL correta) abria o app normalmente
  2. Auto-check do expo-updates baixava a OTA em background
  3. Banner “Atualização disponível” aparecia
  4. Usuário tocava ? reloadAsync() ? bundle OTA carregava
  5. No bootstrap, meu hook chamava a função de conexão do chat que tentava abrir um Socket.IO em http://localhost:3500
  6. Conexão falhava de forma fatal no JS runtime
  7. expo-updates detectava a falha e fazia rollback silencioso pro embedded
  8. App reabria com a versão antiga
  9. Cache da OTA limpo (isUpdatePending: false)
  10. isEmergencyLaunch ficava false porque 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.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Este site utiliza o Akismet para reduzir spam. Saiba como seus dados em comentários são processados.