Resetar o estado de conexão da chamada ao desconectar

TLDR: connectionStatus nunca volta para Disconnected, então quem sai da sessão e entra de novo sem recarregar a página não reconecta ao Twilio e não publica câmera nem microfone para o outro participante.

Contexto

Relato: um usuário está em sessão, sai da sessão e entra de novo — a câmera dele não aparece para a outra pessoa. Ele mesmo continua vendo a própria imagem e a UI se comporta como se estivesse tudo conectado. Só um F5 (feito por quem saiu) restaura o comportamento normal.

Causa raiz confirmada na investigação:

  1. O callManager é um singleton de módulo, criado fora do React em src/providers/CallManager/provider.tsx:11. Ele sobrevive a qualquer navegação client-side — e a entrada na sala vem de um next/link (src/components/ui/LinkButton/index.tsx, component = Link), ou seja, navegação sem reload.
  2. connectionStatus (src/infra/CallManager/modules/connectionManager.ts:16) só é escrito para Connecting e Connected. Nem disconnect() (:47), nem destroy() (:98), nem o catch do connect() (:152) devolvem o estado para Disconnected.
  3. connect() (:132-138) faz early return quando o status é Connecting ou Connected. Na segunda entrada na sala ele retorna imediatamente sem conectar nada.
  4. Sem conexão, o evento LocalParticipantConnected nunca é emitido; src/containers/Call/hooks/useStreaming.ts:44-60 depende desse evento para marcar connected, então startStreaming() nunca roda e as tracks locais nunca são publicadas. O outro participante não recebe vídeo nem áudio.
  5. O preview local não depende da sala (é attach direto na track), por isso quem voltou vê a própria imagem e não percebe o problema.
  6. src/containers/Call/hooks/useConnection.ts:34 chama setConnected(true) mesmo quando connect() retornou undefined, então a UI declara “conectado” sobre uma conexão que não existe.
  7. O F5 recria o módulo, connectionStatus volta a Disconnected e a conexão acontece normalmente.

Há um caminho adicional que produz o mesmo estado travado sem o usuário sair da página: initialize() registra disconnect em pagehide (:95). No mobile, colocar a aba em background dispara pagehide e derruba a sala; ao voltar (inclusive via bfcache, sem reexecutar módulos) o status continua Connected e nada reconecta.

O mesmo defeito trava a chamada em um segundo cenário: se o connect() falhar, o status fica preso em Connecting para sempre, e nenhuma tentativa posterior de conectar acontece até um reload.

Objetivos

  • Devolver connectionStatus para Disconnected em todo caminho que encerra ou falha a conexão, para que uma nova entrada na sala reconecte de fato
  • Zerar a referência de room junto com o status, para que startStreaming/stopStreaming não operem sobre uma sala morta
  • Impedir que a UI se declare conectada quando connect() não devolveu uma sala

Fora de escopo

  • O registro duplicado de RemoteParticipantUnavailable em connectionManager.ts:80-87, que vaza um listener por ciclo de navegação (achado da investigação, spec separada)
  • A blindagem de src/containers/Call/hooks/useRemoteStreams.ts, que anula o stream remoto por kind sem verificar se a track removida é a que está em uso — tela preta latente para quem fica na sala, mas não é a causa deste relato (achado da investigação, spec separada)
  • Reconexão automática ao voltar de pagehide/bfcache. Este fix garante que uma nova entrada na sala funcione; reconectar sozinho ao voltar do background é mudança de comportamento e fica para outra spec
  • Trocar o singleton de módulo por instância por mount do provider

Mudanças

Arquivo O que muda
src/infra/CallManager/modules/connectionManager.ts disconnect() passa a aguardar strategy.disconnect() e, ao final, seta connectionStatus = EConnectionStatus.Disconnected e room = null. destroy() também reseta connectionStatus (hoje só zera room). O catch do connect() reseta connectionStatus para Disconnected e room para null, para que uma falha não trave todas as tentativas seguintes. Se strategy.connect() devolver algo falsy, tratar como falha (mesmo caminho do catch) em vez de marcar Connected.
src/containers/Call/hooks/useConnection.ts connect() só chama setConnected(true) (e identifyUser) quando callManager.connect() devolveu uma sala; sem sala, apenas loga o erro e mantém isConnected como false, permitindo nova tentativa.
src/infra/CallManager/modules/__tests__/connectionManager.test.ts Novo arquivo de teste. Casos: (a) connect → disconnect → connect conecta de novo e emite LocalParticipantConnected na segunda vez; (b) connect → destroy → connect conecta de novo; (c) uma falha em strategy.connect não impede o connect seguinte de funcionar; (d) chamadas concorrentes de connect continuam protegidas pelo early return enquanto o status é Connecting/Connected (não regredir a proteção contra conexão dupla).

Como verificar

Automatizado:

bash make run.test path=src/infra/CallManager

O teste do caso (a) deve falhar antes do fix e passar depois.

Manual, com dois usuários na mesma sessão:

  1. Usuário A e usuário B entram na sessão; ambos se veem.
  2. Usuário A sai da sala por navegação client-side (sem reload) e entra de novo pela sala de espera.
  3. Esperado: B volta a ver a câmera e ouvir o áudio de A, sem ninguém precisar dar F5.
  4. Nos logs do console de A na segunda entrada deve aparecer o Twilio connected de connectionFactory.ts, e não deve aparecer there is no room yet de streamManager.ts ao alternar câmera/microfone.
  5. Regressão a checar: entrar pela primeira vez continua conectando uma única vez (sem sala duplicada) e o botão “Encerrar Chamada” continua levando para o pós-atendimento.

Documentação

  • .project/docs/learnings/: registrar o aprendizado sobre estado mutável em singleton de módulo em app Next.js — sobrevive à navegação client-side e só é limpo por reload, então todo estado de ciclo de vida precisa de reset explícito no caminho de encerramento.
  • .project/docs/rules/: nenhuma regra de negócio muda.