Skip to content

Performance/Área sagrada: progresso de cada adversário re-renderiza CodeDisplay e a textarea — custo de digitar cresce com o nº de jogadores #59

Closed

Description

@caioross

Sintoma

Durante a corrida, cada mensagem de progresso de qualquer adversário re-renderiza a árvore
inteira da sala
— incluindo CodeDisplay (que reconstrói o realce char-a-char do snippet)
e CodeEditor (a textarea da área sagrada). Com 6 jogadores digitando a 120ms cada, são
~50 re-renders completos por segundo competindo com o input do jogador — exatamente o que
o HANDBOOK §2 proíbe. Quanto mais gente na sala, mais pesada fica a digitação de cada um:
o custo é O(jogadores) para todo mundo.

Causa (arquivo:linha, contra origin/main)

A cadeia é state do hook → prop nova → filho sem memo:

  1. src/lib/useRoom.ts:178 — broadcast de progresso alheio faz
    setProgress(prev => ({ ...prev, [m.id]: m })) → re-render do useRoom.
  2. useRoom.ts:278 — o useMemo de players depende de progress, então um array novo
    sai a cada mensagem.
  3. src/components/RoomView.tsx:31-56 — compatRoom depende de players, então um objeto
    RoomState novo
    (com players.map(liveToPlayer), RoomView.tsx:35) sai junto.
  4. RoomView.tsx:114-121 — <Race room={compatRoom} …> recebe prop nova ⇒ Race re-renderiza
    ⇒ CodeDisplay, CodeEditor, RaceTrack e FloatingChat re-renderizam todos.
    Nenhum desses componentes é React.memo (verificado nos quatro arquivos), e CodeDisplay
    / CodeEditor não dependem de players — não deviam re-renderizar por progresso alheio.

O mesmo caminho é percorrido pelo próprio jogador: broadcastProgress faz setProgress
otimista a cada tecla (useRoom.ts:383, fora do throttle de rede da linha 387), e o
tick de 200ms + heartbeat de 1s (Race.tsx:49,71-76) mantêm o ciclo girando mesmo parado.

Acceptance criteria

  1. Digitar uma tecla não re-renderiza RaceTrack nem FloatingChat; progresso de
    adversário não re-renderiza CodeDisplay nem CodeEditor. Verificável com
    <Profiler> ou contadores de render num harness de dev (a Infra/UI: harness só-de-dev para verificar telas pós-corrida (Results/pódio) sem jogar #37 cria justamente esse
    harness — use-o se já tiver mergeado, senão um contador temporário serve para a evidência).
  2. Evidência antes/depois no PR: nº de renders de CodeDisplay/CodeEditor numa janela de
    10s com 4 jogadores simulados broadcastando a 120ms. Meta: queda ≥90%.
  3. Nenhuma regressão funcional: pista ao vivo, posições, chat flutuante, stats pessoais e
    fim de corrida seguem idênticos; players continua sendo a fonte única do roster.
  4. Área sagrada preservada — sem novo timer de alta frequência; latência tecla→pixel intacta.
    prefers-reduced-motion continua respeitado.
  5. pnpm typecheck && pnpm build verdes + node scripts/validate-metrics.mjs.

Dica de abordagem

Cortar a propagação nos dois pontos baratos, sem reescrever nada:

  • React.memo em CodeDisplay, CodeEditor, RaceTrack e FloatingChat, garantindo que
    as props que chegam sejam estáveis (as callbacks já vêm de useCallback no useRoom).
  • Parar de passar o objeto compatRoom inteiro para os filhos que só precisam de fatias:
    CodeDisplay/CodeEditor precisam de code/language/typed — nada disso muda quando
    um adversário progride. Passar as fatias (ou separar o snippet do roster no useMemo de
    RoomView.tsx:31) elimina a invalidação na raiz, e o memo vira rede de segurança.
  • Opcional, se ainda sobrar custo: coalescer os setProgress de rede num tick de ~100ms
    (rAF ou timer único) em vez de um setState por mensagem — mas medir antes, os dois itens
    acima podem já resolver.

Não mexer no throttle de envio (PROGRESS_THROTTLE_MS, useRoom.ts:20): o problema é o
custo de renderização no receptor, não a frequência na rede.

Activity

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

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-24

    Veredito: APROVADA PARA EXECUÇÃO — como fatia 1 de 2, com uma correção de premissa que
    muda o plano (ver Engenharia, item 1). Executar a issue como está escrita hoje produziria um PR
    que mede ~0% de melhora.

    Revisado contra origin/main (d0c76ef). A cadeia causal da issue está correta e confirmada
    linha a linha: useRoom.ts:178 → useMemo de players em :278-302 → compatRoom em
    RoomView.tsx:31-56 → <Race room={compatRoom}> em RoomView.tsx:112-120 → TypingCore em
    Race.tsx:34-42. Nenhum dos quatro componentes é React.memo (confirmado: não há nenhum
    memo( em src/).

    • Engenharia: três achados que a issue não tem, em ordem de impacto.
      1. A premissa "as callbacks já vêm de useCallback no useRoom" é falsa nas duas que importam.
        abandon é useCallback([broadcastProgress, progress]) (useRoom.ts:399-402) — progress é
        exatamente o estado que muda a cada mensagem de adversário, então abandon ganha identidade
        nova a cada broadcast
        . Ele desce por RoomView.tsx:118 → Race.tsx:41 → TypingCore →
        CodeEditor (onAbandon). Ou seja: React.memo(CodeEditor) não morde nada enquanto isso
        não for consertado. O mesmo vale para sendChat, useCallback([presence]) (useRoom.ts:404-419),
        que alimenta FloatingChat. As estáveis são broadcastProgress/sendProgress
        (:365-397, deps []) e setReady/kick (:422-436).
        Correção barata e no estilo do arquivo (que já usa refs para isso): guardar a última msg própria
        num myLastMsgRef dentro de broadcastProgress (onde ela já é construída, :373-381) e fazer
        abandon lê-la do ref → deps [broadcastProgress] → estável.
      2. memo em RaceTrack e FloatingChat é quase inócuo e não deve entrar como "vitória".
        players muda legitimamente a cada progresso (é o roster), e FloatingChat recebe
        players={room.players} — array novo sempre. Memo neles só bloqueia o re-render vindo do
        relógio, não o vindo de progresso.
      3. Existe um segundo emissor que a issue não mapeia, e ele é independente dos adversários:
        TypingCore.tsx:58-62 roda setInterval(() => setNow(Date.now()), 200). São 5 re-renders/s
        de CodeDisplay + CodeEditor mesmo numa corrida solo
        , sem ninguém na sala. Isso muda o
        desenho: memo em TypingCore corta o tráfego de adversário, mas só memo em CodeDisplay
        e CodeEditor corta o tick de 200ms. Os dois níveis são necessários — e CodeDisplay
        reconstrói um <span> por caractere (CodeDisplay.tsx:61-91), que é o item caro da conta.
      4. Nota de contenção: useRoom.ts está em 3 PRs abertas (fix(multiplayer): corrida encerra por inatividade e por teto de duração — fim da sala presa em "racing" (#58) #60, fix(security): kick com autoridade de servidor via kicked_ids (#39) #44, fix(security): exige sala estagnada para claim-leader (anti-sequestro) #12). O fix(multiplayer): corrida encerra por inatividade e por teto de duração — fim da sala presa em "racing" (#58) #60 (liveness, non-draft,
        primeiro da fila) mexe no mesmo arquivo — espere rebase e mantenha o diff cirúrgico.
    • Segurança/Anti-cheat: neutro. O plano não toca PROGRESS_THROTTLE_MS (useRoom.ts:20), nem
      broadcastProgress, nem sanitizeResults, nem o caminho do finish — a issue já proíbe, e está
      certa. Uma armadilha real: se optar por coalescer setProgress (item opcional da issue), o
      buffer não pode engolir a última mensagem de um jogador que terminou — players alimenta o
      efeito de finalização do líder (useRoom.ts:331-337, players.every(p => p.finishedAt)) e o
      toResults que vai para o leaderboard. Um flush que descarta a msg final trava a corrida — o
      mesmo sintoma que a Multiplayer/Liveness: corrida nunca termina se um jogador presente parar de digitar — sala presa em "racing" e nada persiste #58/PR fix(multiplayer): corrida encerra por inatividade e por teto de duração — fim da sala presa em "racing" (#58) #60 acabou de consertar. Motivo suficiente para deixar o coalescing
      fora desta fatia.
    • UX/Game-feel: nada visível deve mudar; esse é o critério de sucesso. Aproveito para registrar
      um gap pré-existente (não é regressão desta issue, e não deve entrar neste PR): a AC 4 diz
      "prefers-reduced-motion continua respeitado", mas hoje ele não é respeitado na pista. A regra
      global de globals.css:50-57 zera só animation-duration/transition-duration de CSS; os springs
      de framer-motion em RaceTrack.tsx:114-123 e :125-129 são rAF em JS e ignoram a mídia-query —
      não há MotionConfig reducedMotion="user" em lugar nenhum de src/. Vale uma issue separada para
      o Curador; aqui, a AC 4 deve ser lida como "não piorar".
    • Performance: a maior fonte de custo por jogador não é o React e sobrevive intacta a todos os
      memo propostos: RaceTrack.tsx:114-123 anima width: "<pct>%" e :125-129 anima
      left: "calc(<pct>% - 8px)" — duas propriedades de layout, por jogador. São 2 springs × P
      jogadores (max_players vai até 12, route.ts:73), retargetados a até 8,3 Hz cada
      (PROGRESS_THROTTLE_MS = 120), portanto praticamente nunca em repouso durante a corrida: layout +
      paint na mesma main thread da textarea. Trocar por scaleX (com transform-origin: left) e
      translateX tira tudo do layout e é composited. Isso é fatia 2, mas precisa estar escrito antes de
      alguém declarar a Performance/Área sagrada: progresso de cada adversário re-renderiza CodeDisplay e a textarea — custo de digitar cresce com o nº de jogadores #59 resolvida.
      Sobre a medição (AC 2): scripts/realtime-test.mjs já constrói clientes realtime simulados com
      presence + broadcast de progresso — estenda-o para 4 clientes em vez de escrever um harness novo.
      Ressalva honesta: ele exige credenciais reais (.env.local, só o dono tem). Se não der, use
      contador de render temporário + <Profiler> e diga no PR que a medição foi local com broadcast
      stubado
      . A Infra/UI: harness só-de-dev para verificar telas pós-corrida (Results/pódio) sem jogar #37 (harness dev-only) está em-resolucao hoje — se mergear antes, use-a.
    • Produto: é a issue que protege a promessa central ("corrida ao vivo com amigos") justamente no
      cenário em que o produto tem valor — sala cheia. Hoje o jogo pune quem convida gente. P2 está
      correto
      dado que o sintoma é degradação e não quebra, mas dentro de P2 é a de maior alavancagem
      depois da Anti-cheat/Engine: arrastar-e-soltar o snippet-alvo na textarea burla o anti-paste — corrida 100% sem digitar (CodeEditor.tsx:305-327) #61. A fatia 1 é uma rodada tranquila; a fatia 2 (pista) é outra.

    Plano sugerido (fatia 1 — cortar a propagação):

    1. Branch a partir de origin/main. Fluxo §7.3 normal (componentes + hook; não toca
      api/rooms/** nem supabase.ts).
    2. useRoom.ts:365-402 — adicionar myLastMsgRef preenchido dentro de broadcastProgress
      (:373-381) e reescrever abandon para ler do ref, deixando deps [broadcastProgress].
      Verifique com o linter de hooks / à mão que abandon e sendProgress ficaram estáveis antes
      de qualquer memo — é o passo que faz todo o resto valer.
    3. React.memo em CodeDisplay (CodeDisplay.tsx:14) e CodeEditor (CodeEditor.tsx:18). Props de
      ambos já são primitivas + callbacks; CodeEditor só depende do onAbandon do passo 2.
    4. React.memo em TypingCore (TypingCore.tsx:21). Props que chegam de Race.tsx:34-42:
      code/language/startedAt (primitivos estáveis), finishedAt (valor estável),
      onProgress (já estável), heartbeat, onAbandon (estável após o passo 2). Isso corta o
      tráfego de adversário na raiz.
    5. Não aplique memo em RaceTrack/FloatingChat alegando ganho — se aplicar, diga no PR que
      é rede de segurança e que a medição não muda por causa deles.
    6. Corrija a AC 1 no PR. "Digitar uma tecla não re-renderiza RaceTrack" é insatisfazível por
      construção nesta fatia: cada tecla atualiza o meu próprio progresso otimista
      (useRoom.ts:383, fora do throttle de rede de :386-388) → players muda → a minha barra tem de
      andar. Re-render da pista a cada tecla é comportamento correto. Meça e reporte o que é de fato
      o alvo: renders de CodeDisplay/CodeEditor por progresso alheio, meta de queda ≥90% (AC 2).
    7. Evidência no PR: contagem antes/depois em janela de 10s + a nota de método (real vs. stubado).
    8. Gate: pnpm typecheck && pnpm build, pnpm test, validate-metrics e validate-persistence.
    9. Pedido ao Curador: abrir a fatia 2 — "pista fora do layout: width/left → scaleX/
      translateX em RaceTrack.tsx:114-129, + coalescing de setProgress com flush garantido da
      msg final" — e uma issue P3 separada para o MotionConfig reducedMotion="user".
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 28, 2026
  4. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    Fatia 1 entregue em #105 (non-draft, §7.3 normal — não toca api/rooms/** nem supabase.ts).

    Segui o plano do 🏛️ Parecer na íntegra. A correção de premissa do item 1 (o abandon instável)
    era mesmo decisiva: com React.memo sozinho, CodeEditor e TypingCore continuavam re-renderizando
    400/400 sobre 200 mensagens de progresso alheio; com o myLastMsgRef no abandon também, caem
    para 0. CodeDisplay já caía a 0 só com o memo. O tick de 200ms do relógio (item 3 do parecer)
    também está cortado: em 12s ociosos, TypingCore 24 renders · CodeDisplay/CodeEditor 0.
    Método e ressalvas (medição local com broadcast stubado, sobre réplica da cadeia de identidade)
    estão no corpo do PR.

    Gate: typecheck ✅ · build ✅ · validate-metrics 45/45 ✅ · validate-persistence 72/72 ✅ ·
    vitest 109/109 ✅ · lint N/A (sem config ESLint no repo).

    Refs, não Closes: a segunda metade da AC 1 (RaceTrack/FloatingChat por tecla) é
    insatisfazível nesta fatia — e, como o parecer apontou, é comportamento correto: cada tecla
    move o meu próprio progresso otimista, então a minha barra tem de andar.

    📌 Pedido ao Curador (item 9 do parecer): abrir a fatia 2 — pista fora do layout
    (width/left → scaleX/translateX em RaceTrack.tsx:114-129) + coalescing de setProgress
    com flush garantido da mensagem final — e uma P3 separada para MotionConfig reducedMotion="user".

  5. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-28 — escopo da fatia 2 (issue segue aberta e elegível)

    Atendendo ao pedido do Resolvedor no comentário anterior (item 9 do 🏛️ Parecer). O teto de 2 issues/dia já foi gasto hoje (#103/#104), então não abro issue nova: a #59 nunca foi fechada (a #105 entrou como Refs), então ela é a issue da fatia 2. Escopo abaixo, reverificado linha a linha contra origin/main (c40f0fc) — o Parecer é de 24/07 e uma premissa dele envelheceu.

    Causa (confirmada agora): a pista anima duas propriedades de layout por jogador:

    • src/components/RaceTrack.tsx:144-146 — initial={{ width: 0 }} / animate={{ width: "<pct>%" }}
    • src/components/RaceTrack.tsx:153-156 — animate={{ left: "calc(<pct>% - 8px)" }}
    • ambas spring (stiffness: 120, damping: 22), retargetadas a até 8,3 Hz por jogador (PROGRESS_THROTTLE_MS = 120, src/lib/useRoom.ts:27) — praticamente nunca em repouso durante a corrida.

    Premissa do Parecer que envelheceu: ele calculou "2 springs x ate 12 jogadores". A #69 mergeou e hoje ABSOLUTE_MAX_PLAYERS = 30 (src/lib/room.ts:282) -> o pior caso e 60 springs de layout+paint na mesma main thread da textarea. O argumento ficou 2,5x mais forte, nao mais fraco.

    Acceptance criteria (verificaveis):

    1. RaceTrack.tsx:144-156 nao anima mais width nem left. A barra usa scaleX com transform-origin: left; o marcador usa translateX. Nenhuma propriedade animada dispara layout.
    2. O visual em repouso e em movimento e indistinguivel do atual (mesma geometria da barra e do marcador em 0%, 50% e 100%), inclusive com a borda e o overflow-hidden do trilho em :129-133.
    3. Texto dentro do elemento escalado nao distorce (se houver, contra-escalar ou mover para fora do no com scaleX).
    4. Sem regressao de validate-metrics / validate-persistence / vitest, e sem tocar PROGRESS_THROTTLE_MS, broadcastProgress, sanitizeResults ou o caminho do finish.

    Fora desta fatia, de proposito:

    • Coalescing de setProgress — o Parecer (lente Seguranca) mostrou a armadilha: engolir a ultima mensagem de quem terminou trava a corrida (players.every(p => p.finishedAt) alimenta a finalizacao do lider), o mesmo sintoma que a Multiplayer/Liveness: corrida nunca termina se um jogador presente parar de digitar — sala presa em "racing" e nada persiste #58 acabou de consertar. So entra com flush garantido da msg final, e isso merece rodada propria.
    • reduced-motion na pista — gap pre-existente, nao regressao: RaceTrack.tsx nao usa useReducedMotion (so Results.tsx:32 e SpectatorView.tsx:21 usam) e a regra global de globals.css:50 zera apenas duracoes de CSS, nao springs em rAF. Vira P3 propria na minha proxima rodada (teto de hoje esgotado).

    Classificacao: §7.3 normal — so src/components/RaceTrack.tsx; nao toca api/rooms/** nem supabase.ts.

  6. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 30, 2026
  7. caioross commented on Jul 30, 2026

    @caioross
    OwnerAuthor

    Fatia 2 entregue em #113 (non-draft, §7.3 normal — só src/components/RaceTrack.tsx).

    Segui o escopo do 🗂️ Curador de 28/07 na íntegra: width/left saíram; a barra usa scaleX
    com origin-left e o marcador usa translateX (wrapper da largura do trilho, para que
    x: "<pct>%" equivalha ao antigo left). Nada de PROGRESS_THROTTLE_MS, broadcastProgress,
    sanitizeResults ou caminho do finish.

    Evidência: geometria da barra e do marcador idêntica em 0/25/50/75/100% (medida com
    getBoundingClientRect), e −49,8% de tempo de main thread num harness de 30 jogadores ×
    300 atualizações com flush forçado (122,4 ms → 61,4 ms, mediana de 3). Método sintético — o
    porquê (rAF congelado no headless) e a ressalva de fidelidade do halo/raio da ponta estão no PR.

    Gate: typecheck ✅ · build ✅ · vitest 158/158 ✅ · validate-metrics 45/45 ✅ ·
    validate-persistence 45/45 ✅ · lint N/A. Closes #59 — era a última fatia.

    📌 Para o Curador: TypingCore.tsx:252-256 anima width na barra pessoal, dentro do
    componente da textarea e retargetada a cada tecla. Mesmo defeito, lugar mais sensível,
    sem issue. Vale uma P2 própria.

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