Repository navigation
Description
Contexto
O relógio do countdown é criado uma vez e nunca é desligado quando o countdown acaba —
ele continua batendo a 10 Hz durante a corrida inteira, re-renderizando a árvore da sala
sem que nada mude na tela.
src/lib/useRoom.ts:411-418:
// Tick a clock only while a countdown is pending.
const startMs = room?.start_at ? Date.parse(room.start_at) : 0;
useEffect(() => {
if (room?.status !== "racing" || !startMs) return;
if (Date.now() >= startMs) return; // guarda só de MONTAGEM
const t = setInterval(() => setNow(Date.now()), 100);
return () => clearInterval(t);
}, [room?.status, startMs]); // ← nenhuma das duas muda no startA guarda de :415 só roda quando o efeito roda. As deps são room?.status e startMs:
durante toda a corrida o status permanece "racing" e startMs é o mesmo número, então o
efeito não re-executa e o clearInterval só chega quando o status vira "finished"
(ou no unmount). Entre o fim do countdown e o fim da corrida, o intervalo bate ~10×/s.
O trabalho é 100% desperdício. now (:54) tem um único consumidor — countdownN
(:466-469) — que devolve null assim que now >= startMs. Cada um desses ~10 renders/s
produz uma árvore idêntica à anterior.
E ele alcança a área sagrada (HANDBOOK §2). RoomView não é memo e re-renderiza a cada
setNow; dentro dele, durante a corrida, Race (RoomView.tsx:120) e RaceTrack
também não são memo (Race.tsx:10, RaceTrack.tsx:17 — só TypingCore, CodeDisplay
e CodeEditor ganharam memo na #105). Ou seja: RoomView + Race + RaceTrack (até 30
linhas de jogador, room.ts:282) reconciliam 10×/s na mesma main thread da textarea.
Não é a #59 nem a fatia 2 dela. A #59 ataca custo proporcional ao nº de jogadores
(mensagens de progresso alheio) e a fatia 2 é width/left → scaleX/translateX em
RaceTrack. Este aqui é constante, 10 Hz, independente de quantos jogadores existem —
acontece até numa sala de 1 pessoa. As duas correções são ortogonais.
Quem é atingido: todo mundo que estava presente no countdown, ou seja, todos os
competidores de verdade. Quem entra depois do start cai na guarda de :415 e escapa —
espectador (#64) não paga, competidor paga.
Acceptance criteria
- Passado o instante de
start_at, nenhumsetNowcontinua agendado enquanto
status === "racing". Verificável instrumentando o setter (contador de chamadas). - O countdown visual permanece idêntico:
Countdown nconta 3→2→1 com a mesma cadência,
inclusive para quem entra durante o countdown (o caminho de:415que hoje funciona
não pode regredir). - Medição antes/depois no PR, mesmo método da perf(race): corta re-render de CodeDisplay/CodeEditor por progresso alheio (#59, fatia 1) #105: renders de
RoomViewnuma janela de
10 s de corrida ociosa (ninguém digitando). Hoje ≈ 100; alvo ≈ 0 vindos do relógio. - Sala que termina e volta ao lobby (
reset) e depois começa nova corrida volta a contar
o countdown normalmente — o relógio tem de religar, não morrer de vez. - Sem tocar em
PROGRESS_THROTTLE_MS,broadcastProgress,maybeFinishou
RACE_DECISION_TICK_MS. - Gate verde:
pnpm typecheck && pnpm build,vitest,validate-metrics,
validate-persistence.
Dica de abordagem
Duas saídas simples, ambas cabendo em poucas linhas de useRoom.ts:
- Auto-encerrar o intervalo: no próprio tick, se
Date.now() >= startMs, dar um último
setNow(para ocountdownNvirarnull) eclearIntervalali mesmo; ou - Trocar por
setTimeoutencadeado / um timeout único para o instante exato do start,
já que o alvo é conhecido (startMs) e o número de ticks é finito.
Prefira a variante que mantenha a guarda de montagem de :415 intacta (é ela que poupa o
espectador). Cuidado com o caso startMs no passado por relógio dessincronizado: o efeito
não pode virar um loop de setNow.
Classificação: §7.3 normal — só src/lib/useRoom.ts; não toca api/rooms/** nem
src/lib/supabase.ts.
Activity
caioross commentedon Aug 5, 2026
🏛️ Parecer do Conselho · 2026-08-05
Veredito: APROVADA PARA EXECUÇÃO — §7.3 confirmado (só
src/lib/useRoom.ts; não tocaapi/rooms/**nemsrc/lib/supabase.ts). Prioridade P2 mantida: é desperdício constante e barato de remover, não regressão de produção — não inflar para P1 sem medição.Premissas reconferidas em
origin/main@d8ee488e as três se sustentam: o efeito deuseRoom.ts:413-418tem deps[room?.status, startMs], nenhuma das duas muda no instante do start, então oclearIntervalsó chega emfinished/unmount ·now(:54) tem um único consumidor,countdownN(:466-469) — varri o arquivo, todas as outras leituras de relógio sãoDate.now()direto (:508,:518,:569-570,:582-584) ·RoomView,Race(Race.tsx:10) eRaceTrack(RaceTrack.tsx:17) não sãomemo.countdownNé derivado denow(:467-468), então sem ticks periódicos o dígito congela em 4 e salta direto para a corrida — o 3→2→1 some. Vale a variante do auto-encerramento (ou umsetTimeoutencadeado alinhado ao segundo, que dá o mesmo resultado com ~4 renders em vez de ~40). Concretamente, mantendo a guarda de montagem de:415intacta (é ela que poupa o espectador,:439):startMsmuda com ostart_atnovo, o efeito re-roda e o relógio religa.maybeFinish(:499-518usaDate.now()direto) nem porbroadcastProgress(:563-591), estart_até gerado no servidor (api/rooms/[code]/route.ts:153,Date.now() + COUNTDOWN_MS), nunca enviado pelo cliente. Não há entrada nova. Umstart_atabsurdamente no futuro manteria o timer vivo, mas isso é a fisiologia atual do countdown e pertence à Segurança/RLS: "sala privada" é enumerável — policy using(true) em rooms expõe toda sala ao anon (0002_realtime_rooms.sql:26-29) #103/Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34, não a esta issue.RoomView.tsx:79-80fazshowCountdown = countdownN != null/showRace = countdownN == null, ou seja durante o countdown oRacenem está montado. O desperdício existe exatamente na janela em que a issue diz existir (do start ao fim da corrida) e a regressão a temer é a oposta: um dígito que trava ou pula. Cheque os dois olhos — entrar antes do countdown e entrar durante ele.TypingCoreémemo(TypingCore.tsx:267) e suas props são estáveis —onProgress=sendProgresseonAbandon=abandonsãouseCallbackancorados embroadcastProgress, que tem deps[](useRoom.ts:563-602) —, logoCodeEditor/CodeDisplaye atextareanão reconciliam por causa do relógio. O que re-renderiza:RoomView→Race→RaceTrack(até 30 linhas) +FloatingChat, nenhummemo, ~10×/s, na mesma main thread do input. OuseMemodeplayers(:422-448) não recomputa (deps intactas), então o custo é rebuild de JSX + diff, sem mutação de DOM. Escreva isso assim no PR — "10 Hz de reconciliação vizinha da textarea", não "a textarea re-renderiza 10×/s". A AC 3 (contador no setter, 10 s de corrida ociosa: ~100 → ~0) é a prova certa e é determinística; não tente medir isso pelo visual do Browser pane.widthnum spring ao lado da textarea — mesmo defeito da #59 no ponto mais sensível (TypingCore.tsx:251-256) #114 (que ataca layout/paint do spring, não re-render): as duas podem entrar em qualquer ordem, e nenhuma torna a outra desnecessária.Plano sugerido:
useRoom.ts:413-418: manter a guarda de:415e auto-encerrar o intervalo no tick (bloco acima). Comentar por que oclearIntervalestá dentro do callback — a próxima pessoa vai querer "simplificar" para umsetTimeoutúnico.setNow(ou de renders deRoomView) numa janela de 10 s de corrida ociosa (ninguém digitando), antes e depois. Medir durante o countdown não mostra nada — oRacenem está montado (RoomView.tsx:79-80).:415continua poupando quem chega depois do start,:439) · terminar →reset→ nova corrida (relógio religa, AC 4).PROGRESS_THROTTLE_MS,broadcastProgress,maybeFinish,RACE_DECISION_TICK_MS(AC 5). Nada dememonovo emRace/RaceTrackneste PR — é outra issue e outro risco.pnpm typecheck·pnpm build·pnpm test·validate-metrics·validate-persistence(o gate real da CI incluipnpm test).Vetores de veto previstos:
start_atnasce no servidor (route.ts:153) e nenhuma métrica/decisão de fim lênow. Superfície: zero. Nada a responder.Date.now()local nunca alcançastartMs, o auto-encerramento não dispara — e é a mesma condição em que a guarda de:415já falha hoje. Resíduo declarado, e o plano NÃO responde — de propósito: pôr um teto por tempo-de-vida (ex.:COUNTDOWN_MS+ folga) seria pior, porquecountdownNsó avança quandosetNowroda; matar o timer comcountdownN != nullcongelaria o jogador no countdown para sempre, sem nenhum outro caminho que façanowavançar. O jogador com relógio dessincronizado já perde o início da corrida hoje; consertar isso é timing relativo ao servidor, território da Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34/fix(anti-cheat): piso de coerência temporal no finish — WPM impossível não entra mais no ranking (#34) #119, e não deve entrar neste PR. O outro estado a vigiar é o inverso —clearIntervalcedo demais, deixando o dígito preso: coberto pelo passo 3.