Otimizando Performance de Diff Lines: A Escalada para PRs Mais Rápidos
A Jornada para Tornar as Linhas de Diff Performáticas
Na AITY, entendemos que Pull Requests (PRs) são o coração do desenvolvimento de software. Como engenheiros, dedicamos uma parte significativa do nosso tempo a eles. No entanto, em sistemas de larga escala — onde os PRs podem variar de pequenas correções a mudanças que abrangem milhares de arquivos e milhões de linhas — a experiência de revisão precisa ser rápida e responsiva.
Recentemente, lançamos uma nova experiência baseada em React para a aba de "Arquivos Alterados" (Files changed), agora padrão para todos os usuários. Um dos nossos principais objetivos era garantir uma performance aprimorada em todos os cenários, especialmente para Pull Requests de grande porte. Isso exigiu um investimento contínuo e a priorização de problemas complexos, como renderização otimizada, latência de interação e consumo de memória.
Para a maioria dos usuários antes da otimização, a experiência já era rápida. Contudo, ao visualizar PRs grandes, a performance declinava notavelmente. Observamos, por exemplo, que em casos extremos, o heap do JavaScript podia exceder 1 GB, a contagem de nós DOM superava 400.000, e as interações na página tornavam-se extremamente lentas ou até inutilizáveis. As pontuações de Interaction to Next Paint (INP), uma métrica chave para determinar a responsividade, estavam acima dos níveis aceitáveis, resultando em uma experiência onde os usuários podiam sentir quantificavelmente o atraso na entrada.
Nossas melhorias recentes na aba "Arquivos Alterados" aprimoraram significativamente algumas dessas métricas de performance essenciais. Embora tenhamos abordado brevemente algumas dessas mudanças em um changelog recente, vamos detalhá-las aqui.
Estratégias de Otimização: Performance por Tamanho e Complexidade de Pull Request
À medida que começamos a investigar e planejar nossos próximos passos para melhorar esses problemas de performance, ficou claro desde o início que não haveria uma solução mágica. Técnicas que preservam todos os recursos e comportamentos nativos do navegador ainda podem atingir um limite no extremo. Enquanto isso, mitigações projetadas para evitar que o pior caso desmorone podem ser a troca errada para revisões do dia a dia.
Em vez de buscar uma única solução, nossa equipe começou a desenvolver um conjunto de estratégias. Selecionamos múltiplas abordagens direcionadas, cada uma projetada para lidar com um tamanho e complexidade específicos de Pull Request.
Essas estratégias focaram nos seguintes temas:
- Otimizações focadas para componentes de linha de diff. Tornar a experiência primária de diff eficiente para a maioria dos Pull Requests. Revisões de médio e grande porte permanecem rápidas sem sacrificar o comportamento esperado, como a funcionalidade nativa de "localizar na página".
- Degradação graciosa com virtualização. Manter a experiência utilizável para os maiores Pull Requests. Priorizar a responsividade e estabilidade limitando o que é renderizado a cada momento.
- Investimento em componentes fundamentais e melhorias de renderização. Estes se acumulam em todos os tamanhos de Pull Request, independentemente do modo em que o usuário esteja.
Com essas estratégias em mente, vamos explorar os passos específicos que tomamos para abordar esses desafios e como nossas iterações iniciais prepararam o terreno para as melhorias que se seguiram.
Primeiros Passos: Otimizando Linhas de Diff
Com o objetivo de nossa equipe de melhorar a performance de Pull Requests, tínhamos três objetivos principais:
- Reduzir o tamanho da memória e do heap do JavaScript.
- Reduzir a contagem de nós DOM.
- Reduzir nossa média de INP e melhorar significativamente nossas medições p95 e p99.
Para atingir esses objetivos, focamos na simplificação: menos estado, menos elementos, menos JavaScript e menos componentes React.
V1: Desafios e Limitações
Na versão 1 (v1), cada linha de diff era custosa para renderizar. Na visualização unificada, uma única linha exigia aproximadamente 10 elementos DOM; na visualização dividida, perto de 15. Isso antes do syntax highlighting, que adiciona muitas mais tags <span> e eleva ainda mais a contagem de DOM.
Na camada React, os diffs unificados geralmente continham pelo menos oito componentes por linha, enquanto a visualização dividida continha um mínimo de 13. E esses números representam contagens de base; estados de UI extras, como comentários, hover e focus, podiam adicionar mais componentes.
Essa abordagem fez sentido para nós na v1, quando portamos as linhas de diff para React de nossa visão clássica em Rails. Nosso plano original centrava-se em muitos componentes React pequenos e reutilizáveis e na manutenção da estrutura da árvore DOM.
Mas também acabamos anexando muitos manipuladores de eventos React em nossos pequenos componentes, frequentemente cinco a seis por componente. Em pequena escala, isso era aceitável, mas em grande escala, o impacto se acumulava rapidamente. Uma única linha de diff podia carregar mais de 20 manipuladores de eventos multiplicados por milhares de linhas.
Além do impacto na performance, também aumentou a complexidade para os desenvolvedores. Este é um cenário familiar onde você implementa um design inicial, apenas para descobrir mais tarde suas limitações quando confrontado com as demandas de dados ilimitados.
Para resumir, para cada linha de diff na v1, haveria:
- Mínimo de 10-15 elementos da árvore DOM
- Mínimo de 8-13 componentes React
- Mínimo de 20 manipuladores de eventos React
- Muitos componentes React pequenos e reutilizáveis
Essa estratégia da v1 mostrou-se insustentável para nossos maiores Pull Requests, pois observamos consistentemente que PRs de tamanho maior levavam diretamente a um INP mais lento e ao aumento do uso do heap do JavaScript. Precisávamos determinar o melhor caminho para melhorar essa configuração.
V2: Pequenas Mudanças, Grande Impacto
Nenhuma mudança é pequena demais quando se trata de performance, especialmente em escala. Por exemplo, removemos tags <code> desnecessárias de nossas células de número de linha. Embora a remoção de dois nós DOM por linha de diff possa parecer insignificante, em 10.000 linhas, isso representa 20.000 nós a menos no DOM. Esses tipos de otimizações incrementais e direcionadas, por menores que sejam, se acumulam para criar uma experiência muito mais rápida e eficiente. Ao não ignorar esses detalhes, garantimos que todas as oportunidades de melhoria fossem capturadas, amplificando o impacto geral em nossos maiores Pull Requests.
Passamos de oito componentes por linha de diff para dois. A maioria dos componentes da v1 eram wrappers finos que nos permitiam compartilhar código entre as visualizações Dividida e Unificada. Mas essa abstração tinha um custo: cada wrapper carregava lógica para ambas as visualizações, mesmo que apenas uma fosse renderizada por vez. Na v2, demos a cada visualização seu próprio componente dedicado. Parte do código é duplicada, mas o resultado é mais simples e rápido.
Simplificando a Árvore de Componentes
Para a v2, removemos árvores de componentes profundamente aninhadas, optando por componentes dedicados para cada linha de diff dividida e unificada. Embora isso tenha levado a alguma duplicação de código, simplificou o acesso aos dados e reduziu a complexidade.
O tratamento de eventos agora é gerenciado por um único manipulador de nível superior usando valores de data-attribute. Assim, por exemplo, quando você clica e arrasta para selecionar várias linhas de diff, o manipulador verifica o data-attribute de cada evento para determinar quais linhas destacar, em vez de cada linha ter sua própria função de mouse enter. Essa abordagem otimiza tanto o código quanto a performance.
Movendo o Estado Complexo para Componentes Filhos Condicionalmente Renderizados
A mudança mais impactante da v1 para a v2 foi mover o estado da aplicação para comentários e menus de contexto para seus respectivos componentes. Dada a escala, onde alguns Pull Requests excedem milhares de linhas de código, não é prático que cada linha carregue um estado de comentário complexo quando apenas um pequeno subconjunto de linhas terá comentários ou menus abertos. Ao mover o estado de comentário para os componentes aninhados de cada linha de diff, garantimos que a principal responsabilidade do componente de linha de diff seja apenas renderizar o código — alinhando-se mais de perto com o Princípio da Responsabilidade Única.
Acesso a Dados O(1) e Menos Hooks useEffect
Na v1, acumulamos gradualmente muitos lookups O(n) em armazenamentos de dados compartilhados e estado de componentes. Também introduzimos re-renderizações extras através de hooks useEffect espalhados pela árvore de componentes de linha de diff.
Para abordar isso na v2, adotamos uma estratégia de duas partes. Primeiro, restringimos estritamente o uso de useEffect ao nível superior dos arquivos de diff. Também estabelecemos regras de linting para evitar a introdução de hooks useEffect em componentes React que envolvem linhas. Essa abordagem permite uma memoização precisa dos componentes de linha de diff e garante um comportamento confiável e previsível.
Em seguida, redesenhamos nossas máquinas de estado global e de diff para utilizar lookups de tempo constante O(1) empregando JavaScript Map. Isso nos permitiu construir seletores rápidos e consistentes para operações comuns em nossa base de código, como seleção de linha e gerenciamento de comentários. Essas mudanças aprimoraram a qualidade do código, melhoraram a performance e reduziram a complexidade, mantendo estruturas de dados mapeadas e flattened.
Agora, qualquer linha de diff simplesmente verifica um mapa passando o caminho do arquivo e o número da linha para determinar se há ou não comentários nessa linha. Um acesso pode se parecer com: commentsMap['path/to/file.tsx']['L8']
// Exemplo conceitual de acesso a dados O(1) com JavaScript Map em v2
const commentsMap = new Map();
// Para 'path/to/file.tsx':
// commentsMap.set('path/to/file.tsx', new Map());
// commentsMap.get('path/to/file.tsx').set('L8', { /* dados do comentário */ });
// Exemplo de como a lógica interna de um componente de linha de diff acessaria:
function verificarComentarioNaLinha(filePath, lineNumber) {
const fileSpecificMap = commentsMap.get(filePath);
if (fileSpecificMap && fileSpecificMap.has(`L${lineNumber}`)) {
console.log(`Comentários encontrados na linha ${lineNumber} do arquivo ${filePath}`);
// Retornar os dados do comentário ou true
return true;
}
return false;
}
// Uso simulado em um componente de linha:
// const temComentario = verificarComentarioNaLinha('path/to/file.tsx', 8);
Os Resultados Falam por Si
Funcionou? Definitivamente. A página roda mais rápido do que nunca, e os números de heap do JavaScript e INP foram maciçamente reduzidos. Para uma visão numérica, confira os resultados abaixo. Essas métricas foram avaliadas em um Pull Request usando uma configuração de diff dividido com 10.000 linhas de alterações na comparação do diff.
| Métrica | v1 | v2 | Melhoria | |---|---|---|---| | Total de linhas de código | 2.800 | 2.000 | 27% menos | | Total de tipos de componentes únicos | 19 | 10 | 47% a menos | | Total de componentes renderizados | ~183.504 | ~50.004 | 74% a menos | | Total de nós DOM | ~200.000 | ~180.000 | 10% a menos | | Uso total de memória | ~150-250 MB | ~80-120 MB | ~50% menos | | INP em um PR grande usando MacBook Pro m1 com 4x slowdown: | ~450 ms | ~100 ms | ~78% mais rápido |
Como pode ser visto, este esforço teve um impacto enorme, mas as melhorias não pararam por aí.
Virtualização para os Maiores Pull Requests
Quando estamos trabalhando com Pull Requests massivos — p95+ (aqueles com mais de 10.000 linhas de diff e linhas de contexto circundantes) —, os truques de performance usuais simplesmente não são suficientes. Mesmo os componentes mais eficientes terão dificuldades se tentarmos renderizar dezenas de milhares deles de uma vez. É aí que a virtualização de janela (window virtualization) entra em cena.
No desenvolvimento front-end, a virtualização de janela é uma técnica que mantém apenas a porção visível de uma grande lista ou conjunto de dados no DOM a qualquer momento. Em vez de carregar tudo (o que sobrecarregaria a memória e tornaria tudo extremamente lento), ela renderiza dinamicamente apenas o que você vê na tela, e troca novos elementos conforme você rola. Essa abordagem é como ter uma "janela" móvel sobre seus dados, para que seu navegador não seja sobrecarregado por conteúdo fora da tela.
Para tornar isso possível, integramos o TanStack Virtual em nossa visualização de diff, garantindo que apenas a porção visível da lista de diff esteja presente no DOM a qualquer momento. O impacto foi enorme: vimos uma redução de 10X no uso do heap JavaScript e nos nós DOM para Pull Requests p95+. O INP caiu de 275–700+ milissegundos (ms) para apenas 40–80 ms para esses grandes Pull Requests. Ao mostrar apenas o que é necessário, a experiência é muito mais rápida.
Otimizações de Performance Adicionais
Para impulsionar ainda mais a performance, abordamos várias áreas importantes em nossa stack, cada uma entregando ganhos significativos em velocidade e responsividade. Ao focar em cortar re-renders desnecessários do React e aprimorar nosso gerenciamento de estado, reduzimos o desperdício de computação, tornando as atualizações da UI visivelmente mais rápidas e as interações mais suaves.
Na frente de estilo, trocamos seletores CSS pesados (por exemplo, :has(...)) e redesenhamos o tratamento de arrastar e redimensionar com transformações de GPU, eliminando layouts forçados e lentidão, e proporcionando aos usuários uma interface nítida e eficiente para ações complexas.
Também intensificamos nosso monitoramento com rastreamento de INP em nível de interação, segmentação por tamanho de diff e marcação de memória, tudo isso exibido em um painel do Datadog. Isso continua a fornecer aos nossos desenvolvedores métricas acionáveis e em tempo real para identificar e resolver gargalos antes que se tornem problemas.
No lado do servidor, otimizamos a renderização para hidratar apenas as linhas de diff visíveis. Isso reduziu nosso tempo de interação e mantém o uso da memória sob controle, garantindo que mesmo Pull Requests enormes pareçam rápidos e responsivos no carregamento.
Finalmente, com carregamento progressivo de diff e buscas inteligentes em segundo plano, os usuários agora podem ver e interagir com o conteúdo mais cedo. Chega de esperar que um número massivo de diffs termine de carregar.
No conjunto, essas otimizações direcionadas fizeram com que nossa UI se tornasse mais leve, rápida e pronta para qualquer desafio dos nossos usuários.
O Poder da Performance Otimizada
Essa empolgante jornada para otimizar a arquitetura das linhas de diff gerou melhorias substanciais em performance, eficiência e manutenibilidade. Ao reduzir nós DOM desnecessários, simplificar nossa árvore de componentes React e realocar o estado complexo para componentes filhos renderizados condicionalmente, alcançamos tempos de renderização mais rápidos e menor consumo de memória. A adoção de padrões de acesso a dados mais próximos de O(1) e regras mais rigorosas para o gerenciamento de estado otimizaram ainda mais a performance. Isso tornou nossa UI mais responsiva (INP mais rápido!) e mais fácil de compreender.
Esses ganhos mensuráveis demonstram que refatorações direcionadas, mesmo dentro de nossa grande e madura base de código, podem entregar benefícios significativos a todos os usuários – e que, às vezes, focar em melhorias pequenas e simples pode ter o maior impacto. Para ver os ganhos de performance em ação, convidamos você a verificar seus próprios Pull Requests abertos.
Aguardando Login...