Skip to content

perf(race): corta re-render de CodeDisplay/CodeEditor por progresso alheio (#59, fatia 1) - #105

Merged
caioross merged 1 commit into
mainfrom
auto/issue-59-render-cut
Jul 28, 2026
Merged

perf(race): corta re-render de CodeDisplay/CodeEditor por progresso alheio (#59, fatia 1)#105
caioross merged 1 commit into
mainfrom
auto/issue-59-render-cut

Conversation

@caioross

Copy link
Copy Markdown
Owner

Contexto

Durante a corrida, cada mensagem de progresso de qualquer adversário invalidava a árvore
inteira da sala até a textarea:
useRoom.setProgressuseMemo(players)RoomView.compatRoom<Race>TypingCore
CodeDisplay + CodeEditor. Com 6 jogadores a 120ms são ~50 re-renders/s competindo com o
input — o custo de digitar crescia com o número de gente na sala (área sagrada, HANDBOOK §2).

Esta é a fatia 1 de 2 do plano do 🏛️ Parecer do Conselho na #59 — segui o plano na íntegra,
inclusive a correção de premissa do item 1 (ver abaixo), que a medição confirmou ser decisiva.

O que mudou e por quê

  1. src/lib/useRoom.tsabandon virou estável. Ele dependia de progress, o estado que
    muda a cada mensagem de adversário: ganhava identidade nova a cada broadcast e descia até
    CodeEditor (onAbandon), invalidando qualquer memo rio abaixo. Agora a última mensagem
    própria vive num myLastMsgRef, preenchido onde ela já é construída (dentro de
    broadcastProgress), e abandon lê do ref → deps [broadcastProgress].
    O ref é zerado nos mesmos dois pontos em que o efeito de reset faz setProgress({}), para
    não carregar valor velho entre rodadas — a semântica anterior (progress[meId]) é preservada
    exatamente, porque aquele mapa só era escrito com essa mesma mensagem.
  2. React.memo em TypingCore — corta o tráfego de adversário na raiz. <Race> continua
    re-renderizando a cada mensagem (a pista TEM de andar), mas as props do núcleo
    (code/language/startedAt/finishedAt/onProgress/onAbandon) são estáveis na corrida.
  3. React.memo em CodeDisplay e CodeEditor — cortam também o segundo emissor que a
    issue não mapeava: o setInterval(200ms) do relógio do próprio TypingCore, que re-renderizava
    os dois 5×/s até numa corrida solo. CodeDisplay reconstrói um <span> por caractere; é o
    item caro da conta.

RaceTrack e FloatingChat ficaram de fora de propósito (o Conselho pediu que não fossem
vendidos como vitória): players muda legitimamente a cada progresso, então memo ali não
mediria nada. É fatia 2.

Medição (AC 2)

Harness de dev temporário (removido antes do commit, §11 da cr-fleet-ops) que reproduz a
cadeia de identidade da produção — cópia literal do useMemo de players e do compatRoom
com 4 adversários e contadores de render por componente. Como o Browser pane headless estrangula
setInterval para ~1 Hz em aba de fundo, a métrica é determinística por mensagem (flushSync,
1 commit por mensagem) em vez de por janela de tempo — imune ao throttling. Números brutos
(StrictMode do dev conta 2 renders por render lógico; o fator é o mesmo nos três cenários):

200 mensagens de progresso alheio:

Cenário CodeDisplay CodeEditor TypingCore RaceTrack FloatingChat
A — origin/main 400 400 400 400 400
B — só memo (com abandon instável) 0 400 400 400 400
C — este PR (memo + abandon estável) 0 0 0 400 400

O cenário B é a confirmação empírica do achado do Conselho: memo sozinho não morde
CodeEditor/TypingCore
enquanto abandon for instável. Repetido com N=200 e N=400 em regime
permanente: CodeDisplay/CodeEditor/TypingCore = 0 nos dois. Queda de 100% (meta era ≥90%).

Janela ociosa de 12s, sem tráfego de adversário (só o tick de 200ms + heartbeat):
TypingCore 24 · CodeDisplay 0 · CodeEditor 0 (antes acompanhavam o TypingCore 1:1).

Validação (gate §6, resultado real)

  • pnpm install --frozen-lockfile ✅ · pnpm typecheck ✅ · pnpm build ✅ (rota /harness/perf59
    ausente do output — o harness temporário saiu mesmo)
  • node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs45/45
  • node scripts/validate-persistence.mjs72/72
  • pnpm test (vitest) → 109/109
  • pnpm lint = N/A (não há config ESLint no repo; a CI também não roda lint)

Riscos

  • O único risco real é a troca de fonte do abandon (state → ref). Se algum caminho novo
    escrevesse progress[meId] sem passar por broadcastProgress, o ref divergiria; hoje não
    existe esse caminho (o único write do meu id é a linha otimista do próprio broadcastProgress).
    O reset entre rodadas foi coberto explicitamente.
  • Nada de anti-cheat mudou: PROGRESS_THROTTLE_MS, broadcastProgress, sanitizeResults e o
    caminho do finish estão intactos. O coalescing de setProgress (item opcional da issue) foi
    deliberadamente deixado fora — o Conselho apontou que um flush que engolisse a última
    mensagem de quem terminou prenderia a sala em racing.
  • memo em TypingCore também vale para o modo Practice (props idem estáveis).

Não verificado (honestidade, §11)

Corrida real com 2+ clientes e Realtime ao vivo é inalcançável neste ambiente: a medição é
local com broadcast stubado, sobre uma réplica literal da cadeia de identidade da produção,
não sobre o useRoom conectado. Confirmação visual em produção continua sendo do dono.

AC não atendidos (por isso Refs, não Closes)

  • AC 1, segunda metade — "digitar uma tecla não re-renderiza RaceTrack nem FloatingChat":
    insatisfazível nesta fatia e, na verdade, incorreto como alvo — cada tecla atualiza o meu
    progresso otimista (useRoom.ts:508, fora do throttle de rede), então a minha barra tem de
    andar. Re-render da pista por tecla é comportamento correto.
  • Fica para a fatia 2 (a abrir pelo Curador, conforme o item 9 do parecer): pista fora do
    layout (width/leftscaleX/translateX em RaceTrack.tsx:114-129) + coalescing de
    setProgress com flush garantido da mensagem final. E uma P3 separada para
    MotionConfig reducedMotion="user" (gap pré-existente que o parecer registrou; não entrou aqui).

Refs #59

…lheio (#59)

Durante a corrida, cada mensagem de progresso de qualquer adversario
invalidava a arvore inteira da sala ate a textarea: setProgress ->
useMemo(players) -> compatRoom -> <Race> -> TypingCore -> CodeDisplay +
CodeEditor. Com 6 jogadores a 120ms sao ~50 re-renders/s competindo com
o input do jogador — o custo de digitar crescia com o numero de gente na
sala (area sagrada, HANDBOOK §2).

Fatia 1 de 2 (plano do Conselho): cortar a propagacao, sem tocar em
PROGRESS_THROTTLE_MS, broadcastProgress, sanitizeResults ou no caminho
do finish.

1. useRoom: `abandon` dependia de `progress`, ou seja ganhava identidade
   nova a cada mensagem de adversario e invalidava qualquer memo rio
   abaixo. Agora a ultima mensagem propria vive em `myLastMsgRef`
   (preenchido onde ela ja e construida, dentro de broadcastProgress) e
   `abandon` le do ref — deps `[broadcastProgress]`, identidade estavel.
   O ref e zerado nos mesmos pontos em que `setProgress({})` zera o
   estado, para nao carregar valor velho entre rodadas.
2. React.memo em TypingCore (corta o trafego de adversario na raiz),
   CodeDisplay e CodeEditor (cortam tambem o tick de 200ms do relogio,
   que re-renderizava os dois 5x/s ate numa corrida solo).

RaceTrack e FloatingChat continuam sem memo de proposito: `players` muda
legitimamente a cada progresso e a barra TEM de andar — memo ali nao
mediria nada. Fica para a fatia 2 (pista fora do layout).

Refs #59
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Ready Ready Preview, Comment Jul 28, 2026 4:07am

@caioross
caioross merged commit c40f0fc into main Jul 28, 2026
3 checks passed
@caioross
caioross deleted the auto/issue-59-render-cut branch July 28, 2026 04:17
@caioross

Copy link
Copy Markdown
Owner Author

🩺 Quórum adversarial (HANDBOOK §7.2) — 3 APROVA / 0 VETO ✅ mergeada

Classifiquei como quórum por julgamento, não por lista: o diff é pequeno (+33/−8) e fora das
áreas literais da §7.2, mas troca a fonte de verdade do abandon (state → ref) dentro da
área sagrada — a classe de bug que já prendeu sala em racing neste repo. Pré-requisitos OK:
CI verde, MERGEABLE/CLEAN, diff lido inteiro no head 12923d7. As 3 lentes leram estado
commitado
(origin/auto/issue-59-render-cut vs origin/main), nunca working tree.

  • AppSec — APROVA. Nenhuma fronteira de confiança mudou: broadcastProgress, throttle e
    payload de finish intactos; o write do ref (useRoom.ts:514) fica antes do return do
    anti-flood (:519), igual ao setProgress. Abandono nunca alimenta o leaderboard
    (toResults filtra !p.abandoned, :407). Sem memo congelando sinal (bail-out exige todas
    as props shallow-equal).
  • Ofensiva — APROVA. Sala presa é inalcançável: quem decide o fim é shouldFinishRace
    (room.ts:152-165) via finishedAt/lastActivityAt, que vêm de myFinishRef (:501-502),
    não do ref novo — e a mensagem de abandono fura o throttle (done em :519). Nenhuma
    janela de ref velho, nenhum ganho de inflação de resultado.
  • Domínio — APROVA. Equivalência exata e ganho real no app conectado (não só na
    réplica): broadcastProgress tem deps [] (:523) → abandon estável → as props que
    Race.tsx:43-51 passa ao TypingCore sobrevivem ao Object.is a cada mensagem alheia, então
    o memo de fato faz bail-out. Na main, abandon dependia de progress e teria anulado o
    memo — a troca é o que faz a fatia funcionar.

Conferi eu mesmo os dois pontos que sustentam tudo, em vez de aceitar o voto: (a) só existem
4 writes de setProgress:225 (guardado por m.id === id em :223, adversário nunca
escreve minha chave), :325 e :338 (os dois resets, pareados com myLastMsgRef = null em
:319/:332) e :516 (o meu, pareado com :514); (b) deps [] em broadcastProgress.
Nenhum reset ficou descoberto.

Refs #59 está correto — a fatia 2 (pista fora do layout + coalescing) segue aberta, e o AC 1
foi honestamente marcado como não atendido/incorreto como alvo.

Ressalvas registradas (não bloqueantes, para a fatia 2):

  1. TypingCore.tsx:66-71 afirma que onAbandon "troca de identidade a cada progress/heartbeat" —
    comentário agora falso, exatamente a premissa que esta PR inverteu. Risco real de um agente
    futuro "corrigir" a otimização para casar com o comentário. Atualize junto com a fatia 2.
  2. Todo o ganho pende de broadcastProgress manter deps []. Uma dep nova ali desmemoiza
    TypingCore+CodeEditor em silêncio — nenhum dos 109 testes cobre estabilidade de
    identidade desses callbacks.
  3. O "100%" vale para render por progresso alheio e pelo tick de 200 ms; digitar continua
    re-renderizando (correto — typed mudou). O ganho na latência é o fim do render duplicado.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant