Skip to content

ServerIntegration pt BR

Edgar Mesquita edited this page Aug 14, 2026 · 9 revisions

Integração com o servidor

🌐 Esta página em: English · Português

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

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:

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
  • Serviços do SignalR

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>.

options.ScanAssembly(typeof(Program).Assembly);

Vários assemblies podem ser varridos:

options.ScanAssembly(typeof(Program).Assembly)
       .ScanAssembly(typeof(SharedComponents).Assembly);

WithSsr

Liga ou desliga a renderização no servidor globalmente. O padrão é true.

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.

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

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:

[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.

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.

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

Since 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.

// 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
EnableDefaultCss bool true Injeta o CSS padrão do eQuantic (ponha false com Tailwind)
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

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.

Uma página é construída a partir dos serviços da REQUISIÇÃO

Desde 0.2.0-preview.13

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.

app.UseServerActions();

Server Actions são métodos marcados com [ServerAction] que executam no servidor e devolvem resultados ao cliente:

[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.

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:

    [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:

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.

UseChartJs / UseApexCharts

Habilita os serviços e os endpoints de script de CDN das bibliotecas de gráfico.

builder.Services.AddUI(options =>
{
    options.UseChartJs()
           .UseApexCharts();
});

Hot reload (web)

Ligado em Development (ou forçado com options.HotReload = true): 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). ~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.
  • 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.

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 browser percorre a pilha JS do erro por DOIS mapas. O .js.map do próprio bundle cai no intermediário TS (o Bun não compõe mapas de entrada), e o .ts.map gerado pelo eqc ao lado desse intermediário cai no C#: arquivo, linha, e o próprio texto do código, embutido no mapa, servido em desenvolvimento em /_equantic/src-map/{name} e respondendo 404 em produção. Frames que não conseguem mapear até o fim ficam rotulados (js), porque uma afirmação verdadeira sobre onde o mapeamento parou vence uma linha C# adivinhada.

A precisão é no nível de MEMBRO: o frame cai no arquivo certo e na linha do membro que o contém (o emissor registra mapeamentos por membro, não por instrução).

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.

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.
  • Guarde os resultados em CAMPOS. A carga de hidratação viaja por nome de campo para os campos idênticos do gêmeo transpilado. Uma propriedade não atravessa.
  • 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.

…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:

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 campos de IServerPrefetch são preenchidos 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 — 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.

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:

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
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.

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:

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 jeito recomendado de registrar recursos de interface é dentro do bloco fluente do AddUI:

builder.Services.AddUI(options =>
{
    options
        .UseChartJs()
        .UseApexCharts()
        .ScanAssembly(typeof(Program).Assembly);
});

Ordem do middleware:

app.UseStaticFiles();
app.UseRouting();
app.UseServerActions();    // Antes do MapUI
app.MapUI();               // Fallback SPA + endpoints dos pacotes (por último)

Clone this wiki locally