InícioDesafiosDesafio 06 › Gabarito

Gabarito · Desafio 06 Nível: Avançado

Gabarito: paginar 5 mil registros num LWC

Esta página destrincha a solução do sexto desafio. Se você ainda não tentou, vale voltar e passar pelo menos meia hora resolvendo: paginar parece simples até você bater no teto do OFFSET e perceber que a primeira ideia não escala. Se já tentou, vamos comparar sua abordagem com esta.

Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio

O problema em uma frase

A tela trazia o objeto inteiro e mandava o navegador montar cinco mil linhas de uma vez. A correção não é otimizar o front: é não trazer o que não cabe na tela. O usuário enxerga algumas dezenas de linhas por vez, então o servidor devolve algumas dezenas por vez, e só busca mais quando ele pede.

A parte que separa a solução ingênua da que escala é o como paginar. A primeira ideia é LIMIT mais OFFSET, e ela funciona nas primeiras páginas. Só que o SOQL trava o OFFSET em 2000: passar disso devolve System.QueryException: maximum SOQL offset allowed is 2000. Com cinco mil registros e páginas de 50, você quebra na página 41. A saída é paginar por cursor: em vez de pular N registros, pedir os que vêm depois do último Id já mostrado.

A solução completa

O controlador Apex, que devolve uma página por vez a partir de um cursor:

public with sharing class OportunidadeController {

    // Tamanho de seguranca caso o cliente mande algo invalido.
    private static final Integer TAMANHO_PADRAO = 50;

    @AuraEnabled(cacheable=true)
    public static List<Opportunity> buscarPagina(Id ultimoId, Integer tamanho) {
        Integer limite = (tamanho == null || tamanho <= 0) ? TAMANHO_PADRAO : tamanho;

        // Primeira pagina: nao ha cursor ainda.
        if (ultimoId == null) {
            return [
                SELECT Id, Name, Amount, StageName
                FROM Opportunity
                ORDER BY Id ASC
                LIMIT :limite
            ];
        }

        // Paginas seguintes: so o que vem depois do ultimo Id ja mostrado.
        return [
            SELECT Id, Name, Amount, StageName
            FROM Opportunity
            WHERE Id > :ultimoId
            ORDER BY Id ASC
            LIMIT :limite
        ];
    }
}

O componente, com o estado da paginação e o tratamento de erro:

import { LightningElement } from 'lwc';
import buscarPagina from '@salesforce/apex/OportunidadeController.buscarPagina';

const TAMANHO = 50;

export default class ListaOportunidades extends LightningElement {
    registros = [];
    ultimoId = null;
    temMais = true;
    carregando = false;
    erro;

    connectedCallback() {
        this.carregarMais();
    }

    async carregarMais() {
        // Guarda contra clique duplo e contra pedir depois do fim.
        if (this.carregando || !this.temMais) {
            return;
        }
        this.carregando = true;
        this.erro = undefined;
        try {
            const pagina = await buscarPagina({ ultimoId: this.ultimoId, tamanho: TAMANHO });
            this.registros = [...this.registros, ...pagina];
            if (pagina.length > 0) {
                this.ultimoId = pagina[pagina.length - 1].Id;
            }
            if (pagina.length < TAMANHO) {
                this.temMais = false;
            }
        } catch (e) {
            this.erro = this.mensagemDeErro(e);
        } finally {
            this.carregando = false;
        }
    }

    mensagemDeErro(e) {
        if (e && e.body && e.body.message) {
            return e.body.message;
        }
        return 'Nao foi possivel carregar os registros. Tente novamente.';
    }

    get vazio() {
        return !this.carregando && !this.erro && this.registros.length === 0;
    }
}

E o template, que reage ao estado sem nunca ficar em branco:

<template>
    <lightning-card title="Oportunidades" icon-name="standard:opportunity">
        <div class="slds-p-horizontal_medium">

            <template if:true={erro}>
                <div class="slds-text-color_error slds-p-around_small" role="alert">
                    {erro}
                </div>
            </template>

            <template if:true={vazio}>
                <p class="slds-p-around_small">Nenhuma oportunidade encontrada.</p>
            </template>

            <ul class="slds-has-dividers_bottom">
                <template for:each={registros} for:item="opp">
                    <li key={opp.Id} class="slds-item slds-p-vertical_x-small">{opp.Name}</li>
                </template>
            </ul>

            <div class="slds-align_absolute-center slds-p-vertical_small">
                <template if:true={temMais}>
                    <lightning-button label="Carregar mais" onclick={carregarMais}
                        disabled={carregando}></lightning-button>
                </template>
                <template if:true={carregando}>
                    <lightning-spinner alternative-text="Carregando"></lightning-spinner>
                </template>
            </div>

        </div>
    </lightning-card>
</template>

Destrinchando parte por parte

1. O cursor em vez do OFFSET

if (ultimoId == null) {
    return [SELECT Id, Name, Amount, StageName FROM Opportunity ORDER BY Id ASC LIMIT :limite];
}
return [
    SELECT Id, Name, Amount, StageName
    FROM Opportunity
    WHERE Id > :ultimoId
    ORDER BY Id ASC
    LIMIT :limite
];

Este é o coração da solução. Em vez de dizer "pule os primeiros 2050 e me traga 50", que o SOQL não deixa, a consulta diz "me traga os 50 primeiros cujo Id é maior que o último que eu já mostrei". O cursor é o próprio Id. Como o Id é único e a consulta ordena por ele, cada página começa exatamente onde a anterior terminou: nada repete, nada some no meio.

Repare que o ORDER BY Id ASC não é decoração. É ele que dá sentido ao Id > :ultimoId. Sem uma ordem estável e alinhada ao campo do cursor, a fatia "depois do último Id" não tem significado, e você volta a repetir ou pular registros. Cursor e ordenação são um par: mudou um, muda o outro.

2. O tamanho com rede de segurança

Integer limite = (tamanho == null || tamanho <= 0) ? TAMANHO_PADRAO : tamanho;

O tamanho vem do cliente, e cliente manda o que quiser. Se chegar null ou zero, a consulta com LIMIT :tamanho ou quebraria ou traria nada. Tratar isso no servidor é barato e evita que um bug no front vire uma exceção feia para o usuário. É a mesma disciplina de sempre: não confie no que atravessa a fronteira do método.

3. Anexar, não substituir

const pagina = await buscarPagina({ ultimoId: this.ultimoId, tamanho: TAMANHO });
this.registros = [...this.registros, ...pagina];
if (pagina.length > 0) {
    this.ultimoId = pagina[pagina.length - 1].Id;
}

O botão se chama "Carregar mais", então cada carga acrescenta à lista, não a troca. O spread [...this.registros, ...pagina] cria um novo array, o que é importante em LWC: reatribuir a propriedade é o que dispara o re-render. Se você fizesse this.registros.push(...), mutaria o array no lugar e o template poderia não atualizar.

Logo depois, o cursor avança para o Id do último registro que veio. É essa linha que faz a próxima chamada pedir a fatia seguinte. Ela só roda se veio pelo menos um registro, senão pagina[pagina.length - 1] estouraria num array vazio.

4. Saber que chegou ao fim (a borda que a maioria esquece)

if (pagina.length < TAMANHO) {
    this.temMais = false;
}

Como o componente sabe que não há mais páginas? Comparando o que pediu com o que veio. Se você pediu 50 e vieram 50, provavelmente há mais. Se vieram menos que 50, aquela foi a última fatia. Quando temMais vira false, o template esconde o botão e a guarda no topo do método (if (this.carregando || !this.temMais) return;) impede qualquer chamada extra.

Esse é o par que satisfaz a regra de borda: lista que termina sem que o usuário consiga disparar mais uma consulta inútil. E cobre também a org vazia: se a primeira carga vier com zero registros, temMais cai na hora, o getter vazio fica verdadeiro e o template mostra o estado vazio em vez de uma tela em branco.

5. O erro que não vira tela branca

} catch (e) {
    this.erro = this.mensagemDeErro(e);
} finally {
    this.carregando = false;
}

Toda chamada imperativa de Apex devolve uma promise. Sem catch, uma falha vira uma promise rejeitada que ninguém tratou: o console acusa Unhandled promise rejection e o usuário fica olhando para o nada. O catch captura o erro e o getter mensagemDeErro puxa o texto de e.body.message, que é onde o Apex coloca a mensagem da exceção.

O finally é tão importante quanto o catch: ele desliga o carregando em qualquer desfecho. Sem isso, um erro deixaria o spinner girando para sempre e o botão travado em desabilitado. O caminho de erro precisa devolver o componente a um estado utilizável.

Erros comuns

  • Paginar com LIMIT e OFFSET. Funciona nas primeiras páginas e quebra na primeira que passa de OFFSET 2000. É o erro que o próprio cenário provoca: cinco mil registros garantem que você bata no teto.
  • Trazer tudo e paginar no JavaScript. Cortar o array no cliente com slice mascara o problema: você ainda buscou os cinco mil e ainda pagou o custo de transferência e memória. A regra pede paginação no servidor justamente para não cair nessa.
  • Usar push em vez de reatribuir o array. Mutar this.registros no lugar não avisa o LWC de que houve mudança, e a lista na tela não cresce apesar de os dados chegarem.
  • Esquecer o finally. Tratar o erro mas deixar o carregando ligado congela o componente num spinner eterno. O estado precisa se recompor depois da falha.
  • Não desligar o botão no fim. Sem a checagem pagina.length < TAMANHO, o usuário continua clicando "Carregar mais" no fim da lista e disparando consultas que voltam vazias.

Como saber se a sua solução passou

O script de teste do desafio prova o servidor por Execute Anonymous: a primeira página volta com o tamanho pedido, a segunda não repete nenhum Id da primeira e o consumo de consultas fica baixo. Esse é o critério objetivo do controlador.

System.assertEquals(0, repetidos, 'A pagina seguinte nao pode repetir registro da anterior');
System.debug('Consultas na transacao: ' + Limits.getQueries());

A parte da tela você confere subindo o componente numa página Lightning: role até o fim e veja o botão sumir, force um erro (por exemplo, renomeando o método Apex temporariamente) e veja a mensagem aparecer em vez de a tela ficar branca. Se as duas bordas se comportam, a solução está completa.

Um passo além

Se quiser levar a solução para o nível que se espera em produção, três evoluções:

  • Trocar a lista por lightning-datatable com scroll infinito. O lightning-datatable tem o evento onloadmore, que dispara quando o usuário rola até o fim. Ligado ao mesmo carregarMais, ele troca o botão por carregamento contínuo, mais próximo do que o usuário espera de uma lista grande.
  • Respeitar as permissões de quem abriu a tela. A consulta acima roda com o acesso da classe. Acrescente WITH USER_MODE para que o componente só mostre oportunidades que o usuário poderia ver, e campos que ele pode ler. O raciocínio completo está em segurança em Apex: CRUD, FLS e sharing.
  • Entender o custo de cada consulta na transação. Paginar bem é, no fundo, respeitar os limites da plataforma sob volume. O panorama de por que cada query e cada linha importam está em governor limits em Apex.

O que fica deste desafio: performance de tela com volume não se resolve no navegador, e sim decidindo no servidor o que sequer sai do banco. Paginação por cursor é a ferramenta que faz isso escalar sem esbarrar no teto do OFFSET. Em entrevista de LWC, "como você pagina uma lista de dez mil registros" é uma das perguntas que mais separa quem já sofreu em produção de quem só viu o caminho feliz.