Resetar o estado de conexão da chamada ao desconectar — Plano de implementação
TLDR: resetar
connectionStatuseroomem todo caminho que encerra ou falha a conexão, e impedir queuseConnectionmarque “conectado” sem sala.
Spec:
.project/docs/specs/20260828081338_reset_call_connection_state.mdBranch: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.tsxe os hooks decontainers/Callconsomem a instância como está hoje. - Manter a proteção contra conexão dupla:
connect()continua ignorando chamadas enquanto o status éConnectingouConnected. - Não tocar em
useRemoteStreams.tsnem no registro duplicado de listener emconnectionManager.ts:80-87— estão fora do escopo da spec. - Baseline:
npx jest src/infra/CallManagerpassa (1 suite, 1 teste) antes de começar.make run.testdepende dos serviços do container de dev de pé (no such service: appquando estão baixados); com o container parado, usenpx 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.