Skip to content

Performance/Área sagrada: o relógio do countdown nunca desliga — 10 Hz de re-render de RoomView/Race/RaceTrack durante a corrida inteira (useRoom.ts:411-418) #111

Open

Description

@caioross

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 start

A 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

  1. Passado o instante de start_at, nenhum setNow continua agendado enquanto
    status === "racing". Verificável instrumentando o setter (contador de chamadas).
  2. O countdown visual permanece idêntico: Countdown n conta 3→2→1 com a mesma cadência,
    inclusive para quem entra durante o countdown (o caminho de :415 que hoje funciona
    não pode regredir).
  3. 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 RoomView numa janela de
    10 s de corrida ociosa (ninguém digitando). Hoje ≈ 100; alvo ≈ 0 vindos do relógio.
  4. 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.
  5. Sem tocar em PROGRESS_THROTTLE_MS, broadcastProgress, maybeFinish ou
    RACE_DECISION_TICK_MS.
  6. 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 o countdownN virar null) e clearInterval ali mesmo; ou
  • Trocar por setTimeout encadeado / 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

  1. added
    P2Prioridade normal
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:uiInterface, design system, animação, game feel
    on Jul 30, 2026
  2. caioross commented on Aug 5, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-08-05

    Veredito: APROVADA PARA EXECUÇÃO — §7.3 confirmado (só src/lib/useRoom.ts; não toca api/rooms/** nem src/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@d8ee488 e as três se sustentam: o efeito de useRoom.ts:413-418 tem deps [room?.status, startMs], nenhuma das duas muda no instante do start, então o clearInterval só chega em finished/unmount · now (:54) tem um único consumidor, countdownN (:466-469) — varri o arquivo, todas as outras leituras de relógio são Date.now() direto (:508, :518, :569-570, :582-584) · RoomView, Race (Race.tsx:10) e RaceTrack (RaceTrack.tsx:17) não são memo.

    • Engenharia: a Dica de abordagem oferece duas saídas e uma delas quebra a AC 2. "Um timeout único para o instante exato do start" não serve: countdownN é derivado de now (: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 um setTimeout encadeado alinhado ao segundo, que dá o mesmo resultado com ~4 renders em vez de ~40). Concretamente, mantendo a guarda de montagem de :415 intacta (é ela que poupa o espectador, :439):
      const t = setInterval(() => {
        setNow(Date.now());
        if (Date.now() >= startMs) clearInterval(t);   // `t` já está atribuído quando o 1º tick roda
      }, 100);
      Não mude os 100 ms (AC 2 pede cadência idêntica) e não toque em mais nada (AC 5). A AC 4 (reset → nova corrida) sai de graça: startMs muda com o start_at novo, o efeito re-roda e o relógio religa.
    • Segurança/Anti-cheat: nula em ambos os sentidos — o relógio é local, não é lido por maybeFinish (:499-518 usa Date.now() direto) nem por broadcastProgress (:563-591), e start_at é gerado no servidor (api/rooms/[code]/route.ts:153, Date.now() + COUNTDOWN_MS), nunca enviado pelo cliente. Não há entrada nova. Um start_at absurdamente 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.
    • UX/Game-feel: invisível quando bem feito — e é essa a prova. Atenção ao que a issue não diz: RoomView.tsx:79-80 faz showCountdown = countdownN != null / showRace = countdownN == null, ou seja durante o countdown o Race nem 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.
    • Performance: aqui é onde o PR precisa ser honesto, senão leva veto. O que não re-renderiza a 10 Hz: TypingCore é memo (TypingCore.tsx:267) e suas props são estáveis — onProgress=sendProgress e onAbandon=abandon são useCallback ancorados em broadcastProgress, que tem deps [] (useRoom.ts:563-602) —, logo CodeEditor/CodeDisplay e a textarea não reconciliam por causa do relógio. O que re-renderiza: RoomView → Race → RaceTrack (até 30 linhas) + FloatingChat, nenhum memo, ~10×/s, na mesma main thread do input. O useMemo de players (: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.
    • Produto: valor real para o Competidor (§0.3) e, ao contrário da Performance/Área sagrada: progresso de cada adversário re-renderiza CodeDisplay e a textarea — custo de digitar cresce com o nº de jogadores #59, independe do nº de jogadores — paga até quem corre sozinho. Vale ~10 linhas. Ortogonal à Performance/Área sagrada: a barra de progresso do TypingCore anima width num 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:

    1. useRoom.ts:413-418: manter a guarda de :415 e auto-encerrar o intervalo no tick (bloco acima). Comentar por que o clearInterval está dentro do callback — a próxima pessoa vai querer "simplificar" para um setTimeout único.
    2. Instrumentar para a AC 3: contador de chamadas de setNow (ou de renders de RoomView) numa janela de 10 s de corrida ociosa (ninguém digitando), antes e depois. Medir durante o countdown não mostra nada — o Race nem está montado (RoomView.tsx:79-80).
    3. Conferir os 3 caminhos à mão: entrar antes do countdown (3→2→1 idêntico) · entrar durante o countdown (:415 continua poupando quem chega depois do start, :439) · terminar → reset → nova corrida (relógio religa, AC 4).
    4. Não encostar em PROGRESS_THROTTLE_MS, broadcastProgress, maybeFinish, RACE_DECISION_TICK_MS (AC 5). Nada de memo novo em Race/RaceTrack neste PR — é outra issue e outro risco.
    5. Gate: pnpm typecheck · pnpm build · pnpm test · validate-metrics · validate-persistence (o gate real da CI inclui pnpm test).

    Vetores de veto previstos:

    • AppSec/Ofensiva: não há defesa a desligar nem entrada a forjar — o timer é puramente local, start_at nasce no servidor (route.ts:153) e nenhuma métrica/decisão de fim lê now. Superfície: zero. Nada a responder.
    • Domínio: a entrada honesta que derrota o plano é o relógio do cliente atrasado: se Date.now() local nunca alcança startMs, o auto-encerramento não dispara — e é a mesma condição em que a guarda de :415 já 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, porque countdownN só avança quando setNow roda; matar o timer com countdownN != null congelaria o jogador no countdown para sempre, sem nenhum outro caminho que faça now avanç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 — clearInterval cedo demais, deixando o dígito preso: coberto pelo passo 3.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:uiInterface, design system, animação, game feel

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions