Resetar o estado de conexão da chamada ao desconectar — Plano de implementação

TLDR: resetar connectionStatus e room em todo caminho que encerra ou falha a conexão, e impedir que useConnection marque “conectado” sem sala.

Spec: .project/docs/specs/20260828081338_reset_call_connection_state.md Branch: fix/reset-call-connection-state

Arquitetura: o connectionManager guarda connectionStatus e room no closure de um singleton de módulo, e hoje só escreve esses valores no caminho de sucesso do connect(). O fix mantém a estrutura atual e fecha o ciclo de vida: todo caminho de saída (disconnect, destroy, falha no connect) devolve o estado para Disconnected e zera room. Em cima disso, useConnection deixa de tratar undefined como conexão bem-sucedida.

Stack: TypeScript, Next.js (pages router), twilio-video, mitt, Jest + @testing-library/react.

Restrições globais

  • Não mudar a assinatura pública de ICallManager — provider.tsx, Controls.tsx e os hooks de containers/Call consomem a instância como está hoje.
  • Manter a proteção contra conexão dupla: connect() continua ignorando chamadas enquanto o status é Connecting ou Connected.
  • Não tocar em useRemoteStreams.ts nem no registro duplicado de listener em connectionManager.ts:80-87 — estão fora do escopo da spec.
  • Baseline: npx jest src/infra/CallManager passa (1 suite, 1 teste) antes de começar. make run.test depende dos serviços do container de dev de pé (no such service: app quando estão baixados); com o container parado, use npx jest.

Task 1: Reset do estado no connectionManager

Files: - Modify: src/infra/CallManager/modules/connectionManager.ts - Test: src/infra/CallManager/modules/__tests__/connectionManager.test.ts (criar)

Interfaces: - Consumes: connectionManager(strategy: ICallManagerSelectedStrategy, deps: IConnectionManagerDeps), EConnectionStatus, ECallManagerEvents de src/infra/CallManager/types.ts. - Produces: nenhuma mudança de assinatura. O retorno segue { connect, disconnect, initialize, destroy, reconnect, getRoom }.

  • [ ] Step 1: Write the failing test

```typescript // src/infra/CallManager/modules/tests/connectionManager.test.ts import { expect } from “@jest/globals”; import mitt from “mitt”; import connectionManager from “../connectionManager”; import { ECallManagerEvents } from “../../types”;

const createMockRoom = () => ({ name: “test-room”, sid: “test-sid”, localParticipant: { identity: “local-participant” }, });

const createMockStrategy = (room: any) => ({ connect: jest.fn().mockResolvedValue(room), disconnect: jest.fn().mockResolvedValue(undefined), reconnect: jest.fn(), });

const createManager = (strategy: any) => { const appEmitter = mitt<Record<ECallManagerEvents, any»(); const strategyEmitter = mitt<Record<ECallManagerEvents, any»();

const manager = connectionManager(strategy as any, { appEmitter, strategyEmitter, } as any);

return { manager, appEmitter }; };

describe(“connectionManager”, () => { it(“should connect again after a disconnect”, async () => { // arrange const room = createMockRoom(); const strategy = createMockStrategy(room); const { manager, appEmitter } = createManager(strategy); const onLocalParticipantConnected = jest.fn(); appEmitter.on( ECallManagerEvents.LocalParticipantConnected, onLocalParticipantConnected, );

// act
await manager.connect([], "test-token");
await manager.disconnect();
await manager.connect([], "test-token");

// assert
expect(strategy.connect).toHaveBeenCalledTimes(2);
expect(onLocalParticipantConnected).toHaveBeenCalledTimes(2);
expect(manager.getRoom()).toBe(room);   });

it(“should clear the room reference on disconnect”, async () => { // arrange const strategy = createMockStrategy(createMockRoom()); const { manager } = createManager(strategy);

// act
await manager.connect([], "test-token");
await manager.disconnect();

// assert
expect(manager.getRoom()).toBeNull();   });

it(“should connect again after a destroy”, async () => { // arrange const strategy = createMockStrategy(createMockRoom()); const { manager } = createManager(strategy);

// act
await manager.connect([], "test-token");
manager.destroy();
await manager.connect([], "test-token");

// assert
expect(strategy.connect).toHaveBeenCalledTimes(2);   });

it(“should allow a new connect after a failed one”, async () => { // arrange const room = createMockRoom(); const strategy = createMockStrategy(room); strategy.connect.mockRejectedValueOnce(new Error(“twilio is down”)); const { manager } = createManager(strategy);

// act
await manager.connect([], "test-token");
const secondRoom = await manager.connect([], "test-token");

// assert
expect(strategy.connect).toHaveBeenCalledTimes(2);
expect(secondRoom).toBe(room);   });

it(“should treat an empty room from the strategy as a failure”, async () => { // arrange const room = createMockRoom(); const strategy = createMockStrategy(room); strategy.connect.mockResolvedValueOnce(undefined); const { manager, appEmitter } = createManager(strategy); const onLocalParticipantConnected = jest.fn(); appEmitter.on( ECallManagerEvents.LocalParticipantConnected, onLocalParticipantConnected, );

// act
const firstRoom = await manager.connect([], "test-token");
const secondRoom = await manager.connect([], "test-token");

// assert
expect(firstRoom).toBeUndefined();
expect(secondRoom).toBe(room);
expect(onLocalParticipantConnected).toHaveBeenCalledTimes(1);   });

it(“should ignore a concurrent connect while one is in flight”, async () => { // arrange const room = createMockRoom(); const strategy = createMockStrategy(room); let resolveConnect: (value: any) => void = () => undefined; strategy.connect.mockReturnValueOnce( new Promise((resolve) => { resolveConnect = resolve; }), ); const { manager } = createManager(strategy);

// act
const first = manager.connect([], "test-token");
const second = manager.connect([], "test-token");
resolveConnect(room);
await Promise.all([first, second]);

// assert
expect(strategy.connect).toHaveBeenCalledTimes(1);   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash npx jest src/infra/CallManager/modules

Expected: FAIL — should connect again after a disconnect recebe expect(strategy.connect).toHaveBeenCalledTimes(2) mas o mock foi chamado 1 vez (Expected: 2, Received: 1), porque o segundo connect() sai no early return com o status ainda em Connected. Também falham should clear the room reference on disconnect, should connect again after a destroy, should allow a new connect after a failed one e should treat an empty room from the strategy as a failure.

  • [ ] Step 3: Write minimal implementation

Em src/infra/CallManager/modules/connectionManager.ts, substituir disconnect (linhas 47-53), destroy (linhas 98-130, só o início) e connect (linhas 132-158):

```typescript const disconnect = async () => { await strategy.disconnect();

const destroyLocalStreams = destroyStreams.getDestroy();

if (destroyLocalStreams) destroyLocalStreams();

connectionStatus = EConnectionStatus.Disconnected;
room = null;   }; ```

```typescript const destroy = () => { console.log(“has dettached events”); connectionStatus = EConnectionStatus.Disconnected; room = null;

// ...restante do destroy segue igual ```

```typescript const connect = async (tracks: TTrack[], token: string) => { // TODO: handle it in a connection manager entity if ( connectionStatus === EConnectionStatus.Connecting || connectionStatus === EConnectionStatus.Connected ) return;

try {
  connectionStatus = EConnectionStatus.Connecting;

  const connectedRoom = (await strategy.connect(
    tracks,
    token,
  )) as TCallManagerRoom;

  if (!connectedRoom) throw new Error("the strategy returned no room");

  room = connectedRoom;
  connectionStatus = EConnectionStatus.Connected;

  appEmitter.emit(
    ECallManagerEvents.LocalParticipantConnected,
    room.localParticipant,
  );

  return room;
} catch (err) {
  connectionStatus = EConnectionStatus.Disconnected;
  room = null;

  logger.error(
    "error while trying to connect in the connectionManager:",
    err,
  );
}   }; ```
  • [ ] Step 4: Run to verify it passes

bash npx jest src/infra/CallManager

Expected: PASS — 2 suites, 7 testes (as 6 novas + a existente de connectionFactory).

  • [ ] Step 5: Commit

bash git add src/infra/CallManager/modules/connectionManager.ts src/infra/CallManager/modules/__tests__/connectionManager.test.ts git commit -m "fix: reset call connection state on disconnect"


Task 2: useConnection não marca conectado sem sala

Files: - Modify: src/containers/Call/hooks/useConnection.ts - Test: src/containers/Call/hooks/__tests__/useConnection.test.tsx (criar)

Interfaces: - Consumes: callManager.connect(tracks, token) => Promise<TCallManagerRoom | undefined> (Task 1 garante undefined em falha), CallManagerContext de src/providers/CallManager/provider.tsx. - Produces: nenhuma mudança de assinatura — o hook continua retornando a função connect.

  • [ ] Step 1: Write the failing test

```tsx // src/containers/Call/hooks/tests/useConnection.test.tsx import React from “react”; import { expect } from “@jest/globals”; import { renderHook, waitFor } from “@testing-library/react”; import useConnection from “../useConnection”; import { CallManagerContext } from “@/providers/CallManager/provider”;

const mockIdentifyUser = jest.fn();

jest.mock(“next/router”, () => ({ useRouter: () => ({ query: { id: “test-room-id” } }), }));

jest.mock(“next-auth/react”, () => ({ useSession: () => ({ data: { user: { pid: “test-pid”, name: “Test User” } }, }), }));

jest.mock(“@/hoc/withLogRocket”, () => ({ identifyUser: (…args: any[]) => mockIdentifyUser(…args), }));

const localStreams = { audio: { kind: “audio”, isEnabled: true }, video: { kind: “video”, isEnabled: true }, } as any;

const createMockCallManager = () => ({ connect: jest.fn(), disconnect: jest.fn(), });

const renderUseConnection = (callManager: any) => renderHook(() => useConnection(“test-token”, localStreams), { wrapper: ({ children }: { children: React.ReactNode }) => ( <CallManagerContext.Provider value={callManager}> {children} </CallManagerContext.Provider> ), });

describe(“useConnection”, () => { beforeEach(() => jest.clearAllMocks());

it(“should not report a connection when no room comes back”, async () => { // arrange const callManager = createMockCallManager(); callManager.connect.mockResolvedValue(undefined);

// act
const { unmount } = renderUseConnection(callManager);
await waitFor(() => expect(callManager.connect).toHaveBeenCalledTimes(1));
unmount();

// assert
expect(mockIdentifyUser).not.toHaveBeenCalled();
expect(callManager.disconnect).not.toHaveBeenCalled();   });

it(“should report a connection when a room comes back”, async () => { // arrange const callManager = createMockCallManager(); callManager.connect.mockResolvedValue({ name: “test-room”, sid: “test-sid”, localParticipant: { identity: “local-participant” }, });

// act
const { unmount } = renderUseConnection(callManager);
await waitFor(() => expect(mockIdentifyUser).toHaveBeenCalledTimes(1));
unmount();

// assert
expect(callManager.disconnect).toHaveBeenCalledTimes(1);   }); }); ```
  • [ ] Step 2: Run to verify it fails

bash npx jest src/containers/Call/hooks

Expected: FAIL — should not report a connection when no room comes back quebra em expect(mockIdentifyUser).not.toHaveBeenCalled() (Expected number of calls: 0, Received: 1), porque hoje identifyUser e setConnected(true) rodam mesmo com connect() devolvendo undefined.

  • [ ] Step 3: Write minimal implementation

Em src/containers/Call/hooks/useConnection.ts, no corpo do connect (linhas 22-37):

```typescript try { const userPID = (userSession as any).user.pid; const room = await callManager.connect( [streams.audio, streams.video], accessToken, );

    if (!room) {
      logger.error("connection finished without a room, not connected");
      return;
    }

    identifyUser(userSession as any, {
      ...room,
      name: userSession.user?.name,
      email: userPID,
    });
    setConnected(true);
  } catch (err) {
    logger.error(err);
  } ```
  • [ ] Step 4: Run to verify it passes

bash npx jest src/containers/Call/hooks src/infra/CallManager

Expected: PASS — 3 suites, 9 testes.

  • [ ] Step 5: Commit

bash git add src/containers/Call/hooks/useConnection.ts src/containers/Call/hooks/__tests__/useConnection.test.tsx git commit -m "fix: only report connection when room is returned"


Task 3: Verificação final e documentação

Files: - Create: .project/docs/learnings/module_singleton_state_survives_navigation.md - Modify: .project/docs/README.md (índice), .project/docs/specs/20260828081338_reset_call_connection_state.md (status: done)

Interfaces: - Consumes: resultado das Tasks 1 e 2. - Produces: nada de código.

  • [ ] Step 1: Rodar a suíte inteira e o lint

bash npx jest npm run lint

Expected: PASS, sem novos erros em relação ao baseline.

  • [ ] Step 2: Teste manual com dois usuários

Seguir o roteiro de “Como verificar” da spec: A e B entram na sessão, A sai por navegação client-side e reentra pela sala de espera, e B volta a ver câmera e ouvir áudio de A sem F5. Conferir no console de A o Twilio connected na segunda entrada e a ausência de there is no room yet ao alternar câmera/microfone.

  • [ ] Step 3: Escrever o learning

Criar .project/docs/learnings/module_singleton_state_survives_navigation.md com o formato de learning do commons:docs: estado mutável em singleton de módulo (callManager criado fora do React em provider.tsx) sobrevive à navegação client-side do Next e só é limpo por reload, então todo estado de ciclo de vida precisa de reset explícito no caminho de encerramento — e o sintoma típico é “só funciona depois do F5”. Adicionar a linha correspondente no índice .project/docs/README.md e mudar o status da spec para done.

  • [ ] Step 4: Commit

bash git add .project/docs git commit -m "docs: learning on module singleton state reset"

  • [ ] Step 5: PR

Aplicar commons:verify antes de afirmar que está pronto, depois commons:finish para abrir o PR e commons:asana para criar a subtask com o link do PR.