Aprender
Interatividade
Trocar um pedaço da página e enviar formulário sem recarregar, com o mesmo handler que serve a página inteira.
Uma página do Trilha é HTML inteiro: o navegador navega, o servidor responde, a tela pisca. Isso funciona bem, mas não em toda tela — filtrar uma lista ou salvar um formulário não deveria custar uma recarga.
O caminho aqui é fragmento: o mesmo link e o mesmo formulário de sempre, com um atributo a mais. Com JavaScript ligado, o kit ui pede a página, o servidor devolve só o pedaço e o navegador troca aquele elemento. Sem JavaScript, o link navega e o formulário envia — o servidor devolve a página inteira porque ninguém pediu fragmento. Nenhuma rota nova, nenhum handler novo, nenhuma dependência.
Uma pergunta a mais no handler#
c.Fragment() devolve o id que o cliente quer trocar, ou "" numa navegação normal:
func Page(c *trilha.Ctx) (h.Node, error) {
c.SetTitle("Clientes")
return tela(c, c.Query("q")), nil
}
// tela é a página inteira quando não há fragmento, e o pedaço quando há:
// o elemento trocado precisa carregar o mesmo id.
func tela(c *trilha.Ctx, q string) h.Node {
return h.Div(h.ID("lista"),
h.Form(h.Method("get"), h.Action("/clientes"), ui.Swap("lista"),
ui.Input(h.Name("q"), h.Value(q)),
ui.Submit(h.Text("Buscar")),
),
lista(clientes.Buscar(q)),
)
}Quando a requisição traz o cabeçalho Trilha-Fragment, o Trilha:
- pula os layouts da rota (nada de
<html>,<head>, barra de navegação); - escreve só os nós que você devolveu, sem o envelope do documento e sem o script do dev server;
- responde com
Vary: Trilha-Fragment, para um cache não guardar o pedaço no lugar da página.
Tudo o mais continua igual: middleware roda, CSRF é verificado, o status é o que você mandou. c.Fragment() é só uma pergunta.
O link e o formulário#
No HTML, ui.Swap("id") marca quem participa:
ui.ButtonLink("/clientes?pagina=2", ui.Swap("lista"), h.Text("Próxima"))
h.Form(h.Method("post"), h.Action("/clientes"), ui.Swap("tela"),
trilha.CSRFInput(c),
// campos…
)O ui.js intercepta o clique (só botão esquerdo, sem Ctrl/Cmd, mesma origem) e o envio, faz um fetch com o cabeçalho, e troca o elemento pelo HTML que voltou. Enquanto espera, o alvo ganha aria-busy="true" (o CSS do kit deixa o bloco opaco e o cursor de espera). ui.NoPush() no link evita mexer no histórico.
Depois do POST#
Um POST que redireciona continua redirecionando — inclusive no fragmento. Como o fetch seguiria o 303 sozinho e devolveria a página nova em pedaço, o Trilha responde 204 com o cabeçalho Trilha-Location, e o ui.js navega de verdade. O padrão redirecionar-depois-de-gravar sobrevive.
Quando faz mais sentido ficar na mesma tela, responda com o pedaço atualizado:
func POST(c *trilha.Ctx) error {
in, errs := ler(c)
if len(errs) > 0 {
return c.Render(422, tela(c, in, errs, "")) // formulário com os erros
}
clientes.Criar(in)
if c.Fragment() != "" {
return c.Render(200, tela(c, clientes.Cliente{}, nil, "Cadastro salvo!"))
}
return c.Redirect("/clientes?ok=1")
}Em 422 o ui.js põe o foco no primeiro campo com aria-invalid="true" — é o que o navegador faria sozinho numa recarga. Fora disso, ele devolve o foco (e a posição do cursor) ao campo que estava em uso, procurando pelo id ou pelo name.
Quando o fragmento não dá certo#
O kit nunca deixa a tela travada: se a resposta for 5xx, se a rede cair ou se o pedaço vier sem o id esperado, ele desiste e faz a navegação de verdade — o link vira location, o formulário vira form.submit(). O usuário vê a página recarregar; não vê um clique que não fez nada.
Depois da troca#
Elementos novos entram já hidratados: [data-ui-fade] e [data-ui-show-when] voltam a funcionar sozinhos. Se você tem comportamento próprio, ouça o evento:
document.addEventListener("trilha:swap", (e) => {
// e.detail.target = elemento novo, e.detail.status = status da resposta
});window.ui.swap(id, html, status) e window.ui.hydrate(el) estão expostos para quem precisar fazer a troca à mão.
A ilha: o que o fragmento não faz#
Fragmento vem sempre do servidor. Um editor com prévia ao vivo, um canvas, um mapa que arrasta: o estado está no cliente e não há ida e volta a fazer. Isso é uma ilha — um pedaço da página que traz o próprio módulo, com tudo em volta continuando HTML comum.
c.Island("/editor.js", map[string]any{"ppm": 200},
h.Class("editor"),
ui.Textarea(h.Name("corpo")), // o conteúdo de origem: ainda é um campo
h.P(h.Data("info", ""), h.Hidden()), // preenchido pelo módulo
)<div data-trilha-island="/editor.js?v=9c1f" data-trilha-props="{"ppm":200}" class="editor">…</div>O módulo é um ES module comum em public/, e a exportação padrão dele é a montagem:
export default function (el, props) {
const area = el.querySelector("textarea");
area.addEventListener("input", () => { /* … */ });
}Quatro coisas saem desse formato:
- Os filhos são o conteúdo de origem, e quem os renderiza é o servidor. Script bloqueado, ainda a caminho ou 404: a página é o que sempre foi. A ilha acrescenta, não sustenta.
- As props são dado. Vão escapadas num atributo e voltam pelo
JSON.parse— um valor vindo do banco não vira marcação. Vale o que oencoding/jsonserializa; o que não serializa avisa no log e deixa o conteúdo de origem em paz. - Sem bundler e sem hidratação global. O módulo é um arquivo em
public/, endereçado peloAsset(então a URL leva o hash do conteúdo), e só as ilhas presentes na página são montadas, cada uma uma vez. O carregador é um único script inline com o nonce da requisição, e é por isso que a CSP padrão o aceita semunsafe-inline. - Uma ilha que chega dentro de um fragmento também monta: o carregador ouve o
trilha:swap. O que ele precisa é já estar na página — ou seja, a página renderizou ao menos uma ilha própria.
A porta de saída#
A ilha é a fronteira onde outra biblioteca entra, e onde o custo dela para. Web Components não precisam de nada daqui — customElements.define e a tag é a ilha. Para Alpine, htmx ou o que for, ponha o arquivo em public/ e importe do módulo da ilha; para React, uma build ESM em public/ e um createRoot(el) dentro da montagem. A página em volta não é obrigada a virar componente, e o resto do projeto não fica sabendo da escolha.
A CSP padrão é script-src 'self', então módulo vindo de CDN é recusado até você abrir a mão — decisão, não acidente.
A página inteira, sem a recarga#
O fragmento troca um pedaço da página que um handler escolheu. A navegação é a outra metade: a próxima página é outra página, e o que não deveria piscar é tudo em volta — o cabeçalho, a barra lateral, a rolagem de uma lista longa.
// app/painel-/layout.go
return h.Section(h.Class("app"), ui.Navigate("conteudo"), ui.NavigateScript(c),
ui.Sidebar(ui.Nav(
ui.NavLink("/painel", "Painel", cur == "/painel"),
ui.NavLink("/relatorio", "Relatório", cur == "/relatorio"),
)),
h.Div(h.Class("app-content"), children),
), nilui.Navigate(id) marca uma região: um clique em link da mesma origem dentro dela busca a próxima página e troca o #id pelo mesmo elemento dela. ui.NavigateScript(c) carrega o comportamento — arquivo separado do ui.js, para que um app que não navegue assim não o baixe. No servidor não muda nada: /relatorio é a mesma rota, respondendo o mesmo documento. Recarregar, abrir em outra aba ou chegar com o JavaScript desligado dá a mesma página.
Desligada por padrão, e desligada por link:
ui.ButtonLink("/relatorio.pdf", ui.NoNavigate(), h.Text("Baixar"))O navegador mantém os costumes — Voltar e Avançar funcionam e restauram a rolagem da entrada para onde voltam, Cmd-clique abre aba, target e download passam intactos. O kit acrescenta aria-busy durante a espera, leva o foco para o que entrou e dispara trilha:swap, então uma ilha dentro da página nova monta. Um segundo clique cancela a primeira requisição; 5xx, redirecionamento ou página sem aquele id desiste e navega de verdade.
A regra de bolso: fragmento quando um handler responde um pedaço, navegação quando a resposta é uma página e a moldura em volta deve ficar.
O arquivo, e a barra que diz onde ele está#
Mandar arquivo é o único lugar onde "a tela pisca" não é o problema — o problema é não acontecer nada por trinta segundos. O navegador sabe quanto já subiu; ele só não tem como dizer isso num envio de formulário comum.
// app/anexos/page.go
h.Form(h.Method("post"), h.Action("/anexos"), h.Enctype("multipart/form-data"),
ui.UploadTo("lista"),
trilha.CSRFInput(c),
ui.Field("arquivo", "Arquivo", ui.Input(h.ID("arquivo"), h.Name("arquivo"), h.Type("file"), h.Required())),
ui.UploadBar(),
ui.Submit(h.Text("Enviar")),
)ui.UploadTo(id) envia o formulário por XHR e troca o #id pela resposta; ui.UploadBar() é o <progress> que o kit preenche com o evento de progresso do próprio navegador; e ui.UploadScript(c) carrega o comportamento — arquivo próprio de novo, para que uma página sem upload não o baixe. Com o JavaScript desligado nada disso existe, e o formulário é o que sempre foi: envia, o servidor responde, a página recarrega.
No servidor não há API nova. A requisição leva o Trilha-Fragment, então o mesmo handler que desenha a página responde o pedaço:
func POST(c *trilha.Ctx) error {
if err := c.FormErr(); err != nil {
return err
}
f, hdr, err := c.Request().FormFile("arquivo")
if err != nil {
return err
}
defer f.Close()
anexos.Add(hdr.Filename, hdr.Size)
if c.Fragment() != "" {
return c.Render(200, lista()) // o pedaço, com o mesmo id
}
return c.Redirect("/anexos") // sem JavaScript: gravar, redirecionar, buscar
}O limite é do app; a exceção é da rota#
O corpo tem teto no Config.MaxBodyBytes (1 MiB por padrão) — é esse teto que impede uma requisição de comer a memória do servidor, e ele deve continuar de pé em toda rota que recebe formulário. A rota que recebe arquivo diz isso por conta própria, no middleware.go dela:
// app/anexos/middleware.go
func Middleware(c *trilha.Ctx, next trilha.Next) error {
if c.Request().Method == "POST" {
c.AllowBody(8 << 20)
c.NoReadDeadline() // conexão ruim não é erro
}
return next()
}No middleware, e não no handler: o CSRF lê o formulário antes do handler rodar, então lá dentro o corpo já teria sido lido no limite antigo. O resto do app continua no 1 MiB, e passar dos 8 MiB continua sendo 413 com a mensagem de sempre.
O que isso não é#
Não é SPA. Não há roteador no cliente, estado compartilhado, hidratação de componente nem diff de DOM — a troca é outerHTML, e a fonte da verdade continua sendo o servidor. Uma tela que precise de estado local rico (um editor, um canvas) merece JavaScript próprio, e a ilha acima é onde esse JavaScript mora; o fragmento resolve o caso comum, que é a maioria das telas.
Vale lembrar o limite de segurança: o cabeçalho Trilha-Fragment é personalizado, então um site de terceiros não consegue mandá-lo sem preflight — e o Trilha não responde preflight. Um fragmento só sai para a sua própria origem.
O exemplo examples/cadastro usa os dois: busca que filtra a lista e cadastro que salva sem recarregar, ambos funcionando com o JavaScript desligado.
Desafio#
Faça a lista trocar sozinha enquanto o usuário digita, sem esperar o botão — e sem disparar uma requisição por tecla.
Mostrar solução
let t;
document.addEventListener("input", (e) => {
const campo = e.target.closest("form[data-trilha-target] input[name=q]");
if (!campo) return;
clearTimeout(t);
t = setTimeout(() => campo.form.requestSubmit(), 250);
});requestSubmit() dispara o mesmo evento submit que o kit já escuta, então o data-trilha-target continua valendo — e o formulário segue funcionando no clique do botão para quem não tem JavaScript.