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
- 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.
- 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.
- Busque a página completa da URL, extraia o
<main>e troque o miolo atual. - Atualize
document.titlecom o título da página nova. - Chame
pushStatecom a URL, guardando antes a rolagem da entrada atual. - Mova o foco para o título do conteúdo novo, para o leitor de tela anunciar a mudança.
- No
popstate, carregue a URL atual sem empurrar entrada nova e restaure a rolagem salva. - 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
pushStatedentro dopopstate. 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ãopushState. - 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?
Referência: http://wbruno.com.br/ajax/navegacao-sem-refresh-%E2%80%93-carregando-conteudo-ajax-em-div-2/