eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsServidor
Integração com o servidor
Editar esta página
32 min de leitura
O eQuantic.UI se integra ao ASP.NET Core por uma API fluente para registro de serviços, configuração de middleware e personalização do shell HTML.
Começo rápido
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options =>
{
options.ScanAssembly(typeof(Program).Assembly)
.WithSsr()
.UseTheme(PhotonTheme.Instance) // Tema write-once
.UseChartJs() // Gráficos
.UseApexCharts() // Gráficos
.ConfigureHtmlShell(shell =>
{
shell.SetTitle("My App")
.SetHtmlClass("dark")
.AddHeadTag("<meta name=\"theme-color\" content=\"#3b82f6\">");
});
});
var app = builder.Build();
app.UseStaticFiles();
app.UseServerActions(); // Middleware dos Server Actions
app.MapUI(); // Roteamento SPA e endpoints dos pacotes
app.Run();
AddUI
O AddUI() é o ponto de entrada principal, que registra todos os serviços centrais:
1
builder.Services.AddUI(options => { ... });
O que ele registra:
•
UIOptions (singleton) - a configuração
•
IServerActionRegistry - varre os assemblies procurando métodos [ServerAction]
•
IServerActionAuthorizationService - autorização dos server actions
•
IServerRenderingService - o motor de renderização do SSR
•
IAppTheme - o tema write-once selecionado (UseTheme(...); PhotonTheme por padrão, veja DesignSystem)
•
IComponentAssetProvider<T> - varrido automaticamente dos assemblies (veja Assets)
•
IThemeController - a mão do claro/escuro durante o SSR (UseInitialThemeMode(...)); a do browser assume na hidratação
•
IServerEvents - uma capacidade que nunca conecta enquanto o servidor renderiza; o UseServerEvents(...) acrescenta os endpoints que a conexão de uma página usa (veja Eventos do servidor)
UIOptions
A API fluente de configuração do framework de interface.
ScanAssembly
Varre um assembly procurando componentes [Page], métodos [ServerAction] e implementações de IComponentAssetProvider<T>.
1
options.ScanAssembly(typeof(Program).Assembly);
Vários assemblies podem ser varridos:
1
2
options.ScanAssembly(typeof(Program).Assembly)
.ScanAssembly(typeof(SharedComponents).Assembly);
WithSsr
Liga ou desliga a renderização no servidor globalmente. O padrão é true.
1
2
options.WithSsr(); // Liga (padrão)
options.WithSsr(false); // Desliga
Desde 0.2.0-preview.1
UseInitialThemeMode
O modo claro/escuro em que o servidor renderiza, que é o que o browser pinta antes de qualquer JavaScript rodar. Light se não for definido.
1
options.UseInitialThemeMode(ThemeMode.Dark);
Ele é um PADRÃO, não um valor fixo: um visitante que alternou o tema carrega um cookie (eq-theme), e ele vence. O controller do próprio browser o escreve a partir do document.cookie, o que não custa nada e não precisa de ida e volta por alternância, e o servidor o lê, que é a razão inteira de ser um cookie e não localStorage: o requisito não é "lembrar" e sim contar ao servidor. Lembrar no localStorage funciona perfeitamente e o servidor não enxerga uma palavra disso, então a página chegaria no modo padrão e seria corrigida na hidratação, que é justamente o piscar que isto remove.
Um valor de cookie não reconhecido é ignorado em vez de confiado: ele é texto fornecido pelo usuário, e essa pergunta tem exatamente duas respostas.
Desde 0.2.0-preview.11
O cookie de tema é configurável, inclusive desligado
1
2
options.UseThemeCookie(name: "acme-theme", days: 30); // renomear / encurtar
options.WithoutThemeCookie(); // nunca escrever um
É UMA configuração porque as duas metades têm que concordar: o controller do browser escreve esse cookie e o servidor o lê. Configure separadamente e eles se afastam, ponto em que o servidor lê um nome que ninguém escreve: a persistência para de funcionar enquanto cada parte dela continua parecendo correta. A configuração atravessa para o browser na configuração da própria página exatamente por isso.
Vale renomear quando dois apps eQuantic dividem um domínio e não deveriam herdar o tema um do outro, ou quando um site já tem uma convenção de cookies.
O WithoutThemeCookie() e o consentimento. Se um cookie de preferência precisa de consentimento sob o GDPR ou a LGPD depende da sua jurisdição e da sua própria avaliação. O framework não decide isso por você, ele te dá o interruptor. Com ele desligado nada é escrito e a alternância continua funcionando: o modo se aplica à página na frente do visitante, ele simplesmente não sobrevive a ela, então toda visita começa do UseInitialThemeMode ou do SO.
Note a cerca: este é um interruptor de tempo de build. Um app que quer começar a persistir no momento em que um visitante aceita um banner precisa da mão dele na escrita: o framework ainda não expõe um gancho de consentimento em tempo de execução.
Isto existe para que nada tenha que adivinhar. Um componente que oferece alternância de tema resolve IThemeController; no browser esse é o controller que carimba data-theme, mas durante o SSR não há browser, e um componente que resolvia nada tinha que assumir um modo, uma suposição que decide o markup que o leitor vê primeiro, então adivinhar errado significa que o primeiro paint está no tema errado e a hidratação corrige na frente dele.
O QUE A ALTERNÂNCIA LÊ IMPORTA TANTO QUANTO O QUE ELA ESCREVE Uma alternância pergunta ao controller o modo atual e aplica o outro, então um controller que reporta o modo errado aplica o modo em que a página já está: o primeiro clique não faz nada e o visitante clica duas vezes. Por isso o controller do browser lê, nesta ordem: o estilo inline dele (uma escolha viva), depois o color-scheme computado (que é como um modo declarado pelo servidor chega, como regra de folha de estilo que nunca aparece em element.style), depois o SO. Desde 0.2.0-preview.12
Aplicar um modo no servidor é deliberadamente inerte, e o modo é lido por requisição em vez de capturado: o controller é um singleton, então um valor capturado entregaria a escolha de um visitante ao primeiro paint do visitante seguinte. Um app que quer memória por requisição (um cookie, um cabeçalho) registra o próprio IThemeController, e este é registrado com TryAdd, então o seu vence.
Páginas individuais podem sair fora:
1
2
[Page("/interactive", DisableSsr = true)]
public class InteractivePage : StatefulComponent { }
WithAssetProvider\<T\>
Registra explicitamente uma implementação de IComponentAssetProvider<T>. Útil para provedores de assemblies externos que o ScanAssembly não cobre.
1
options.WithAssetProvider<ChartJsAssetProvider>();
Veja Assets para os detalhes do sistema de provedores de asset.
ConfigureHtmlShell
Configura o template HTML que embrulha todas as páginas.
1
2
3
4
5
6
7
8
options.ConfigureHtmlShell(shell =>
{
shell.SetTitle("My App")
.SetHtmlClass("dark")
.SetBaseStyles("body { margin: 0; }")
.AddHeadTag("<link rel=\"icon\" href=\"/favicon.ico\">")
.AddHeadTag("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
});
O header de proveniência
Desde 0.2.0-preview.29
Toda resposta que o app envia (páginas, Server Actions, bundles estáticos, até o 404) carrega x-powered-by: eQuantic.UI. Ele se instala sozinho: o AddUI registra um startup filter, então nenhum Program.cs o menciona. O valor é só o nome (versão em header de resposta é presente para scanner de vulnerabilidade), e ele nunca sobrescreve um x-powered-by que outra coisa já pôs.
1
2
// Para o app cujo checklist de hardening reprova qualquer x-powered-by:
options.WithoutPoweredByHeader();
Propriedades
Propriedade
Tipo
Padrão
Descrição
EnableSsr
bool
true
Interruptor global do SSR
HtmlShell
HtmlShellOptions
-
Configuração do template HTML
HtmlShellOptions
Método
Descrição
SetTitle(string)
O <title> da página
SetHtmlClass(string)
Classe no elemento <html> (ex.: "dark")
SetBaseStyles(string)
O bloco <style> base
AddHeadTag(string)
Tag HTML crua injetada no <head>
O SetBaseStyles substitui só os padrões COSMÉTICOS. A invariante estrutural (#app como quadro determinado: height: 100dvh; display: grid, filhos min-height: 0, o espelho web da janela nativa) é emitida pelo próprio template do shell, antes dos estilos do app. Uma página de APP (raiz Height = Fill) recebe exatamente um viewport e rola internamente; uma página de DOCUMENTO (raiz de altura automática) transborda o quadro e o body rola como sempre rolou. Um app ainda pode sobrepor a regra deliberadamente; ele não consegue apagá-la sem querer.
MapPage: uma rota declarada ao lado de todo outro endpoint
Desde 0.2.0-preview.13
1
2
app.MapPage<HomePage>("/");
app.MapPage<DocPage>("/docs/{slug}", title: "Docs");
O atributo [Page("/route")] fica, e para uma página cuja rota faz parte do que ela É (um 404, um login) ele continua sendo a melhor resposta. O MapPage<T> é para o resto: rotas que um app quer ler num lugar só, rotas que diferem entre hosts, uma página montada num caminho que o arquivo dela não tem por que conhecer. Ele também é o único jeito de rotear uma página de um assembly que não é seu.
Declare junto de onde todo outro endpoint é declarado, antes do app.Run(). A rota se registra nos três lugares em que uma rota tem que existir (a tabela de endpoints, o índice de páginas do SSR, e a tabela do cliente para a navegação SPA), então nada rio abaixo consegue distinguir os dois jeitos de declarar uma rota. Uma rota que só se registra pela metade é pior que nenhuma: a página serve, e aí o primeiro link no cliente para ela recarrega o documento inteiro sem razão visível.
Um tipo que não é componente lança no arranque se nomeando, em vez de na primeira requisição a uma rota que ninguém consegue servir.
Desde 0.2.0-preview.60
Uma rota renderiza a sua página pelo TIPO da página, seja como for que a rota foi declarada, então duas páginas com um mesmo nome em dois namespaces respondem cada uma na sua rota: o Dashboard do app e um que uma biblioteca roteia, por exemplo. O servidor guardava as páginas pelo nome simples, e a página registrada por último respondia nas duas rotas, sem erro. Dentro de um projeto o build ainda recusa dois tipos de um mesmo nome (EQ1005), já que cada um vira um módulo com o nome dele.
Uma página responde a HEAD como responde a GET
Desde 0.2.0-preview.61
Toda rota de página, um [Page] ou um MapPage<T>, e os módulos sob /_equantic/ respondem a um HEAD como respondem a um GET: o mesmo status e os mesmos headers, e nenhum corpo (RFC 9110, seção 9.3.2). Um HEAD caía no fallback e respondia 404, então um monitor de disponibilidade, um verificador de links ou um crawler que pergunta com HEAD lia toda página como inexistente.
Uma página é construída a partir dos serviços da REQUISIÇÃO
Desde 0.2.0-preview.13
1
2
3
builder.Services.AddScoped<IOrders, Orders>(); // um DbContext, uma unidade de trabalho, o tenant atual
public sealed class OrdersPage(IOrders orders) : StatelessComponent { … }
As páginas e os server actions são construídos a partir do context.RequestServices. Isto é uma correção, não uma feature: os dois construíam do container raiz da aplicação, e o .NET recusa entregar um serviço scoped a partir da raiz por design, porque um serviço scoped resolvido ali sobrevive à requisição e passa a ser compartilhado por todas as seguintes.
O caminho dos server actions é onde isso mais doía, já que é exatamente ali que as coisas scoped vivem. Aparecia como um 500 dizendo "An error occurred while processing the request."
Invisível até se pedir ao container que confira: um container construído com as opções padrão entrega serviços scoped a partir da raiz muito alegremente, e o ASP.NET Core só valida em Development.
Uma página cujo próprio construtor lança agora diz isso. Essa falha era engolida e a página silenciosamente reconstruída sem nada injetado: ela renderizava o estado vazio dela como se não tivesse pedido nada, a dependência era nula, e a exceção que explicava isso tinha sumido.
Middleware
UseServerActions
Registra o middleware dos Server Actions para tratar chamadas RPC vindas do browser.
1
app.UseServerActions();
Server Actions são métodos marcados com [ServerAction] que executam no servidor e devolvem resultados ao cliente:
1
2
3
4
5
6
[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
}
MapUI
Mapeia o roteamento SPA: toda rota [Page] ganha um endpoint, e um fallback serve o shell HTML para todo o resto.
1
app.MapUI();
No cliente, a navegação é um roteador SPA completo: navegação sem recarga, parâmetros de rota tipados, layout persistente por reconciliação na navegação, guardas, prefetch e restauração de rolagem, verificados de ponta a ponta pela suíte do Playwright.
O fallback responde a uma rota desconhecida com um HTTP 404 de verdade, nunca um 200 que só parece um, para que crawlers e monitores aprendam a verdade. O que renderiza com esse status:
•
A página 404 do próprio app, quando declarada. Roteie uma página write-once comum em "/404" e ela vira a página de não encontrado: renderizada no servidor e montada no cliente para toda URL desconhecida, com o tema do app, transpilada para o bundle do próprio app como qualquer página:
1
2
3
4
5
[Page("/404", Title = "Not found | My App")]
public sealed class NotFoundScreen : StatelessComponent
{
public override VisualNode Build(ComponentContext context) => /* qualquer página */;
}
Uma página roteada em "/500" é registrada do mesmo jeito: em produção, quando o SSR da página pedida falha, aquela página renderiza com status 500.
•
Uma embutida e estilizada, caso contrário. O runtime pinta uma página de não encontrado mínima e ciente do tema (tokens via var(--eq-color-*), fallback claro/escuro do SO quando o app não selecionou tema). O mesmo renderizador compartilhado dá suporte à página de erro de boot e à tela de boas-vindas sem páginas, e nenhuma delas depende de folha de estilo nenhuma existir.
Navegar direto para /404 cai numa página mapeada e responde 200; só o fallback fala 404.
RegisterServices / RegisterEndpoints
A costura pela qual um PACOTE estende o app, que é como o UseChartJs() e companhia são construídos, em vez de algo que um app normalmente chama:
1
2
options.RegisterServices(services => services.AddSingleton<IMyThing, MyThing>());
options.RegisterEndpoints(endpoints => endpoints.MapGet("/_mine/thing.js", …));
O primeiro roda dentro do AddUI(), o segundo dentro do MapUI(), então um pacote publica um método de extensão e um app ganha os serviços e as rotas dele numa linha só.
Estilo e ícones não precisam de registro
Não há UseTailwind() nem UseLucideIcons(). Os dois já existiram e sumiram, e a razão vale conhecer em vez de adivinhar:
•
O estilo é um motor só agora: C# tipado rebaixado para classes atômicas deduplicadas, descrito em Estilo. Nada é registrado e nenhuma folha de estilo utilitária é buscada. Qualquer CSS externo que você traga é assunto do build do seu app.
•
Os ícones são catálogos, não provedores: você nomeia o glifo (Glyph(LucideIcons.Search)) e o compilador embute aquele. Não há registro para acrescentar e nenhum nome para resolver em tempo de execução. Veja Ícones.
UseServerEvents
Deixa um componente ouvir o que o servidor publica num tópico tipado, por uma conexão por página, e diz quem pode ouvir cada tópico. O MapUI() mapeia os endpoints. Veja Eventos do servidor.
1
2
3
4
5
6
builder.Services.AddUI(options =>
{
options.UseServerEvents(events => events
.Topic("prices", rule => rule.AllowAnonymous())
.Topic("room:{roomId}", rule => rule.RequireAuthorization("RoomMember")));
});
Desde 0.2.0-preview.61
UseChartJs / UseApexCharts
Habilita os serviços e os endpoints de script de CDN das bibliotecas de gráfico.
1
2
3
4
5
builder.Services.AddUI(options =>
{
options.UseChartJs()
.UseApexCharts();
});
Hot reload (web)
Ligado em Development e sob o dotnet watch, qualquer que seja o ambiente (um app rodado sem perfil de lançamento é um app de Production); options.HotReload com true ou false decide no lugar. O servidor observa os *.cs do app, roda de novo o próprio target eqc do SDK ao salvar, e manda todo browser conectado atualizar por SSE (/_equantic/hmr). A página escuta exatamente quando o servidor dela transmite: o servidor diz isso na configuração da página. ~5s de salvar até os pixels com um MSBuild quente.
Comportamentos que tornam o circuito confiável:
•
Um reload disparado pelo hot reload MONTA (renderiza no cliente com o código novo) em vez de hidratar, porque o SSR ainda vem do assembly em execução do servidor, então adotar o DOM velho mostraria os pixels velhos.
•
A página mantém o estado: os campos que o C# dela declara atravessam o reload e chegam à página nova antes de ela construir, cada um exatamente como o tipo que era em qualquer profundidade (um long numa lista, as chaves e os valores de um dicionário, um Point, os stores de um record), reconstruído como o estado que vem do servidor, e só num campo que a página nova ainda tipa do mesmo jeito. O payload do servidor para a página nunca sobrescreve um campo que o reload decidiu. Um campo que a edição removeu fica para trás, e um que ela adicionou mantém o inicializador. O mesmo vale para um campo que o reload não consegue devolver exatamente: um que guarda um NaN ou um zero negativo, um controlador ou um serviço, um dicionário ordenado, uma lista de records do próprio app, ou um tipo que a edição mudou. Uma página que faz prefetch declara ao browser os tipos dos seus campos; em qualquer outra página, um long, uma data, um dicionário ou um record só entra num campo cujo inicializador é vazio ou nulo onde a página o declara, já que nada mais diz que o código editado ainda o espera.
•
O canal SSE manda um comentário : ping a cada 20s para que proxies que derrubam conexões ociosas e o Kestrel mantenham a requisição estacionada viva; a reconexão do próprio EventSource do browser trata quedas transitórias.
•
A reconstrução lê os pipes de saída dela concorrentemente e loga a duração.
•
O eqc reescreve a saída no lugar, e remove o que deixou de escrever só depois de escrever o resto, e wwwroot/_equantic é declarada como saída de build (DefaultItemExcludes), que o dotnet watch ignora: uma reconstrução nem deixa a página sem os módulos, nem adiciona um arquivo em que o dotnet watch pararia.
•
Parar o app para uma reconstrução ainda em curso e encerra todo stream, então um reinício (o que o dotnet watch faz depois de uma edição que o hot reload não consegue aplicar) nem deixa o eqc escrevendo ao lado do build seguinte, nem espera por uma aba aberta.
Escopo: este pipeline atualiza o CLIENTE, enquanto o C# do próprio servidor (server actions, corpos de SSR) roda o assembly carregado. Para edições no servidor, rode sob dotnet watch run: o hot reload do .NET remenda o servidor em execução no processo, e este pipeline continua tratando a metade cliente.
O overlay de erro: uma pilha em C#, porque quem desenvolveu escreveu C#
Um erro não capturado em desenvolvimento levanta o modal no estilo do Next.js (mensagem, quadro de código, pilha de chamadas), exceto que a pilha que ele mostra é C#: Screens/PaymentsPage.cs:441, com as linhas do arquivo C# que falharam renderizadas e realçadas. Uma pilha de JS minificado é ruído de uma máquina que quem desenvolve nunca pediu para conhecer.
Como funciona: o .js.map do próprio bundle, que um build Debug escreve, cai no C#: arquivo, linha, e o próprio texto do código, embutido no mapa. O Bun mapeia o JavaScript só até o intermediário TS, então o eqc compõe esse mapa com o seu próprio .ts.map (do TypeScript ao C#) durante o build, em C#, sem instalar nada. Um mapa que o eqc não conseguiu compor fica como o bun o escreveu, levando ao intermediário, e o eqc o reporta no log do build; para esse caso o browser dá um segundo salto pelo .ts.map ao lado do intermediário, servido em desenvolvimento em /_equantic/src-map/{name} e respondendo 404 em produção. Um frame que não mapeia para lugar nenhum fica rotulado (js), e um que só chega ao intermediário fica com a resposta em TypeScript, porque uma afirmação verdadeira sobre onde o mapeamento parou vence uma linha C# adivinhada.
A precisão é no nível de INSTRUÇÃO: o frame cai na instrução de C# que lançou ou que chamou, o bloco de uma lambda incluído (a página Depuração diz o que ainda aponta para a instrução ao redor).
Renderização no servidor (SSR)
Quando o SSR está ligado, o framework:
1.
Encontra o componente [Page] que casa com a rota
2.
Cria a instância do componente (com suporte a DI)
3.
Coleta as dependências de asset (veja Assets)
4.
Coleta os metadados de SEO (veja abaixo)
5.
Renderiza a árvore de componentes para HTML
6.
Serializa o estado para a hidratação no cliente
7.
Serve a página HTML completa
Desde 0.2.0-preview.1
Dados do servidor no primeiro render (IServerPrefetch)
Uma página declara os dados de que precisa, o pipeline de SSR os aguarda antes de construir a árvore, e os valores viajam ao browser para que a hidratação veja exatamente o que o servidor renderizou. O markup carrega números reais para os crawlers, e o cliente nunca pisca um estado vazio virando um preenchido.
1
2
3
4
5
6
7
8
9
10
public sealed class HomePage : StatelessComponent, IServerPrefetch
{
private PackageStats _stats = PackageStats.Empty;
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _stats = await services.GetRequiredService<IPackageStats>().LoadAsync(cancellationToken);
public override VisualNode Build(ComponentContext context) => new HeroSection(_stats);
}
Três coisas decidem se isso funciona:
•
O [ServerOnly] mantém a implementação fora do bundle do cliente, então ela pode usar a superfície inteira do servidor: HttpClient, EF, os serviços da própria requisição. Numa classe diz o mesmo do tipo inteiro — um serviço de compilação Roslyn, um warm-up hospedado, um repositório que vive no projeto web: nenhum módulo é emitido para ela, então suas chamadas server-only nunca chegam ao EQ2004 (0.2.0-preview.46). Um componente, ou uma classe que o código do cliente instancia, não deve carregá-lo.
•
Guarde o resultado onde a página o lê. Um campo, uma auto-property e um parâmetro do construtor primário que o prefetch reescreve atravessam todos, cada um sob o nome pelo qual o código do browser o lê (abaixo).
•
Ele roda uma vez por requisição, antes do primeiro build, que é o que o diferencia de carregar num handler e chamar SetState.
Hosts nativos renderizam localmente e não fazem prefetch nenhum: um shell Photon carrega os mesmos dados antes de construir a árvore, como uma chamada explícita.
O que atravessa, e sob que nome
Desde 0.2.0-preview.60
O build anota o que um componente com prefetch leva ao browser. Um source generator, que o SDK adiciona a todo app, lê cada componente como o C# o declara e escreve um manifesto de hidratação no próprio assembly do app: uma entrada por valor, com o membro nomeado como o C# o declara. O servidor escreve a carga só a partir dessas entradas, e o eqc escreve a adoção do gêmeo a partir das mesmas, então cada valor atravessa sob o nome com que o gêmeo o declara, e as duas metades não têm como discordar.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public sealed class StatsPage : StatelessComponent, IServerPrefetch
{
public long Downloads { get; private set; }
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> Downloads = await services.GetRequiredService<IPackageStats>().CountAsync(cancellationToken);
public override VisualNode Build(ComponentContext context) =>
Column(children: [Text($"{Downloads} downloads"), Quote("acme")]);
}
public sealed class Quote(string symbol) : StatelessComponent, IServerPrefetch
{
[ServerOnly]
public Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
{
symbol = symbol.ToUpperInvariant();
return Task.CompletedTask;
}
public override VisualNode Build(ComponentContext context) => Text(symbol);
}
Downloads atravessa como downloads, e symbol como symbol, para dentro de um Quote que o browser constrói com "acme" exatamente como o servidor construiu: o que ele desenha depois da hidratação é ACME, o valor que o prefetch deixou. Um delegate não atravessa, nem uma dependência que o browser resolve sozinho (veja abaixo). Uma propriedade com corpo próprio não guarda nada, então o browser a calcula de novo a partir dos valores que atravessaram.
Antes da 0.2.0-preview.60 só um campo atravessava. O servidor nomeava uma auto-property pelo campo que o compilador C# sintetiza para ela, e um parâmetro capturado pelo seu armazenamento, nomes que nenhum gêmeo declara: os dois valores desenhavam no servidor e o primeiro build do browser desenhava os defaults, o que aparecia depois de uma navegação no cliente ou no primeiro re-render. Uma página que passou os resultados para campos para contornar isso pode desfazer a mudança.
Todo campo é público
Desde 0.2.0-preview.43
A carga é escrita dentro do HTML servido. Um valor que o prefetch guarda é legível por qualquer um que veja a fonte da página — ele deixa de ser "dado do servidor" no instante em que cai num campo.
Carregue o que a página mostra, e nada mais. O token de acesso usado para buscar, a connection string por trás da consulta, o id interno de que você só precisou enquanto carregava: nada disso pertence a um campo.
Um segredo não pertence a nenhum dos dois lugares. Um [ServerAction] roda no servidor e pode usar um — ler um token, abrir uma conexão, chamar uma API com ele — mas o valor de retorno dele é serializado para o browser exatamente como um campo é, então devolva a resposta e nunca o segredo que a produziu. A regra é a mesma dos dois lados: o que atravessa é o que a página pode mostrar.
O que não viaja é uma dependência: um campo cujo tipo é uma interface de fora de System, que o cliente resolve por conta própria — a mesma regra que o compilador aplica ao decidir que um parâmetro de construtor é uma capacidade e não um valor. A exclusão de System é deliberada e não é um detalhe: IReadOnlyList<T> é como um componente recebe seus itens, então pular toda interface apagaria estado em vez de proteger qualquer coisa.
Todo o resto que pode ser escrito viaja, inclusive uma string que ninguém quis publicar, menos um serviço que o container entrega a uma página, que atravessa como o que a página lê dele (abaixo). Um delegate e um valor que falha ao serializar ficam de fora — mas leia isso pelo que é, uma regra de robustez para que um campo ruim não esvazie a carga inteira. Não é proteção: nunca conte com um valor ser inserializável para mantê-lo fora da página.
Um serviço atravessa como o que a página lê dele
Desde 0.2.0-preview.60
Uma página é construída com os serviços da requisição, e o browser a constrói sem nenhum: o router constrói uma página sem argumentos. Então um serviço que o container entrega a uma página como uma classe nunca atravessa inteiro. Ele atravessa como o que o código da página que roda no browser lê dele: se é nulo, e os membros que lê, até chegar a um escalar (um número, um texto, uma data, um enum, um GUID) ou a uma lista, um dicionário, um conjunto, um conjunto ordenado, uma fila, uma pilha ou uma lista encadeada de escalares, que atravessam inteiros e chegam como as coleções do próprio browser. Um struct e um par também são lidos membro a membro, porque o que o serializador do servidor escreve deles não é o que o código do browser lê. Uma coleção ordenada chega na ordem do tipo do elemento, como o comparador padrão do .NET a ordena: um texto na cultura atual, um decimal ou uma data pelo valor. E um conjunto ou um dicionário que a cópia do browser responderia de outro jeito não atravessa: um com um comparador próprio (um conjunto que ignora maiúsculas, lido também através de um ReadOnlyDictionary ou de um ImmutableHashSet), um ordenado por qualquer comparador que não o padrão do tipo do elemento, e um conjunto de uma classe sua, cuja igualdade nada consegue ler. O servidor deixa essa leitura de fora, mantém o resto do valor e registra no log.
1
2
3
4
5
6
7
[Page("/login")]
public sealed class LoginPage(SiteIdentity? identity) : StatelessComponent
{
public override VisualNode Build(ComponentContext context) =>
Link(identity is null ? "/signin" : "/account",
Text(identity is null ? "Sign in" : $"Signed in as {identity.DisplayName}"));
}
identity atravessa como { "displayName": "Ada" }, ou como null: o browser desenha o ramo que o servidor desenhou, e a Authority da identidade, que a página nunca lê, não está em lugar nenhum do HTML. O build segue o valor por um teste de nulo, uma leitura de membro, um pattern, uma variável local, um campo onde a página o guarda, um membro que o devolve, e um componente ou um método do app que o recebe, cujas leituras se juntam às da página. Ele lê só os membros que rodam no browser, então um valor que só o PrefetchAsync ou outro membro [ServerOnly] lê não atravessa nada.
Qualquer outro uso falha o build com EQ2114, com o nome da página, do valor e da expressão: um método chamado nele, o valor passado onde o build não tem o fonte, convertido em texto, comparado, enumerado, guardado numa coleção, lido através de um membro que um tipo do .NET declara num valor que não atravessa inteiro (o Count de uma lista ou de um conjunto de objetos, o Value de um par, que o browser responde pela sua própria forma do tipo), lido através de um tipo cujo membro com esse nome o tipo do próprio valor esconde (new), ou lido enquanto a página é construída, antes de o browser o ter. Decida no servidor, no PrefetchAsync ou num membro [ServerOnly], e guarde o resultado num campo; ou chame uma [ServerAction] quando o estado do browser é uma entrada.
O container também tem a última palavra. Um valor escrito inteiro que ele entrega como serviço, registrado com o próprio tipo ou como uma interface ou um tipo base de que o tipo do valor deriva, fica fora da página, e o log diz qual membro o guardava e como que serviço está registrado: um campo do tipo object é o único lugar onde o build não consegue ver o que um valor é.
Antes da 0.2.0-preview.60 um serviço assim atravessava inteiro para o HTML de uma página que fazia prefetch, e a página de uma que não fazia não levava nada, então o browser desenhava o outro ramo.
…e numa NAVEGAÇÃO do cliente
Desde 0.2.0-preview.21
Um link dentro de um app já iniciado nunca chega ao servidor, então por um tempo ele trocava o componente e mais nada: o prefetch não rodava, e toda página navegada renderizava o estado vazio que ela foi escrita para mostrar enquanto os dados carregam, sem nada carregando. O head mantinha o título e o canônico do documento anterior, o que para um crawler é uma página afirmando que duas URLs são o mesmo documento.
O roteador agora pede os dados da página à própria rota de destino, carregando um cabeçalho:
1
2
GET /docs/Photon X-EQ-Navigate: 1
→ { "title": "Photon | …", "head": "<link rel=canonical …>", "state": { … } }
Ir à rota em vez de a um endpoint lateral é a parte que sustenta tudo: os parâmetros de rota, a query e a resolução da página são os que uma carga completa teria, porque É a mesma rota. Um endpoint lateral recebendo um caminho teria que reimplementar os três.
O estado chega pela mesma porta que a carga do SSR usa, então os valores que um IServerPrefetch carregou já estão no lugar antes do primeiro build. O head é remendado por identidade, no atributo que nomeia uma tag (name, property, rel), nunca acrescentando ao fim, ou o canônico da página anterior sobreviveria ao lado do novo. Uma falha não é fatal: a página então renderiza exatamente o que renderizava antes disto existir.
Ele não desenha. O PreparePageAsync roda o mesmo código que o shell roda menos o markup: uma navegação de cliente já tem o componente e constrói a árvore sozinha, então HTML renderizado aqui seria HTML jogado fora. Acessível uma vez por navegação, e nem um pouco uma vez por link sob o ponteiro, que é sobre o que a próxima seção fala.
Um link sob o ponteiro chega quente
Desde 0.2.0-preview.23
Apontar para um link do app aquece as duas metades da navegação que ele sugere: o bundle da página, e a carga acima. O clique que segue faz nenhuma requisição.
Medido no próprio site desta wiki, num documento grande: 172 ms → 28 ms até o primeiro remendo no DOM.
Três coisas tinham que ser verdade, e duas delas eram silenciosamente falsas por muito tempo:
•
Um link tem que convidar. O roteador aquece rotas no hover desde que foi escrito, controlado por data-prefetch, e nada no framework jamais marcava um link: código morto, e toda navegação pagava por tudo na hora do clique. Destinos internos ao app o carregam agora; uma URL absoluta é o servidor de outra pessoa e não carrega.
•
A resposta aquecida tem que ser ENCONTRADA. Medido primeiro, e a navegação aquecida saiu *mais lenta* que a fria: o hover guardava a carga sob uma string e o clique a procurava com um objeto URL. Um cache em que ninguém acerta é pior que nenhum cache, porque ele custa a requisição que estava lá para poupar, e parece estar funcionando.
•
O roteador pergunta uma vez por link e engole as falhas, então o pior caso é trabalho que o clique ia fazer de qualquer forma, feito um pouco antes.
O que sobra é o build da própria página. Num documento grande isso é a maior parte do tempo, e nenhuma alavanca do framework o encurta: o conteúdo decide.
Uma página que não achou nada (IHandleStatus)
Desde 0.2.0-preview.13
Uma rota como /docs/{slug} casa com todo slug, incluindo os que não nomeiam documento nenhum. A página renderiza "não encontrado", e sem isto o servidor ainda responde 200 OK, então o leitor vê a coisa certa enquanto toda máquina ouve a errada. Um crawler indexa a página vazia, um verificador de links chama o site de saudável, e uma sonda de disponibilidade nunca percebe. A falha é invisível justamente para as coisas cujo trabalho é perceber.
1
2
3
4
5
6
7
8
9
10
public sealed class DocPage : StatelessComponent, IServerPrefetch, IHandleStatus
{
private Doc? _doc;
[ServerOnly]
public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
=> _doc = await services.GetRequiredService<IDocs>().FindAsync(Slug, cancellationToken);
public int StatusCode => _doc is null ? 404 : 200;
}
Lido depois do prefetch, porque "isto existe" é algo que uma página normalmente aprende carregando. Uma página que não o implementa responde 200, então nada escrito antes disto muda. Hosts nativos não têm status para responder e o ignoram; a árvore é a mesma de qualquer jeito.
SEO e metadados
Os componentes implementam IHandleMetadata para SEO dinâmico:
1
2
3
4
5
6
7
8
9
10
11
public class BlogPost : StatelessComponent, IHandleMetadata
{
public void ConfigureMetadata(SeoBuilder seo)
{
seo.Title("Blog Post Title")
.Description("A summary of the post...")
.Canonical("https://example.com/blog/post")
.OpenGraph("type", "article")
.Twitter("card", "summary_large_image");
}
}
Métodos do SeoBuilder:
Método
Descrição
Title(string)
Título da página
Description(string)
Meta description
Canonical(string)
URL canônica
Alternate(string, string)
A URL de um idioma no grupo de tradução (hreflang). Escrever o mesmo idioma duas vezes o SUBSTITUI, então uma página sobrepõe a política global só para aquele idioma
AlternateDefault(string)
A URL do x-default: onde cai o visitante cujo idioma não casou com nada
Image(string, string?)
A imagem de compartilhamento. Escreve og:image E twitter:image, mais as duas variantes :alt quando você passa uma. Esquecer a metade do Twitter é por que um cartão aparece em branco em metade dos lugares em que é colado
Keywords(string)
Meta keywords
Robots(bool, bool)
Diretivas de index/follow
OpenGraph(string, string)
Propriedade OG
Twitter(string, string)
Propriedade de card do Twitter
Desde 0.2.0-preview.1
Padrões para o app inteiro, e uma página sobrepondo-os
O shell declara o que toda página deve dizer a menos que ela diga o contrário; o ConfigureMetadata da própria página o sobrepõe por chave, para que os dois nunca apareçam juntos.
1
2
3
4
5
6
builder.Services.AddUI(options => options
.ConfigureHtmlShell(shell => shell
.SetTitle("Acme")
.ConfigureMetadata(seo => seo
.Image("https://acme.test/og-default.png", "Acme")
.Twitter("card", "summary_large_image"))));
Uma página então redeclara só o que difere, e o tipo de cartão e a imagem de fallback acima sobrevivem intocados:
1
2
3
4
5
public void ConfigureMetadata(SeoBuilder seo) =>
seo.Title("Playground")
.Description("Write a component in C#, press Run…")
.Canonical("https://acme.test/playground")
.Image("https://acme.test/og-playground.png");
Vale declarar isso com clareza porque antes não funcionava. O AddDescription escrevia HTML cru no head, e HTML cru não compartilha chave com nada, então um app com uma descrição global e uma página com a dela publicavam duas <meta name="description">, e nenhuma página conseguia vencer. A única saída era deixar a global vazia, o que a tornava inútil para a única coisa para a qual uma global serve. Os metadados do shell agora semeiam a mesma coleção em que a página escreve.
O AddHeadTag continua sendo a saída de emergência para markup genuinamente cru (um <link rel="icon">, um bloco JSON-LD). Qualquer coisa com uma chave de metadado pertence ao ConfigureMetadata, ou não pode ser sobreposta.
O título e a descrição de uma rota
Uma rota declara o título e a descrição do documento onde ela é declarada, e uma página que sabe mais diz isso no ConfigureMetadata. A ordem é: as palavras da própria página, depois as da rota, depois as do app, a mesma num carregamento completo e numa navegação no cliente (Desde 0.2.0-preview.60):
1
2
3
4
5
[Page("/orders", Title = "Orders | Acme", Description = "Every order, newest first")]
[Page("/orders/archive", Title = "Archive | Acme")]
public sealed class OrdersPage : StatelessComponent { … }
app.MapPage<ReportPage>("/report", title: "Report | Acme");
Cada rota dá o seu próprio título, então uma página declarada em duas rotas leva o título daquela por onde foi alcançada. Antes desta versão o título da rota nunca chegava ao documento: ele só valia quando nada tinha definido um, e o SetTitle do app sempre tinha.
Uma navegação no cliente substitui os metadados do head como um conjunto: toda tag que os metadados escrevem é marcada, e o conjunto da próxima página toma o lugar do da anterior, o grupo de traduções incluído, então uma descrição ou um canonical que a próxima página não tem não fica para trás. Uma página que o servidor não renderiza continua respondendo o título e a descrição da rota numa navegação, e o mesmo vale para uma página que responde com um status próprio (um 404 via IHandleStatus para um conteúdo que não existe).
hreflang: uma política, todas as páginas
Desde 0.2.0-preview.31
Um site localizado que nunca diz qual URL é qual idioma é um site que o buscador indexa como páginas duplicadas umas das outras, e ele nunca reclama: as páginas competem entre si e a tradução que ninguém pediu é a que aparece. A correção é rel="alternate" hreflang, e ela é um fato do APP inteiro, não de cada página, então o app declara a política de URL uma vez:
1
2
builder.Services.AddUI(options => options
.UseAlternateLinks(AlternateUrls.PathPrefix(), "en", "pt-BR", "es"));
Toda página passa a carregar o grupo inteiro:
1
2
3
4
<link rel="alternate" hreflang="en" href="https://acme.test/en/pricing">
<link rel="alternate" hreflang="pt-BR" href="https://acme.test/pt-BR/pricing">
<link rel="alternate" hreflang="es" href="https://acme.test/es/pricing">
<link rel="alternate" hreflang="x-default" href="https://acme.test/en/pricing">
As três regras que o padrão impõe são garantidas aqui, e não deixadas para o app, porque cada uma delas falha em silêncio quando é quebrada:
•
O conjunto inclui a PRÓPRIA página. Um grupo que omite a página atual é descartado por inteiro, então uma página sempre anuncia também o idioma dela, e toda tradução carrega o mesmo conjunto.
•
Toda URL é absoluta. Um hreflang relativo parece certo no markup e é descartado por todo buscador, então uma política que responde /es/pricing recebe o esquema e o host do request.
•
O x-default diz onde cai quem não casou com nada. Ele segue a cultura padrão do próprio app quando o middleware a compartilhou, e o primeiro idioma nomeado caso contrário.
Duas formas de URL vêm prontas, e qualquer uma pode virar um lambda quando o site escreve do jeito dele (um subdomínio, ou um slug que é simplesmente outra página):
Política
Forma
AlternateUrls.PathPrefix()
/pt-BR/pricing — a forma que o Google recomenda; um segmento que já nomeia uma cultura é SUBSTITUÍDO, não empilhado
AlternateUrls.QueryString()
/pricing?culture=pt-BR — o resto da query sobrevive, só a chave da cultura é trocada
r => …
Qualquer coisa, a partir de r.Culture e r.Request; uma resposta relativa continua sendo tornada absoluta
1
options.UseAlternateLinks(r => $"https://{r.Culture.ToLowerInvariant()}.acme.test{r.Request.Path}");
Nomeie os idiomas, a menos que o app compartilhe as opções de localização pelo DI. A sobrecarga que quase todo app usa, app.UseRequestLocalization(o => …), monta as opções INLINE e não registra nada, então perguntar ao contêiner devolve o padrão invariante e o head sai vazio. Um app que chama services.Configure<RequestLocalizationOptions>(…) e depois o app.UseRequestLocalization() sem argumento mantém uma lista só e pode omitir os nomes.
Uma página ainda pode sobrepor um idioma por conta própria: seo.Alternate("pt-BR", "…/precos") substitui a resposta do app para aquele idioma e deixa o resto do grupo intacto, que é o que torna uma política global segura de ligar num site com alguns poucos slugs traduzidos.
Nada é emitido quando o app não declarou política, ou quando ele tem um idioma só: um grupo de tradução com um membro afirma que a página não existe em nenhum outro idioma, o que é uma afirmação, não uma ausência.
O jeito recomendado de registrar recursos de interface é dentro do bloco fluente do AddUI:
1
2
3
4
5
6
7
builder.Services.AddUI(options =>
{
options
.UseChartJs()
.UseApexCharts()
.ScanAssembly(typeof(Program).Assembly);
});
Ordem do middleware:
1
2
3
4
app.UseStaticFiles();
app.UseRouting();
app.UseServerActions(); // Antes do MapUI
app.MapUI(); // Fallback SPA + endpoints dos pacotes (por último)