Skip to content

Resiliencia: fallback de erro e teclado

Regra base: nenhuma tela pode ficar branca, presa ou mentindo. Todo caminho de falha precisa terminar em uma das tres saidas abaixo.

Tipo de falhaSaida obrigatoriaPeca
Erro de render (crash JS)Tela de erro com reiniciar / tentar de novo / ver detalheAppErrorBoundary
Falha de fetch de listaEstado de erro com botao de recarregarPaginatedListStateView
Falha de fetch de secaoEstado de erro com botao de recarregarScreenState (error)
Falha de acao do usuarioToast com a mensagem do backenduseToast

1. Error boundary

components/ui/AppErrorBoundary captura erro de render e mostra AppErrorFallback (lottie consecutive-error, titulo, descricao, Reiniciar o app, Tentar carregar de novo, Ver detalhes do erro). O detalhe abre AppErrorDetailsModal com app/dispositivo, rota, mensagem, stack e component stack, com botao de copiar o relatorio inteiro.

Ele esta montado em dois niveis no app/_layout.tsx:

tsx
<AppErrorBoundary scope="root">        // pega crash de provider/boot
  ...
      <AppErrorBoundary scope="navigation">   // pega crash de tela, providers seguem vivos

O boundary interno e o que importa no dia a dia: o crash de uma tela nao derruba sessao, tema nem navegacao, e o Tentar carregar de novo remonta so a subarvore.

AppErrorFallback traz o proprio SafeAreaProvider e nao usa useTheme/useAuth, porque o boundary root roda acima desses providers.

Toda entrada e registrada com logError('ui', ...), que ja alimenta Sentry e o useErrorTracking (que por sua vez dispara o NavigationErrorRecoveryModal apos falhas repetidas).

Quando adicionar um boundary novo: em qualquer subarvore que renderize conteudo de terceiros ou pesado (WebView de documento, preview, editor). scope e so um rotulo de log.


2. Falha de fetch nunca vira estado vazio

O interceptor do config/api.ts nao mostra toast, so rejeita. Um .catch(logCaughtError(...)) e portanto silencioso para o usuario: a tela cai no estado vazio e a pessoa acha que nao existe dado, quando na verdade a rede caiu.

hooks/use-paginated-list.ts expoe error e failed (Boolean(error) && pages.length === 0). Listas usam PaginatedListStateView, que decide entre skeleton, erro e vazio:

tsx
ListEmptyComponent={
  <PaginatedListStateView
    loading={showListLoading}
    failed={failed}
    emptyIcon={FileText}
    emptyTitle={t('...')}
    onRetry={handleRefresh}
  />
}

Secoes de tela usam ScreenState, que agora aceita error, errorTitle, errorDescription e onRetry, e prioriza erro sobre vazio. O visual vem de components/ui/ErrorState (textos padrao em errorState.*).


3. Teclado cobrindo formulario

O app roda com edgeToEdgeEnabled: true. No Android 15+ isso faz o android:windowSoftInputMode="adjustResize" deixar de redimensionar a janela, entao o teclado passa por cima dos campos mesmo com o manifest correto. Foi por isso que o problema aparecia mais no Android.

Nao usamos react-native-keyboard-controller: e dependencia nativa e quebraria o fluxo de teste no Expo Go. A solucao e em JS:

  • hooks/use-keyboard-inset.ts escuta keyboardWillChangeFrame/keyboardWillHide (iOS) e keyboardDidShow/keyboardDidHide (Android) e devolve a altura atual do teclado.
  • components/ui/FormScrollView e um ScrollView que soma keyboardInset + insets.bottom no paddingBottom, com keyboardShouldPersistTaps="handled" e keyboardDismissMode="on-drag".

Toda tela com formulario usa FormScrollView no lugar de ScrollView. Lista com apenas um campo de busca no header nao precisa.

Sheets do @gorhom/bottom-sheet ja tratam teclado por conta propria (android_keyboardInputMode="adjustResize" + keyboardBehavior no components/ui/BottomSheet), entao input dentro de sheet nao precisa de FormScrollView.


Estado do modulo de contratos

O modulo contracts foi o primeiro varrido por completo. Padrao adotado, que serve de modelo para os demais:

Leitura guarda uma flag de falha ao lado do loading no proprio store, e a lista renderiza ContractFlowErrorState (mesma moldura do ContractFlowEmptyState, com botao de recarregar) em vez do estado vazio:

Store / hookFlagOnde aparece
use-contract-reviewloadFailed, validatorsFailed, commentsFailed, repliesFailed, revisionsFailedetapa Revisao, telas de comentario, resposta, revisao e validadores
use-contract-signaturesloadFailedetapa Assinatura
use-contract-providerloadFailedetapa Prestador
use-contract-detailpanelsFailedpaineis de aditivo e compliance
use-contract-model-uitemplateFailed, previewFailed, optionsFailedetapa Modelo, previa, sheet de opcoes
use-contract-part-searchsearchFailed (via resolvePartSearchFailure)modal de adicionar parte
use-contract-signature-workflowfailedsomado ao failed da etapa Assinatura

Escrita ja estava coberta: as acoes fazem throw e quem chama usa useError().handleError, que toasta a mensagem do backend.

Boundaries do modulo: cada etapa do editor roda dentro de um AppErrorBoundary com key={activeStep} e scope={contract-editor:<etapa>}, entao um crash de etapa nao derruba o contrato inteiro. O DocumentPaperPreview tem boundary proprio com fallback embutido (DocumentPaperPreviewFallback), porque a previa monta WebView de HTML de terceiro.

Degradacao aceita (nao vira UI de erro): preview de substituicao de variavel nos itens de revisao (use-contract-review.ts, item aparece sem previa) e parse de mensagem do WebView do documento (use-contract-review-validator-document.ts, sem efeito visivel).


Estado dos modulos de template e workflow

Aqui estava o problema mais grave da varredura: tela travada, nao so estado vazio.

use-template-structure.ts tinha tres acoes async que ligavam a flag e so a desligavam no caminho feliz. Qualquer falha de rede deixava a flag ligada para sempre:

AcaoFlag presaSintoma
initloadingtela presa no skeleton, com toast de erro
saveDraftsavingbotao salvar preso em "salvando"
publishpublishingbotao publicar preso

O corpo de cada uma passou para dentro de runTemplateStructureTask, com .finally(() => set({ <flag>: false })). A regra vale para qualquer acao async de store: flag ligada exige finally, nunca reset no fim do caminho feliz. Coberto por __tests__/modules/templates/hooks/use-template-structure-loading.test.ts.

Demais fallbacks:

Store / hookFlagOnde aparece
use-templatesloadFailedlista de modelos
use-workflowsloadFailedlista de fluxos
use-signature-workflowsloadFailedlista de fluxos de assinatura
use-revision-workflows-list-screenfailedlista de fluxos de revisao
use-signature-workflows-list-screenfailedlista de fluxos de assinatura
use-template-signature-flowflowsFailedseletor de fluxo no template
use-template-typesfailedseletor de tipo de contrato

A previa da estrutura (renderPreview) passou a toastar com useError().handleError no lugar de so logar, tanto ao abrir o painel quanto ao trocar valor de variavel.

Boundaries: EditorPanelScreenLayout embrulha o conteudo em AppErrorBoundary (scope=editor-panel:<titulo>), o que cobre de uma vez os paineis de workflow, revisao, assinatura, compliance e template. TemplateStructureScreen e TemplateEditorScreen tem boundary proprio.

Degradacao aceita: use-template-document-fonts (o documento renderiza com a fonte padrao se a fonte customizada nao carregar).