Programação Por Equipe DevBras

Capa do artigo: Blazor Auto: o componente troca de lado e o estado fica para trás

Blazor Auto: o componente troca de lado e o estado fica para trás

Entenda por que um componente InteractiveAuto busca dados duas vezes, perde o que estava num serviço e esquece quem está logado, e como desenhar o estado para os dois lados.

A tela de pedidos abre com a lista certa e o nome do cliente no topo. Meio segundo depois a lista pisca, some e volta. Na semana seguinte, depois de um refresh, o mesmo componente mostra "entre na sua conta" para quem acabou de entrar. Ninguém mexeu no login. O componente está em InteractiveAuto e, a cada visita, pode estar rodando num lugar diferente.

A resposta curta: no modo Auto, o mesmo componente roda no servidor (pré-renderização e circuito SignalR) e depois no navegador (WebAssembly), e nada do que estava na memória de um lado chega sozinho ao outro. Você precisa escolher onde cada dado nasce, atravessar a fronteira de propósito (com PersistentComponentState ou com uma API) e testar o primeiro carregamento, o refresh e a segunda visita.

O que o modo Auto faz com o seu componente

Num Blazor Web App, um componente marcado com @rendermode InteractiveAuto passa por até três vidas. Primeiro, por padrão, ele é pré-renderizado no servidor: o HTML sai pronto na resposta HTTP, e a pessoa vê a página antes de qualquer interatividade. Depois, se o pacote WebAssembly ainda não está no cache do navegador, ele vira interativo pelo servidor, num circuito SignalR. Nas visitas seguintes, com o pacote já baixado, ele vira interativo direto no navegador.

Cada uma dessas vidas cria uma instância nova do componente. O OnInitializedAsync roda de novo. Os serviços injetados vêm de containers diferentes: no servidor, um serviço scoped vive enquanto dura o circuito; no WebAssembly, vive enquanto a aba estiver aberta. Um campo preenchido na pré-renderização não existe na instância interativa. É por isso que a lista pisca: a primeira instância buscou os pedidos, desenhou o HTML e morreu; a segunda começou do zero e buscou de novo.

Por que parece bug de login

Autenticação é só mais um estado. No servidor, o componente enxerga o usuário porque o cookie chegou na requisição. No navegador, o código WebAssembly não lê cookie HttpOnly (e não deve ler). Se ninguém passou o estado de autenticação para o lado cliente, o componente pergunta "quem é você?" e recebe um usuário anônimo. A sessão está ótima; o componente é que está do outro lado da fronteira. O desenho específico para isso está em como o componente WebAssembly fica sabendo quem está logado. Aqui o foco é o estado em geral: dados, serviços e o usuário como um caso particular.

Como desenhar o estado para os dois lados

O exemplo que atravessa o texto: uma loja com a página /pedidos, que lista os 20 últimos pedidos do cliente logado. O componente fica no projeto Loja.Client, porque precisa rodar no navegador também. O banco só existe no servidor.

  1. Defina um contrato que os dois lados implementam. O componente depende de IPedidoService, não do DbContext nem do HttpClient.
  2. Implemente o lado servidor indo direto ao repositório, descobrindo o usuário pelo AuthenticationStateProvider.
  3. Implemente o lado cliente chamando uma API do próprio servidor, que exige autenticação.
  4. Registre cada implementação no seu Program.cs. O servidor registra a dele; o cliente registra a dele. O componente não sabe qual recebeu.
  5. Persista o resultado da pré-renderização com PersistentComponentState, para a instância interativa não buscar de novo.
  6. Teste os três caminhos: primeira visita com cache limpo, refresh com F5 e navegação depois que o WebAssembly já carregou.

O contrato e as duas implementações

// Loja.Client/Pedidos/IPedidoService.cs
public record PedidoResumo(int Id, decimal Total, DateTime CriadoEm);

public interface IPedidoService
{
    Task<List<PedidoResumo>> ListarAsync();
}

// Loja.Client/Pedidos/PedidoServiceCliente.cs
public sealed class PedidoServiceCliente(HttpClient http) : IPedidoService
{
    public async Task<List<PedidoResumo>> ListarAsync() =>
        await http.GetFromJsonAsync<List<PedidoResumo>>("api/pedidos") ?? [];
}

// Loja/Pedidos/PedidoServiceServidor.cs
public sealed class PedidoServiceServidor(
    PedidoRepositorio repo,
    AuthenticationStateProvider auth) : IPedidoService
{
    public async Task<List<PedidoResumo>> ListarAsync()
    {
        var estado = await auth.GetAuthenticationStateAsync();
        var clienteId = estado.User.FindFirstValue(ClaimTypes.NameIdentifier)
            ?? throw new UnauthorizedAccessException("Usuário não autenticado.");
        return await repo.ListarDoClienteAsync(clienteId);
    }
}

A API que o cliente chama não usa o AuthenticationStateProvider, porque fora do pipeline do Blazor quem diz quem é o usuário é o próprio ClaimsPrincipal da requisição:

// Loja/Program.cs
builder.Services.AddScoped<PedidoRepositorio>();
builder.Services.AddScoped<IPedidoService, PedidoServiceServidor>();

app.MapGet("/api/pedidos", async (ClaimsPrincipal user, PedidoRepositorio repo) =>
{
    var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
    return id is null
        ? Results.Unauthorized()
        : Results.Ok(await repo.ListarDoClienteAsync(id));
}).RequireAuthorization();

// Loja.Client/Program.cs
builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<IPedidoService, PedidoServiceCliente>();

Repare que a regra "cada cliente só vê os próprios pedidos" mora no repositório, no servidor. O lado cliente não filtra nada; ele pede e recebe o que o servidor autorizou. Se a regra estivesse no componente, bastaria abrir o DevTools e chamar a API com outro parâmetro.

Sem buscar duas vezes: PersistentComponentState

Na pré-renderização, o componente registra uma função que serializa os dados no próprio HTML da página. Quando a instância interativa nasce, ela tenta pegar esses dados antes de chamar o serviço:

@page "/pedidos"
@rendermode InteractiveAuto
@implements IDisposable
@inject IPedidoService Pedidos
@inject PersistentComponentState Estado

@if (pedidos is null)
{
    <p>Carregando pedidos...</p>
}
else
{
    <ul>
        @foreach (var p in pedidos)
        {
            <li>#@p.Id: @p.Total.ToString("C") em @p.CriadoEm.ToString("d")</li>
        }
    </ul>
}

@code {
    private List<PedidoResumo>? pedidos;
    private PersistingComponentStateSubscription assinatura;

    protected override async Task OnInitializedAsync()
    {
        assinatura = Estado.RegisterOnPersisting(Persistir);

        if (!Estado.TryTakeFromJson<List<PedidoResumo>>("pedidos", out var salvos))
        {
            salvos = await Pedidos.ListarAsync();
        }
        pedidos = salvos;
    }

    private Task Persistir()
    {
        Estado.PersistAsJson("pedidos", pedidos);
        return Task.CompletedTask;
    }

    public void Dispose() => assinatura.Dispose();
}

Com isso, a primeira visita faz uma consulta ao banco, não duas. Versões mais novas do .NET oferecem uma forma declarativa (um atributo na propriedade) para o mesmo mecanismo; se a sua versão tem, use, mas o modelo mental é o mesmo: o dado viaja dentro do HTML, então ele precisa ser pequeno e não pode conter segredo. Tudo que você persiste fica visível no código-fonte da página.

Onde cada tipo de estado deve morar

  • Dado de negócio (pedidos, saldo, estoque): nasce no servidor. O cliente pede por API autorizada.
  • Quem está logado: nasce no cookie do servidor. O cliente recebe uma cópia das informações de que a interface precisa, nunca o cookie.
  • Estado de tela (aba aberta, filtro digitado): pode viver no componente ou na URL. Na URL ele sobrevive ao refresh e à troca de lado de graça.
  • Estado compartilhado entre componentes da mesma página: um serviço scoped funciona, desde que você aceite que ele recomeça vazio quando o componente muda de modo.

Erros comuns

  • Injetar o DbContext num componente do projeto Client. Compila em um lado e quebra no outro; quando o WebAssembly assume, não há banco nem connection string no navegador.
  • Guardar o carrinho num serviço scoped do servidor e esperar que ele esteja lá depois. Quando a página passa a rodar no navegador, o circuito acabou e o carrinho foi junto.
  • Persistir segredo com PersistentComponentState. O valor vai serializado no HTML; um token ali é um token publicado.
  • Testar só com o circuito quente do debug. O desenvolvedor navega de uma página para outra sem nunca dar refresh e não vê a troca de modo que o usuário vê.
  • Duplicar a regra de autorização no componente. O if no cliente esconde o botão, mas a API sem RequireAuthorization continua entregando o dado.
  • Desligar a pré-renderização para "resolver" a busca dupla. Some o pisca, mas a página fica em branco até a interatividade chegar e perde o HTML que os buscadores leem.

Checklist

  • O componente depende de uma interface, com uma implementação registrada em cada Program.cs.
  • A API usada pelo lado cliente exige autenticação e filtra pelo usuário no servidor.
  • Na primeira visita, os logs mostram uma consulta ao banco por carregamento de página, não duas.
  • Nada persistido no HTML é senha, token ou dado de outro usuário.
  • Refresh com F5 na página interativa ainda mostra o usuário certo.
  • Você testou com o cache do navegador limpo e com o WebAssembly já baixado.
  • Estado que precisa sobreviver ao refresh está na URL ou no servidor, não num serviço em memória.

Abra agora a página mais usada do seu Blazor Auto, limpe o cache e conte as requisições ao banco no primeiro carregamento: o mesmo dado está sendo buscado uma vez ou duas?

Avalie este artigo

Referência: https://www.telerik.com/blogs/fetching-sharing-data-between-components-blazor-auto-render-mode