Start Debugging

Migre um app Blazor com componentes roteáveis em outro assembly do .NET 8 para o .NET 10

Atualizando um Blazor Web App cujas páginas ficam em uma Razor class library ou em um projeto .Client do .NET 8 para o .NET 10: o que continua igual (os dois registros de AdditionalAssemblies) e as quatro coisas que quebram, de um 404 em blazor.web.js em hosts enxutos a páginas NotFoundPage que renderizam em branco. Medido no ASP.NET Core 8.0.32 e 10.0.10.

Se o seu Blazor Web App do .NET 8 renderiza páginas que ficam em outro assembly (uma Razor class library, um projeto .Client, um módulo de funcionalidade), o contrato de roteamento não muda no .NET 10: o lado do endpoint ainda precisa de MapRazorComponents<App>().AddAdditionalAssemblies(...) e o Router interativo ainda precisa do parâmetro AdditionalAssemblies. Executei a mesma matriz de sete casos de registro no ASP.NET Core 8.0.32 e no 10.0.10, e todos os casos deram o mesmo resultado. O que quebra está ao redor: um host sem arquivos .razor próprios deixa de servir blazor.web.js, uma NotFoundPage que fica na biblioteca renderiza um 404 vazio a menos que a biblioteca esteja registrada no lado do endpoint, o antigo fragmento <NotFound> está obsoleto, e o erro “Assembly already defined” sai da sua linha do Program.cs e vai para a inicialização do app. Para uma solução típica com uma ou duas bibliotecas de páginas, a atualização leva menos de uma hora. Tudo abaixo foi compilado com o SDK 10.0.302 e Microsoft.AspNetCore.Components.Web 10.0.13.

Por que sair do .NET 8 agora

O que muda entre o .NET 8 e o .NET 10

ÁreaMudançaGravidade
Host sem arquivos .razorblazor.web.js é um ativo web estático no .NET 10 e só é emitido quando o projeto contém um componente. Hosts enxutos recebem um 404.alta
NotFoundPage em uma bibliotecaA página precisa ter @page, e o assembly dela precisa ser passado para AddAdditionalAssemblies, senão a resposta 404 fica vaziamédia
Fragmento <NotFound>[Obsolete], e lança exceção se combinado com NotFoundPagemédia
Referência de pacote da RCLMicrosoft.AspNetCore.Components.Web 8.0.x precisa subir para 10.0.xbaixa
Registro duplicado de assembly”Assembly already defined” é lançado durante a inicialização, não na chamada a AddAdditionalAssembliesbaixa
Nomes de membros em componentes da bibliotecaComponentBase ganhou RendererInfo e AssignedRenderMode no .NET 9; um membro de componente com o mesmo nome agora o oculta (CS0108)baixa
UseStaticFilesAinda funciona após o publish, mas o guia de migração o substitui por MapStaticAssetsbaixa

Os dois registros e o que cada um controla

Esta é a parte que a maioria das migrações erra, geralmente porque o app Blazor Server do .NET 7 só precisava de um deles. Um Blazor Web App tem dois roteadores. O roteamento de endpoints do ASP.NET Core trata a primeira requisição HTTP (e toda requisição de navegação aprimorada) e precisa saber quais assemblies contêm componentes @page. O componente Router do Blazor trata a navegação quando já está rodando de forma interativa, e monta sua própria tabela de rotas a partir de AppAssembly mais AdditionalAssemblies.

Este é o cenário de teste: um host criado a partir do template blazor do .NET 8 com Interactive Server, e uma Razor class library Shared.Pages com uma página.

@* Shared.Pages/Pages/Reports.razor, .NET 8 and .NET 10 *@
@page "/reports"
@rendermode InteractiveServer

<h1>Reports (from Shared.Pages)</h1>
<p>Rendered from @GetType().Assembly.GetName().Name</p>
<button @onclick="() => count++">Clicked @count</button>

@code {
    int count;
}

Alternei cada registro e o modo de renderização de <Routes /> com variáveis de ambiente, e depois requisitei /reports diretamente. Os resultados foram idênticos no 8.0.32 e no 10.0.10:

Router.AdditionalAssembliesAddAdditionalAssembliesModo de renderização de <Routes>GET /reports
não definidonão definidoestático404
definidonão definidoestático404
não definidodefinidoestático200
definidodefinidoestático200
definidonão definidoInteractiveServer404
não definidodefinidoInteractiveServer200, depois “not found” quando o circuito inicia
definidodefinidoInteractiveServer200

A sexta linha é a que confunde. O servidor faz o prerender da página corretamente, o navegador a exibe por um instante, e então o Router interativo assume, não encontra /reports na sua própria tabela de rotas e substitui a página pelo conteúdo de “não encontrado”. Confirmei isso em um navegador nas duas versões. A quinta linha é o erro oposto: o roteador interativo conhece a página, mas a primeiríssima requisição nunca chega a ela, porque não existe endpoint.

Então a regra para as duas versões é simples: registre todo assembly que contém componentes roteáveis nos dois lugares. Se o componente Routes fica no projeto do servidor, o AdditionalAssemblies dele também precisa incluir o assembly .Client.

Checklist prévio

  1. SDK do .NET 10 instalado (dotnet --list-sdks mostra 10.0.1xx ou posterior; usei o 10.0.302). Se um global.json fixa o 8.0, atualize-o.
  2. Uma lista de todo assembly que contém componentes @page. grep -rln "@page" --include=*.razor . e agrupe os resultados por projeto.
  3. Todos os lugares que os registram. grep -rn "AdditionalAssemblies" --include=*.razor --include=*.cs . deve mostrar um Router e uma cadeia MapRazorComponents por host. Se mostrar só um, você já tem o bug da matriz acima.
  4. Uma lista das URLs que precisam continuar funcionando, incluindo uma URL de cada biblioteca e uma que deve dar 404. Você vai usar curl nelas após cada passo.

Passos da migração

  1. Altere o framework de destino de todos os projetos e atualize o pacote da biblioteca. Troque <TargetFramework>net8.0</TargetFramework> por net10.0 no host, no projeto .Client e em cada Razor class library. O template de RCL referencia Microsoft.AspNetCore.Components.Web como pacote, então ele também precisa de uma nova versão:

    <!-- Shared.Pages.csproj, .NET 10 -->
    <Project Sdk="Microsoft.NET.Sdk.Razor">
      <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <SupportedPlatform Include="browser" />
      </ItemGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.13" />
      </ItemGroup>
    </Project>

    Se outros apps ainda consomem a biblioteca no .NET 8, faça multi-targeting com <TargetFrameworks>net8.0;net10.0</TargetFrameworks> e condicione a versão do pacote a $(TargetFramework). Verifique com dotnet build e leia os avisos: um CS0108 em um componente da biblioteca significa que ele declara um membro que agora oculta um membro de ComponentBase (veja as armadilhas).

  2. Coloque a lista de assemblies em um único lugar e use-a nos dois registros. A maioria dos bugs do tipo “a página funciona ao atualizar, mas não ao clicar” vem de duas listas que divergem. Uma pequena classe estática elimina a divergência:

    // Host/RoutableAssemblies.cs, .NET 10
    using System.Reflection;
    
    namespace Host;
    
    public static class RoutableAssemblies
    {
        // Do NOT include typeof(Program).Assembly: it is already the app assembly.
        public static readonly Assembly[] Additional =
        [
            typeof(Shared.Pages._Imports).Assembly,
            // typeof(Host.Client._Imports).Assembly,
        ];
    }
    // Host/Program.cs, .NET 10
    app.MapRazorComponents<App>()
        .AddInteractiveServerRenderMode()
        .AddAdditionalAssemblies(RoutableAssemblies.Additional);
    @* Host/Components/Routes.razor, .NET 10 *@
    <Router AppAssembly="typeof(Program).Assembly"
            AdditionalAssemblies="RoutableAssemblies.Additional"
            NotFoundPage="typeof(Shared.Pages.Pages.NotFound)">
        <Found Context="routeData">
            <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
            <FocusOnNavigate RouteData="routeData" Selector="h1" />
        </Found>
    </Router>

    Verifique com curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/reports retornando 200 para uma URL por biblioteca, depois abra a mesma URL em um navegador e espere o circuito (ou o runtime WebAssembly) iniciar. A página ainda precisa estar lá depois disso.

  3. Substitua <NotFound> por uma NotFoundPage roteável. No .NET 10 o render fragment NotFound é marcado com [Obsolete("NotFound is deprecated. Use NotFoundPage instead.")]. As notas de versão vão além e dizem que ele “isn’t supported in .NET 10 or later”, mas no meu teste o fragmento ainda renderizava no 10.0.10 quando nenhuma NotFoundPage estava definida, então isso é uma descontinuação que você pode agendar, não uma interrupção. Definir os dois lança InvalidOperationException: Setting NotFound and NotFoundPage properties simultaneously is not supported. A página precisa de uma rota, porque o roteador valida que ela tem um atributo [Route]:

    @* Shared.Pages/Pages/NotFound.razor, .NET 10 *@
    @page "/not-found"
    
    <h1>Not found (from Shared.Pages)</h1>

    Depois adicione o middleware de reexecução de código de status que o template do .NET 10 usa, para que URLs desconhecidas da primeira requisição HTTP também caiam nessa página:

    // Host/Program.cs, .NET 10
    app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
    app.UseAntiforgery();

    Verifique que GET /missing retorna 404 com o <h1> da sua página no corpo, e que GET /not-found retorna 200. Se o corpo do 404 estiver vazio, leia o próximo passo.

  4. Registre o assembly da página de não encontrado no lado do endpoint. A reexecução é uma segunda requisição pelo roteamento de endpoints até /not-found. Se a página fica em uma biblioteca que só o Router conhece, não existe endpoint para ela. Medido no 10.0.10 com a página em Shared.Pages:

    AddAdditionalAssemblies(Shared.Pages)GET /missingGET /not-found
    ausente404, corpo vazio404, corpo vazio
    presente404, “Not found (from Shared.Pages)“200

    O passo 2 já resolve isso se a biblioteca estiver na lista compartilhada. Verifique com as mesmas duas requisições.

  5. Troque UseStaticFiles por MapStaticAssets. O guia de migração do .NET 9 substitui app.UseStaticFiles() por app.MapStaticAssets(), e o template do .NET 10 o utiliza. Ele serve os ativos da biblioteca em _content/Shared.Pages/... com variantes .br e .gz pré-comprimidas. Verifiquei que manter UseStaticFiles no .NET 10 ainda serve blazor.web.js a partir de uma pasta de publish, então isso não é obrigatório, mas é o que o restante do template (@Assets["..."], <ResourcePreloader />, <ImportMap />) espera:

    // Host/Program.cs, .NET 10
    app.UseAntiforgery();
    app.MapStaticAssets();
    app.MapRazorComponents<App>()
        .AddInteractiveServerRenderMode()
        .AddAdditionalAssemblies(RoutableAssemblies.Additional);

    Verifique que GET /_framework/blazor.web.js retorna 200 e que uma folha de estilo da biblioteca, como /_content/Shared.Pages/Shared.Pages.bundle.scp.css, ainda carrega.

  6. Se o host não tem arquivos .razor, force a volta do script do Blazor. Esta é a quebra que atinge apenas as arquiteturas mais limpas. Quando App.razor, o layout e todas as páginas ficam em bibliotecas e o host é só o Program.cs, o .NET 8 ainda servia blazor.web.js, porque ele era um recurso embutido no shared framework. No .NET 10 o script é um ativo web estático que o SDK adiciona apenas se o projeto contiver pelo menos um componente. Meu host enxuto, medido a partir de uma pasta de publish:

    HostGET /reportsGET /_framework/blazor.web.js
    .NET 8.0.32, UseStaticFiles200200
    .NET 10.0.10, MapStaticAssets200404
    .NET 10.0.10, MapStaticAssets + RequiresAspNetWebAssets200200

    A página ainda faz prerender, e é por isso que é fácil deixar passar: nada é interativo, e o único sinal é um 404 na aba de rede do navegador. A correção é uma propriedade do MSBuild:

    <!-- Host.csproj, .NET 10, host project without any .razor file -->
    <PropertyGroup>
      <TargetFramework>net10.0</TargetFramework>
      <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
    </PropertyGroup>

    Verifique que publish/wwwroot/_framework/blazor.web.js existe após dotnet publish, e depois clique em algo interativo no navegador.

  7. Para páginas WebAssembly e Auto em uma biblioteca, confira também o lado .Client. Nada aqui mudou entre o 8 e o 10, mas a migração é um bom momento para corrigir isso. Uma página de biblioteca com @rendermode InteractiveWebAssembly precisa poder ser baixada pelo navegador, então o projeto .Client precisa referenciar a biblioteca, e o servidor ainda precisa listá-la em AddAdditionalAssemblies para a primeira requisição. Se o próprio Routes roda em WebAssembly (interatividade global), ele fica em .Client, e o Router dele precisa da biblioteca em AdditionalAssemblies. Verifique carregando uma página WebAssembly diretamente e depois navegando até ela a partir de outra página.

Verificação

Execute isto contra o app publicado (dotnet publish -c Release -o out, depois dotnet out/Host.dll), não contra bin/:

#!/usr/bin/env bash
# .NET 10 smoke test for routable components from other assemblies
BASE=http://localhost:5000
for u in /reports /not-found /missing /_framework/blazor.web.js; do
  printf '%-30s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE$u")"
done

Esperado: 200, 200, 404, 200. Depois, em um navegador:

Como reverter

A mudança é reversível desde que você mantenha a biblioteca compatível. Se a biblioteca faz multi-targeting em net8.0;net10.0, você pode reverter o host para net8.0 desfazendo o .csproj dele. Lembre-se do que não existe no 8: MapStaticAssets (use UseStaticFiles), NotFoundPage (use o fragmento <NotFound>), o parâmetro createScopeForStatusCodePages de UseStatusCodePagesWithReExecute e RequiresAspNetWebAssets (inofensivo, mas sem uso). Mantenha essas mudanças em um commit separado do retarget, para que o rollback seja um revert limpo.

Armadilhas encontradas nos testes

“Assembly already defined” mudou de lugar. Adicionar o assembly do próprio app a AddAdditionalAssemblies sempre foi um erro. No 8.0.32 o stack trace aponta direto para o seu código:

Unhandled exception. System.InvalidOperationException: Assembly already defined.
   at Microsoft.AspNetCore.Components.Discovery.ComponentApplicationBuilder.AddLibrary(...)
   at Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilderExtensions.AddAdditionalAssemblies(...)
   at Program.<Main>$(String[] args) in .../Host/Program.cs:line 29

No 10.0.10 o registro é adiado até os endpoints serem construídos pela primeira vez, então o mesmo erro é registrado como Application startup exception por RazorComponentEndpointDataSource.UpdateEndpoints(), e nenhum frame menciona Program.cs. Se você vir essa mensagem após a atualização, procure typeof(Program).Assembly na sua lista de assemblies adicionais, ou a mesma biblioteca adicionada duas vezes por dois métodos auxiliares.

Rotas duplicadas entre assemblies falham em tempo de requisição, não na inicialização. Quando o host e uma biblioteca declararam ambos @page "/reports", o app iniciou normalmente no 10.0.10 e a requisição retornou 500 com AmbiguousMatchException: The request matched multiple endpoints. Só a URL em conflito quebra, então um smoke test que a pule vai passar. A saída de grep -rn '@page "' --include=*.razor do checklist prévio, ordenada por rota, encontra esses casos antes de irem para produção.

Um membro da biblioteca chamado RendererInfo agora oculta um membro do framework. Minha primeira versão da página de teste tinha uma propriedade string RendererInfo. Ela compilava sem problemas no .NET 8 e produziu warning CS0108: 'Reports.RendererInfo' hides inherited member 'ComponentBase.RendererInfo' no .NET 10, porque ComponentBase ganhou RendererInfo e AssignedRenderMode no .NET 9. O aviso é inofensivo até que um colega leia RendererInfo.IsInteractive nesse componente e receba a sua string. Renomeie o membro.

Manter UseStaticFiles e testar a partir de bin/ dá um 404 falso. Com o pipeline do .NET 8 mantido (UseStaticFiles, sem MapStaticAssets), executar dotnet bin/Release/net10.0/Host.dll no ambiente Production retornou 404 para blazor.web.js mesmo em um host normal com componentes, enquanto o mesmo teste no .NET 8 retornou 200. UseStaticFiles só enxerga os arquivos fisicamente presentes em wwwroot, e fora do Development nada adiciona os ativos web estáticos dos projetos referenciados e do SDK. No .NET 8 o script do Blazor vinha de um recurso embutido, então isso não importava; no .NET 10 ele é um ativo web estático. A mesma execução a partir de bin/ com MapStaticAssets retornou 200, porque ele lê o manifesto de endpoints gerado, e a pasta de publish retornou 200 com qualquer um dos middlewares. Teste a partir de uma pasta de dotnet publish ou com dotnet run, ou você vai perseguir o problema errado.

O prerender esconde um registro ausente no Router. Como a matriz mostra, uma página registrada apenas no lado do endpoint renderiza perfeitamente no curl e em testes automatizados de snapshot de HTML. A falha só aparece depois que o roteador interativo inicia. Qualquer teste de ponta a ponta para páginas de biblioteca precisa esperar a interatividade, por exemplo clicando em um botão e verificando o resultado.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar