InícioDesafios › Desafio 06

Desafio 06 Nível: Avançado

Listar 5 mil registros sem travar o navegador

Todo componente de lista nasce bonito. Você testa com os trinta registros da org de desenvolvimento, fica instantâneo, sobe. Meses depois o objeto tem cinco mil registros, o usuário abre a tela e o navegador congela por segundos antes de responder. O problema não é o Salesforce: é a decisão de trazer tudo de uma vez e mandar o front renderizar cinco mil linhas. Este desafio é sobre paginar do jeito certo, no servidor, sem quebrar quando o volume cresce de verdade.

LWC Paginação server-side Apex cacheable Imperative Apex Tratamento de erro Governor limits

O cenário

Uma tela lista as oportunidades da org num componente Lightning. A primeira versão foi resolvida do jeito mais direto: um método Apex que devolve tudo e um @wire no componente que pega esse tudo e manda renderizar.

// A versao que trava: uma consulta que traz o objeto inteiro
@AuraEnabled(cacheable=true)
public static List<Opportunity> getTudo() {
    return [SELECT Id, Name, Amount, StageName FROM Opportunity ORDER BY Name];
}
// O componente pega a lista inteira e joga no template
@wire(getTudo)
oportunidades;

Funcionou. Na org de desenvolvimento, com trinta registros, a tela abre na hora. Quando a base cresceu para cinco mil oportunidades, cada abertura passou a trazer os cinco mil registros de uma vez e a pedir ao navegador que montasse cinco mil linhas no DOM. O resultado é a tela que congela: o usuário clica e nada responde por três, quatro segundos.

A saída não é reclamar do volume. É parar de trazer o que não cabe na tela. O usuário vê vinte, trinta linhas por vez. Não há motivo para buscar as outras 4.950 antes que ele peça.

O que você deve construir

Duas peças que conversam: um controlador Apex que devolve uma página por vez e um LWC que carrega a próxima página só quando o usuário pede.

O método Apex tem esta assinatura exata:

@AuraEnabled(cacheable=true)
public static List<Opportunity> buscarPagina(Id ultimoId, Integer tamanho)

Ele deve:

  • Devolver no máximo tamanho oportunidades, ordenadas de forma estável.
  • Quando ultimoId vier nulo, devolver a primeira página. Quando vier preenchido, devolver a página seguinte ao registro daquele Id.
  • Ser cacheable=true: é leitura pura, então cada página pode ser cacheada pela plataforma.

O componente listaOportunidades deve:

  • Carregar a primeira página ao abrir.
  • Ter um botão Carregar mais que traz a próxima página e anexa à lista já exibida.
  • Esconder ou desabilitar o botão quando não houver mais o que carregar.
  • Mostrar uma mensagem quando o Apex falhar, em vez de ficar em branco.

Regras de aceite

São elas que definem se o desafio foi resolvido. Duas delas são casos de borda, e é onde a maioria das soluções escorrega:

  • Cada carga pede no máximo tamanho registros ao servidor. A consulta usa LIMIT :tamanho. O componente nunca busca os cinco mil de uma vez, nem na primeira carga nem depois.
  • A paginação é decidida no servidor. O cliente não recebe a lista completa para recortar no JavaScript. Quem escolhe qual fatia volta é a consulta Apex, a partir do cursor e do tamanho.
  • Os registros novos são anexados sem repetir nem pular nenhum. A ordenação é estável e o cursor avança pelo último Id trazido, de modo que a página seguinte começa exatamente onde a anterior parou.
  • Quando o servidor devolve menos que tamanho registros, o botão some ou desabilita e nenhuma chamada extra é feita. Com zero registros, o componente mostra um estado vazio, sem erro.
  • Se a chamada ao Apex falhar, o componente exibe uma mensagem legível. Ele não fica em branco, não trava e não deixa uma promise rejeitada sem tratamento no console.

O que este desafio avalia

  • Se você entende que performance de tela grande se resolve no servidor, não no navegador.
  • Se você sabe paginar sem depender de OFFSET, que tem teto e degrada com o volume.
  • Se você controla o estado do componente entre uma carga e a próxima sem repetir nem perder registro.
  • Se você trata o caminho de erro e o de lista vazia, e não só o caminho feliz.
Travei. Me dá uma direção (sem entregar a resposta)

Quatro perguntas que destravam o desafio:

  • A primeira ideia de paginação costuma ser LIMIT mais OFFSET. Antes de seguir por aí, descubra até onde o OFFSET vai numa consulta SOQL. Pesquise por "SOQL OFFSET 2000 limit" e veja o que acontece com cinco mil registros.
  • Existe uma forma de paginar que não usa OFFSET: em vez de pular N registros, você pede os que vêm depois do último que já mostrou. Pesquise por "keyset pagination" e "SOQL WHERE Id maior que". Pense em qual campo dá uma ordem estável e única para servir de cursor.
  • Para chamar o Apex quando o usuário clica, e não automaticamente, você precisa da forma imperativa, não do @wire. Pesquise por "call apex method imperatively lwc".
  • Como o componente sabe que chegou ao fim? Compare quantos registros o servidor devolveu com o tamanho que você pediu. Se veio menos, não há próxima página.

Sobre o erro: toda chamada imperativa devolve uma promise. Procure "lwc apex error body message" para saber onde a mensagem do Apex chega no catch.

Como testar

O coração do desafio é o controlador, e ele dá para provar por Execute Anonymous, sem subir a tela. Primeiro, semeie volume na sua org de desenvolvimento com uma única DML:

// Cria 5000 oportunidades para simular volume. Uma unica DML.
List<Opportunity> lote = new List<Opportunity>();
for (Integer i = 0; i < 5000; i++) {
    lote.add(new Opportunity(
        Name = 'Volume ' + i,
        StageName = 'Prospecting',
        CloseDate = Date.today().addDays(30)
    ));
}
insert lote;
System.debug('Criadas: ' + lote.size());

Agora prove que a paginação pega no máximo tamanho por vez e que a segunda página não repete a primeira:

Integer tamanho = 50;

List<Opportunity> pagina1 = OportunidadeController.buscarPagina(null, tamanho);
System.assert(pagina1.size() <= tamanho, 'Nenhuma pagina pode passar do tamanho pedido');
System.assertEquals(tamanho, pagina1.size(), 'Com 5000 registros a primeira pagina vem cheia');

Id cursor = pagina1[pagina1.size() - 1].Id;
List<Opportunity> pagina2 = OportunidadeController.buscarPagina(cursor, tamanho);

// Nenhum Id da pagina 1 pode reaparecer na pagina 2
Set<Id> daPagina1 = new Set<Id>();
for (Opportunity o : pagina1) {
    daPagina1.add(o.Id);
}
Integer repetidos = 0;
for (Opportunity o : pagina2) {
    if (daPagina1.contains(o.Id)) {
        repetidos++;
    }
}
System.assertEquals(0, repetidos, 'A pagina seguinte nao pode repetir registro da anterior');

System.debug('Pagina 1: ' + pagina1.size() + ' | Pagina 2: ' + pagina2.size());
System.debug('Registros repetidos entre paginas: ' + repetidos);
System.debug('Consultas na transacao: ' + Limits.getQueries());

Se a primeira página voltar com 50, a segunda não repetir nenhum Id e o número de consultas ficar baixo, o servidor está paginando certo. A borda da tela (botão que desabilita no fim, mensagem de erro) você confere subindo o componente numa página Lightning e rolando até o último registro.

Gabarito comentado

A solucao completa, controlador e componente, destrinchada parte por parte: por que cursor e nao OFFSET, como o estado se mantem entre cargas e como tratar erro e fim de lista. Tente resolver antes de abrir.

Ver o gabarito

Resolveu? O ganho aqui é o reflexo de perguntar "e quando isso tiver dez mil registros" antes de escrever a primeira consulta de tela. Trazer só o que cabe na visão do usuário é o que separa a interface que escala da que trava no primeiro cliente grande.