Programação Por Equipe DevBras

Capa do artigo: pushState e popstate: o botão voltar funcionando depois do AJAX

pushState e popstate: o botão voltar funcionando depois do AJAX

Troque o conteúdo da página por fetch sem quebrar o voltar: endereço novo a cada assunto, título e foco atualizados, rolagem restaurada e a mesma página ao recarregar.

No blog da empresa, a lista de artigos carrega cada post por AJAX dentro de uma <div>, sem recarregar a página. A leitora abre três artigos seguidos e aperta voltar para rever o segundo. Cai no Google. A tela mudou três vezes, mas o histórico do navegador não ficou sabendo de nenhuma delas, e o endereço na barra continua sendo o da lista.

A resposta curta: se a tela mudou de assunto, o endereço muda junto. A cada conteúdo carregado, registre uma entrada no histórico com history.pushState e a URL daquele conteúdo; quando a pessoa voltar, o evento popstate avisa e você carrega o conteúdo da URL atual. Atualize o título da aba, mova o foco para o novo título e garanta que abrir a mesma URL do zero mostre o mesmo conteúdo.

Por que o voltar sai do site

O histórico do navegador é uma lista de endereços. Clicar num link normal acrescenta um item; apertar voltar recua um. Quando você troca o miolo da página com fetch e innerHTML, nenhum item novo entra na lista. Para o navegador, a pessoa ainda está na primeira página que abriu, e o voltar leva ao que havia antes dela.

O mesmo defeito quebra outras coisas. O link copiado da barra aponta para a lista, não para o artigo. Recarregar volta para a lista. O buscador não tem um endereço para cada artigo. O post AJAX que substitui o HTML sem atualizar o endereço trata dessa perda de estado; aqui o foco é a implementação que resolve.

A API de histórico tem duas peças. history.pushState(estado, '', url) acrescenta uma entrada e muda a barra de endereço sem carregar nada; o segundo argumento existe por compatibilidade e os navegadores o ignoram. E o evento popstate dispara quando a pessoa usa voltar ou avançar entre entradas criadas assim. Ele não dispara na chamada de pushState, só na navegação pelo histórico.

Como implementar passo a passo

  1. Garanta que cada artigo tem uma URL própria que o servidor responde com a página completa. Esse é o caminho sem JavaScript e o que o recarregar usa.
  2. Intercepte o clique só nos links que você quer carregar por AJAX, e só no clique simples. Ctrl, Shift e o botão do meio continuam abrindo nova aba.
  3. Busque a página completa da URL, extraia o <main> e troque o miolo atual.
  4. Atualize document.title com o título da página nova.
  5. Chame pushState com a URL, guardando antes a rolagem da entrada atual.
  6. Mova o foco para o título do conteúdo novo, para o leitor de tela anunciar a mudança.
  7. No popstate, carregue a URL atual sem empurrar entrada nova e restaure a rolagem salva.
  8. Se a busca falhar, faça a navegação normal para a URL. O pior caso vira um carregamento comum.

O código

const miolo = document.querySelector('main');
let pedidoAtual = null;

history.scrollRestoration = 'manual';
history.replaceState({ rolagem: 0 }, '');

async function carregar(url, { empurrar = true, rolagem = 0 } = {}) {
  pedidoAtual?.abort();
  pedidoAtual = new AbortController();

  let html;
  try {
    const resposta = await fetch(url, { signal: pedidoAtual.signal });
    if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
    html = await resposta.text();
  } catch (erro) {
    if (erro.name === 'AbortError') return;
    if (empurrar) window.location.assign(url);
    else window.location.reload();
    return;
  }

  const pagina = new DOMParser().parseFromString(html, 'text/html');
  const novoMiolo = pagina.querySelector('main');
  if (!novoMiolo) {
    window.location.assign(url);
    return;
  }

  if (empurrar) {
    history.replaceState({ ...history.state, rolagem: window.scrollY }, '');
  }

  miolo.replaceChildren(...novoMiolo.childNodes);
  document.title = pagina.title;

  if (empurrar) history.pushState({ rolagem: 0 }, '', url);

  const titulo = miolo.querySelector('h1');
  if (titulo) {
    titulo.tabIndex = -1;
    titulo.focus({ preventScroll: true });
  }
  window.scrollTo(0, rolagem);
}

document.addEventListener('click', (evento) => {
  const link = evento.target.closest('a[data-carregar]');
  if (!link) return;
  if (evento.button !== 0 || evento.ctrlKey || evento.metaKey || evento.shiftKey || evento.altKey) return;
  if (link.origin !== window.location.origin) return;
  evento.preventDefault();
  carregar(link.href);
});

window.addEventListener('popstate', (evento) => {
  carregar(window.location.href, { empurrar: false, rolagem: evento.state?.rolagem ?? 0 });
});

O replaceState logo no começo grava um estado na entrada inicial, para que voltar até a lista também tenha onde guardar a rolagem. Antes de cada pushState, o código salva a posição da tela atual na entrada que está sendo deixada. No popstate, ele lê essa posição e devolve a pessoa ao ponto da lista onde estava, depois que o conteúdo chegou. Por isso a restauração automática foi desligada com scrollRestoration = 'manual': o navegador tentaria rolar antes de o conteúdo existir.

O AbortController cuida do clique rápido em dois links. Sem ele, a resposta do primeiro pode chegar depois da do segundo e sobrescrever a tela com o artigo errado, enquanto a barra mostra o endereço certo.

Decisões que evitam o bug seguinte

Buscar a página inteira, não um pedaço

O código acima pede a mesma URL que o navegador pediria e recorta o <main>. Parece desperdício, mas resolve três problemas: o servidor tem uma resposta só por endereço, o recarregar mostra exatamente o que o AJAX mostrou, e o cache do navegador nunca confunde o pedaço com a página. Se você preferir um endpoint que devolve só o fragmento, use outra URL para ele (como /api/artigos/42), não a mesma do artigo com um cabeçalho diferente. Com a mesma URL, há casos em que o navegador mostra a resposta guardada do fragmento quando a pessoa reabre a aba.

Filtro e busca usam replaceState

Nem toda mudança merece uma entrada no histórico. Se a pessoa digita na busca da lista e o resultado se atualiza a cada letra, cinco letras não podem virar cinco voltas. Use history.replaceState para manter a URL com o filtro atual (?q=docker) sem acumular entradas. Mudou o assunto, pushState; refinou o mesmo assunto, replaceState.

Scripts e metadados do conteúdo novo

Scripts dentro do HTML inserido por replaceChildren não executam. Se o artigo precisa de um script, carregue-o pela página principal e inicialize depois da troca. A tag canônica e a descrição continuam sendo lidas pelo buscador na resposta completa da URL, que é mais um motivo para o servidor responder a página inteira; veja canonical da página certa.

Erro do servidor continua sendo erro

Se a URL do artigo não existe, o servidor responde 404 com uma página de erro, e o código cai na navegação normal, que mostra essa página. Não transforme o 404 em "conteúdo vazio" na tela com status 200; os códigos de status HTTP existem para o navegador e o buscador entenderem o que aconteceu.

Navegadores mais novos também oferecem a Navigation API, que centraliza a interceptação de navegação num só evento. Ela simplifica parte deste código onde está disponível, mas confira o suporte nos navegadores do seu público antes de depender dela; o modelo com pushState e popstate funciona em todos os atuais.

Erros comuns

  • Trocar o miolo sem pushState. O voltar sai do site e o link copiado aponta para a página errada.
  • Chamar pushState dentro do popstate. Cada volta cria uma entrada nova e a pessoa fica presa num laço de voltar.
  • URL que o servidor não conhece. O AJAX funciona, mas recarregar ou abrir o link compartilhado dá 404.
  • Esquecer o título da aba. O histórico e as abas mostram o mesmo título para todos os artigos.
  • Não mover o foco. Quem usa leitor de tela não percebe que o conteúdo mudou e continua no link que clicou.
  • Interceptar Ctrl+clique. A pessoa pede nova aba e o artigo abre na mesma.

Checklist

  • Cada conteúdo carregado por AJAX tem uma URL própria.
  • Abrir essa URL do zero mostra o mesmo conteúdo.
  • Voltar e avançar percorrem os conteúdos na ordem em que foram abertos.
  • O título da aba muda a cada conteúdo.
  • O foco vai para o título do conteúdo novo.
  • Voltar para a lista devolve a rolagem onde a pessoa estava.
  • Filtros digitados usam replaceState, não pushState.
  • Ctrl+clique e botão do meio abrem nova aba normalmente.

Abra dois conteúdos seguidos no seu site e aperte voltar uma vez. Você está no conteúdo anterior, na mesma posição de rolagem, ou o navegador acabou de levar a pessoa para fora do seu site?

Avalie este artigo

Referência: http://wbruno.com.br/ajax/navegacao-sem-refresh-%E2%80%93-carregando-conteudo-ajax-em-div-2/