Repository navigation
Description
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:
src/lib/useRoom.ts:178— broadcast de progresso alheio faz
setProgress(prev => ({ ...prev, [m.id]: m }))→ re-render douseRoom.useRoom.ts:278— ouseMemodeplayersdepende deprogress, então um array novo
sai a cada mensagem.src/components/RoomView.tsx:31-56—compatRoomdepende deplayers, então um objeto
RoomStatenovo (complayers.map(liveToPlayer),RoomView.tsx:35) sai junto.RoomView.tsx:114-121—<Race room={compatRoom} …>recebe prop nova ⇒Racere-renderiza
⇒CodeDisplay,CodeEditor,RaceTrackeFloatingChatre-renderizam todos.
Nenhum desses componentes éReact.memo(verificado nos quatro arquivos), eCodeDisplay
/CodeEditornão dependem deplayers— 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
- Digitar uma tecla não re-renderiza
RaceTracknemFloatingChat; progresso de
adversário não re-renderizaCodeDisplaynemCodeEditor. 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). - Evidência antes/depois no PR: nº de renders de
CodeDisplay/CodeEditornuma janela de
10s com 4 jogadores simulados broadcastando a 120ms. Meta: queda ≥90%. - Nenhuma regressão funcional: pista ao vivo, posições, chat flutuante, stats pessoais e
fim de corrida seguem idênticos;playerscontinua sendo a fonte única do roster. - Área sagrada preservada — sem novo timer de alta frequência; latência tecla→pixel intacta.
prefers-reduced-motioncontinua respeitado. pnpm typecheck && pnpm buildverdes +node scripts/validate-metrics.mjs.
Dica de abordagem
Cortar a propagação nos dois pontos baratos, sem reescrever nada:
React.memoemCodeDisplay,CodeEditor,RaceTrackeFloatingChat, garantindo que
as props que chegam sejam estáveis (as callbacks já vêm deuseCallbacknouseRoom).- Parar de passar o objeto
compatRoominteiro para os filhos que só precisam de fatias:
CodeDisplay/CodeEditorprecisam decode/language/typed— nada disso muda quando
um adversário progride. Passar as fatias (ou separar osnippetdo roster nouseMemode
RoomView.tsx:31) elimina a invalidação na raiz, e omemovira rede de segurança. - Opcional, se ainda sobrar custo: coalescer os
setProgressde rede num tick de ~100ms
(rAF ou timer único) em vez de umsetStatepor 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
caioross commentedon Jul 24, 2026
🏛️ 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.
useCallbacknouseRoom" é 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
abandonganha identidadenova 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 issonão for consertado. O mesmo vale para
sendChat,useCallback([presence])(useRoom.ts:404-419),que alimenta
FloatingChat. As estáveis sãobroadcastProgress/sendProgress(
:365-397, deps[]) esetReady/kick(:422-436).Correção barata e no estilo do arquivo (que já usa refs para isso): guardar a última msg própria
num
myLastMsgRefdentro debroadcastProgress(onde ela já é construída,:373-381) e fazerabandonlê-la do ref → deps[broadcastProgress]→ estável.memoemRaceTrackeFloatingChaté quase inócuo e não deve entrar como "vitória".playersmuda legitimamente a cada progresso (é o roster), eFloatingChatrecebeplayers={room.players}— array novo sempre. Memo neles só bloqueia o re-render vindo dorelógio, não o vindo de progresso.
TypingCore.tsx:58-62rodasetInterval(() => setNow(Date.now()), 200). São 5 re-renders/sde
CodeDisplay+CodeEditormesmo numa corrida solo, sem ninguém na sala. Isso muda odesenho:
memoemTypingCorecorta o tráfego de adversário, mas sómemoemCodeDisplaye
CodeEditorcorta o tick de 200ms. Os dois níveis são necessários — eCodeDisplayreconstrói um
<span>por caractere (CodeDisplay.tsx:61-91), que é o item caro da conta.useRoom.tsestá 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.
PROGRESS_THROTTLE_MS(useRoom.ts:20), nembroadcastProgress, nemsanitizeResults, nem o caminho dofinish— a issue já proíbe, e estácerta. Uma armadilha real: se optar por coalescer
setProgress(item opcional da issue), obuffer não pode engolir a última mensagem de um jogador que terminou —
playersalimenta oefeito de finalização do líder (
useRoom.ts:331-337,players.every(p => p.finishedAt)) e otoResultsque vai para o leaderboard. Um flush que descarta a msg final trava a corrida — omesmo 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.
um gap pré-existente (não é regressão desta issue, e não deve entrar neste PR): a AC 4 diz
"
prefers-reduced-motioncontinua respeitado", mas hoje ele não é respeitado na pista. A regraglobal de
globals.css:50-57zera sóanimation-duration/transition-durationde CSS; os springsde
framer-motionemRaceTrack.tsx:114-123e:125-129são rAF em JS e ignoram a mídia-query —não há
MotionConfig reducedMotion="user"em lugar nenhum desrc/. Vale uma issue separada parao Curador; aqui, a AC 4 deve ser lida como "não piorar".
memopropostos:RaceTrack.tsx:114-123animawidth: "<pct>%"e:125-129animaleft: "calc(<pct>% - 8px)"— duas propriedades de layout, por jogador. São 2 springs × Pjogadores (
max_playersvai 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 porscaleX(comtransform-origin: left) etranslateXtira tudo do layout e é composited. Isso é fatia 2, mas precisa estar escrito antes dealgué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.mjsjá constrói clientes realtime simulados compresence + 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, usecontador de render temporário +
<Profiler>e diga no PR que a medição foi local com broadcaststubado. A Infra/UI: harness só-de-dev para verificar telas pós-corrida (Results/pódio) sem jogar #37 (harness dev-only) está
em-resolucaohoje — se mergear antes, use-a.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):
origin/main. Fluxo §7.3 normal (componentes + hook; não tocaapi/rooms/**nemsupabase.ts).useRoom.ts:365-402— adicionarmyLastMsgRefpreenchido dentro debroadcastProgress(
:373-381) e reescreverabandonpara ler do ref, deixando deps[broadcastProgress].Verifique com o linter de hooks / à mão que
abandonesendProgressficaram estáveis antesde qualquer
memo— é o passo que faz todo o resto valer.React.memoemCodeDisplay(CodeDisplay.tsx:14) eCodeEditor(CodeEditor.tsx:18). Props deambos já são primitivas + callbacks;
CodeEditorsó depende doonAbandondo passo 2.React.memoemTypingCore(TypingCore.tsx:21). Props que chegam deRace.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 otráfego de adversário na raiz.
memoemRaceTrack/FloatingChatalegando ganho — se aplicar, diga no PR queé rede de segurança e que a medição não muda por causa deles.
RaceTrack" é insatisfazível porconstrução nesta fatia: cada tecla atualiza o meu próprio progresso otimista
(
useRoom.ts:383, fora do throttle de rede de:386-388) →playersmuda → a minha barra tem deandar. Re-render da pista a cada tecla é comportamento correto. Meça e reporte o que é de fato
o alvo: renders de
CodeDisplay/CodeEditorpor progresso alheio, meta de queda ≥90% (AC 2).pnpm typecheck && pnpm build,pnpm test,validate-metricsevalidate-persistence.width/left→scaleX/translateXemRaceTrack.tsx:114-129, + coalescing desetProgresscom flush garantido damsg final" — e uma issue P3 separada para o
MotionConfig reducedMotion="user".caioross commentedon Jul 28, 2026
Fatia 1 entregue em #105 (non-draft, §7.3 normal — não toca
api/rooms/**nemsupabase.ts).Segui o plano do 🏛️ Parecer na íntegra. A correção de premissa do item 1 (o
abandoninstável)era mesmo decisiva: com
React.memosozinho,CodeEditoreTypingCorecontinuavam re-renderizando400/400 sobre 200 mensagens de progresso alheio; com o
myLastMsgRefnoabandontambém, caempara 0.
CodeDisplayjá 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,
TypingCore24 renders ·CodeDisplay/CodeEditor0.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-metrics45/45 ✅ ·validate-persistence72/72 ✅ ·vitest109/109 ✅ · lint N/A (sem config ESLint no repo).Refs, nãoCloses: a segunda metade da AC 1 (RaceTrack/FloatingChatpor 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/translateXemRaceTrack.tsx:114-129) + coalescing desetProgresscom flush garantido da mensagem final — e uma P3 separada para
MotionConfig reducedMotion="user".perf(race): corta re-render de CodeDisplay/CodeEditor por progresso a…
caioross commentedon Jul 28, 2026
🗂️ 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 contraorigin/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)" }}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 datextarea. O argumento ficou 2,5x mais forte, nao mais fraco.Acceptance criteria (verificaveis):
RaceTrack.tsx:144-156nao anima maiswidthnemleft. A barra usascaleXcomtransform-origin: left; o marcador usatranslateX. Nenhuma propriedade animada dispara layout.overflow-hiddendo trilho em:129-133.scaleX).validate-metrics/validate-persistence/vitest, e sem tocarPROGRESS_THROTTLE_MS,broadcastProgress,sanitizeResultsou o caminho dofinish.Fora desta fatia, de proposito:
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-motionna pista — gap pre-existente, nao regressao:RaceTrack.tsxnao usauseReducedMotion(soResults.tsx:32eSpectatorView.tsx:21usam) e a regra global deglobals.css:50zera 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 tocaapi/rooms/**nemsupabase.ts.caioross commentedon Jul 30, 2026
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/leftsaíram; a barra usascaleXcom
origin-lefte o marcador usatranslateX(wrapper da largura do trilho, para quex: "<pct>%"equivalha ao antigoleft). Nada dePROGRESS_THROTTLE_MS,broadcastProgress,sanitizeResultsou caminho dofinish.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✅ ·vitest158/158 ✅ ·validate-metrics45/45 ✅ ·validate-persistence45/45 ✅ · lint N/A.Closes #59— era a última fatia.📌 Para o Curador:
TypingCore.tsx:252-256animawidthna barra pessoal, dentro docomponente da
textareae retargetada a cada tecla. Mesmo defeito, lugar mais sensível,sem issue. Vale uma P2 própria.
perf(racetrack): anima a pista com scaleX/translateX em vez de width/…