Alguém do time fala "vamos só embrulhar o app web".

Duas semanas depois o botão voltar faz uma coisa que ninguém consegue explicar.

A tela de login funciona no Android e entra em loop eterno no iOS.

A palavra "wrapper" causou isso. É o modelo mental errado, e todo bug dessa lista nasce dele.


Hotwire Native é um aplicativo nativo cuja pilha de navegação é dirigida pelas respostas do seu servidor. O lado nativo é dono das telas, das transições e da back stack; o lado web é dono do que vai dentro de uma tela e, através das respostas HTTP que manda, avisa o nativo quando empilhar uma nova. Visitar uma URL não é carregar uma página num controle de browser. É pedir uma tela, e a shell nativa decide como apresentar.

Essa frase sozinha resolve quase tudo que confunde as pessoas. Vamos rastrear.

O fluxo real de um toque

O usuário toca num link dentro da WebView. O que acontece, em ordem:

  usuário toca <a href="/trades/42">
            ↓
  [WebView]  o delegate de navegação dispara
             a web view NÃO navega
            ↓
  [Nativo]   a Session intercepta a visita proposta
            ↓
  [Nativo]   path configuration casa "/trades/42"
             → regra diz: context=default, presentation=push
            ↓
  [Nativo]   o Navigator empilha um NOVO view controller
             na pilha nativa
            ↓
  [Nativo]   esse controller tem uma web view própria; ela
             pede /trades/42 com os headers de Turbo-Visit
            ↓
  [Servidor] responde com HTML
            ↓
  [WebView]  renderiza dentro da nova tela nativa

O passo que importa é o segundo. A WebView não navega sozinha. O Hotwire Native intercepta a navegação proposta e cancela, e aí decide — em código nativo — que tela criar. A web view que você estava olhando continua exatamente onde estava; uma nova aparece por cima, dentro de uma tela nativa nova.

Instinto de júnior: "a página mudou." O que aconteceu: um view controller nativo foi empilhado, e por acaso ele contém uma web view mostrando outra URL.

É por isso que o botão voltar nativo funciona. Ele está desempilhando uma pilha nativa, não chamando history.back().

Path configuration é uma tabela de rotas

Path configuration costuma ser apresentado como "um JSON de configurações". Não é. É a tabela de rotas que mapeia padrões de URL para decisões de apresentação nativa, e é avaliada de cima pra baixo com a última regra que casa vencendo.

{
  "rules": [
    {
      "patterns": [".*"],
      "properties": { "context": "default", "pull_to_refresh_enabled": true }
    },
    {
      "patterns": ["/new$", "/edit$"],
      "properties": { "context": "modal", "presentation": "default" }
    },
    {
      "patterns": ["/trades/\\d+/chart$"],
      "properties": { "view_controller": "chart" }
    }
  ]
}

Três coisas que o pessoal erra aqui:

Os patterns são expressões regulares contra o path, não matchers de rota estilo glob. /new casa com /renew-subscription. Ancore.

A ordem importa e a última regra que casa vence, o oposto de como rota do Rails funciona. Catch-all primeiro, regras específicas depois. O Rails te treinou pro reflexo contrário e isso vai doer.

Ele vem do seu servidor — normalmente numa URL que o app busca no launch, com uma cópia embutida de fallback. Ou seja: dá pra mudar como o app navega sem publicar build nova. Também quer dizer que um deploy ruim de path configuration muda a navegação de todo app instalado, na hora. Trate como migration, não como ajuste de config.

A bridge, e pra que ela serve de verdade

A bridge é o canal pras coisas que HTML realmente não faz: share sheet nativo, date picker do sistema, haptics, câmera.

Um bridge component é um par. Do lado web, um controller Stimulus se declara:

// app/javascript/controllers/bridge/share_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "share"

  share(event) {
    event.preventDefault()
    this.send("share", { url: this.element.href }, () => {
      // chamado de volta quando o lado nativo termina
    })
  }
}

Do lado nativo, um componente com o mesmo nome recebe a mensagem e faz algo que um browser não faz.

A regra que mantém isso são: a bridge carrega intenção, não markup. Manda { url: "..." } e deixa o nativo decidir como um share sheet se parece. No minuto que você começa a mandar HTML pela bridge, ou o nativo começa a mexer no DOM, você tem duas bases de código renderizando a mesma tela e nenhuma forma de raciocinar sobre qualquer uma das duas.

E o fallback importa. Aquele controller Stimulus tem que degradar pra um link comum num browser de desktop. Se o app web quebra sem a shell nativa, você não tem mais um app web — tem um app nativo com um motor de renderização estranhamente lento.

Quem é dono da back stack

É aqui que moram os bugs de duas semanas. O nativo é dono. Toda regra decorre disso:

ERRADO                         CERTO
──────                         ─────
history.back() no JS           deixa o nativo desempilhar, ou
pra sair de uma tela           devolve um redirect que a shell honra

Renderizar um link "cancelar"  Apresenta como modal na path
dentro de um modal que         configuration; o nativo dispensa
navega pra trás                o modal como unidade

Redirecionar pra mesma URL     Redireciona pra outra URL, ou usa
depois de um POST              turbo_stream, pra shell saber que
                               uma tela foi substituída e não
                               empilhada

O loop de login da abertura é quase sempre isto: o app empilha /login como tela normal, o login dá certo, o servidor redireciona pro referrer do /login, e a pilha nativa agora tem duas telas onde o usuário espera zero. No iOS o modal nunca some porque nada mandou ele sumir. A correção é uma regra de path configuration marcando as rotas de auth como modal, mais um redirect que sai do contexto modal.

Os erros mais comuns

  1. Tratar como wrapper. Todo outro erro dessa lista é um caso particular deste.
  2. Patterns de path configuration sem âncora. /new casando com /renew é um bug que vai pra produção e leva um dia pra achar.
  3. Assumir que a primeira regra que casa vence. Vence a última. Catch-all primeiro.
  4. Dirigir navegação pelo JavaScript. history.back(), location.replace() e cia. brigam com a pilha nativa e ganham só às vezes.
  5. Bridge component sem fallback web. O site quebra no browser de desktop e ninguém percebe por um mês.
  6. Publicar mudança de path configuration como se fosse ajuste de config. Ela chega em todo app instalado no próximo launch, inclusive versões que você parou de testar.

Perguntas frequentes

Hotwire Native é a mesma coisa que um wrapper de WebView?

Não, e a diferença é arquitetural, não estética. Um wrapper carrega um site dentro de uma web view e deixa o site cuidar da própria navegação. O Hotwire Native intercepta toda navegação proposta, consulta uma path configuration e cria uma tela nativa pra cada uma — então transições, back stack, modais e componentes nativos são todos da plataforma. O app web fornece o conteúdo de uma tela; ele não fornece a tela.

Quando uma tela deve ser nativa em vez de WebView?

Três casos se pagam: qualquer coisa que usa hardware (câmera, biometria, localização), qualquer lista longa com scroll onde reciclagem de células importa, e qualquer coisa que precisa funcionar offline. O resto costuma sair mais barato em HTML, porque o motivo inteiro de escolher Hotwire Native é um time entregar uma implementação só. Fazer um formulário nativo porque "tá lento" quase sempre é consertar problema de latência na camada errada.

A Apple rejeita app Hotwire Native por ser wrapper?

A diretriz que citam é sobre apps que não oferecem nada além de um site reempacotado. Apps que usam navegação nativa, componentes nativos pros recursos de plataforma e push notification passam normalmente — Basecamp e HEY são publicados assim. O risco é real pra um app que é genuinamente uma web view e nada mais, que é exatamente o que essa arquitetura não é.

Dá pra usar Hotwire Native sem Rails?

Dá. O lado nativo só precisa de respostas HTML e, opcionalmente, de um endpoint JSON de path configuration. O Rails te dá as conveniências do turbo-rails, mas nada no modelo de navegação exige ele.

Mapa mental rápido

toca num link
  ↓
nativo intercepta (a web view NÃO navega)
  ↓
path configuration decide: push? modal? controller nativo?
  ↓
nativo cria uma tela
  ↓
essa tela carrega a URL e renderiza HTML
  ↓
botão voltar desempilha a pilha NATIVA
  ↓
precisa de algo que HTML não faz? → bridge, carregando só intenção

Conclusão

Hotwire Native é uma boa troca quando um time só precisa entregar web e mobile e o app é majoritariamente formulário, lista e leitura. É uma troca ruim quando o app é um canvas, um jogo ou uma ferramenta offline-first.

Só que você não consegue avaliar a troca enquanto ainda pensa nisso como wrapper. O nativo é dono da pilha. O servidor propõe telas. A bridge carrega intenção.

Acerte esses três e o botão voltar para de ser mal-assombrado.