Migra una aplicación Blazor con componentes enrutables en otro assembly de .NET 8 a .NET 10
Cómo actualizar una Blazor Web App cuyas páginas viven en una biblioteca de clases Razor o en un proyecto .Client de .NET 8 a .NET 10: qué se mantiene igual (los dos registros de AdditionalAssemblies) y las cuatro cosas que se rompen, desde un 404 en blazor.web.js en hosts delgados hasta páginas NotFoundPage que se muestran en blanco. Medido en ASP.NET Core 8.0.32 y 10.0.10.
Si tu Blazor Web App de .NET 8 muestra páginas que viven en otro assembly (una biblioteca de clases Razor, un proyecto .Client, un módulo de funcionalidad), el contrato de enrutamiento no cambia en .NET 10: el lado del endpoint sigue necesitando MapRazorComponents<App>().AddAdditionalAssemblies(...) y el Router interactivo sigue necesitando su parámetro AdditionalAssemblies. Ejecuté la misma matriz de siete casos de registro en ASP.NET Core 8.0.32 y 10.0.10, y todos los casos dieron el mismo resultado. Lo que se rompe está alrededor: un host sin archivos .razor propios deja de servir blazor.web.js, un NotFoundPage que vive en la biblioteca muestra un 404 vacío a menos que la biblioteca esté registrada en el lado del endpoint, el antiguo fragmento <NotFound> está obsoleto, y el error “Assembly already defined” pasa de tu línea de Program.cs al arranque de la aplicación. En una solución típica con una o dos bibliotecas de páginas, la actualización toma menos de una hora. Todo lo que sigue se compiló con el SDK 10.0.302 y Microsoft.AspNetCore.Components.Web 10.0.13.
Por qué dejar .NET 8 ahora
- El soporte de .NET 8 termina el 10 de noviembre de 2026. .NET 10 es la LTS actual y tiene soporte hasta noviembre de 2028. Después de esa fecha, las aplicaciones en .NET 8 dejan de recibir parches de seguridad.
NotFoundPagefunciona entre assemblies. .NET 10 puede mostrar una página enrutable de “no encontrado” desde tu biblioteca compartida para URL desconocidas, llamadas aNavigationManager.NotFound()y reejecución de códigos de estado, en lugar de un fragmento en línea duplicado en cada host.- Los recursos estáticos se versionan con huella y se comprimen.
MapStaticAssets(agregado en .NET 9) sirve los archivos_content/...de la biblioteca y el script de Blazor con compresión en tiempo de compilación y ETags, lo que ayuda a las bibliotecas de páginas que incluyen su propio CSS y JS.
Qué cambia entre .NET 8 y .NET 10
| Área | Cambio | Gravedad |
|---|---|---|
Host sin archivos .razor | blazor.web.js es un recurso web estático en .NET 10 y solo se emite cuando el proyecto contiene un componente. Los hosts delgados obtienen un 404. | alta |
NotFoundPage en una biblioteca | La página debe tener @page y su assembly debe pasarse a AddAdditionalAssemblies, o la respuesta 404 queda vacía | media |
Fragmento <NotFound> | Es [Obsolete] y lanza una excepción si se combina con NotFoundPage | media |
| Referencia de paquete de la RCL | Microsoft.AspNetCore.Components.Web 8.0.x debe pasar a 10.0.x | baja |
| Registro duplicado de assembly | ”Assembly already defined” se lanza durante el arranque, no en la llamada a AddAdditionalAssemblies | baja |
| Nombres de miembros en componentes de la biblioteca | ComponentBase incorporó RendererInfo y AssignedRenderMode en .NET 9; un miembro de componente con el mismo nombre ahora lo oculta (CS0108) | baja |
UseStaticFiles | Sigue funcionando después de publicar, pero la guía de migración lo reemplaza por MapStaticAssets | baja |
Los dos registros y qué controla cada uno
Esta es la parte que la mayoría de las migraciones hace mal, normalmente porque la aplicación Blazor Server de .NET 7 solo necesitaba uno de ellos. Una Blazor Web App tiene dos enrutadores. El enrutamiento de endpoints de ASP.NET Core atiende la primera solicitud HTTP (y cada solicitud de navegación mejorada) y necesita saber qué assemblies contienen componentes con @page. El componente Router de Blazor gestiona la navegación una vez que se ejecuta de forma interactiva, y construye su propia tabla de rutas a partir de AppAssembly más AdditionalAssemblies.
Esta es la configuración de prueba: un host creado con la plantilla blazor de .NET 8 con Interactive Server, y una biblioteca de clases Razor Shared.Pages con una 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;
}
Activé y desactivé cada registro y el modo de renderizado de <Routes /> con variables de entorno, y luego solicité /reports directamente. Los resultados fueron idénticos en 8.0.32 y 10.0.10:
Router.AdditionalAssemblies | AddAdditionalAssemblies | Modo de renderizado de <Routes> | GET /reports |
|---|---|---|---|
| sin definir | sin definir | estático | 404 |
| definido | sin definir | estático | 404 |
| sin definir | definido | estático | 200 |
| definido | definido | estático | 200 |
| definido | sin definir | InteractiveServer | 404 |
| sin definir | definido | InteractiveServer | 200, y luego “not found” cuando arranca el circuito |
| definido | definido | InteractiveServer | 200 |
La sexta fila es la que confunde. El servidor prerenderiza la página correctamente, el navegador la muestra un momento, y luego el Router interactivo toma el control, no encuentra /reports en su propia tabla de rutas y reemplaza la página con el contenido de no encontrado. Lo confirmé en un navegador en ambas versiones. La quinta fila es el error opuesto: el enrutador interactivo conoce la página, pero la primera solicitud nunca llega a ella, porque no existe ningún endpoint.
Así que la regla para ambas versiones es simple: registra cada assembly que contenga componentes enrutables en ambos lugares. Si el componente Routes vive en el proyecto del servidor, su AdditionalAssemblies también debe incluir el assembly .Client.
Lista de verificación previa
- SDK de .NET 10 instalado (
dotnet --list-sdksmuestra10.0.1xxo posterior; yo usé 10.0.302). Si unglobal.jsonfija 8.0, actualízalo. - Una lista de cada assembly que contiene componentes con
@page. Usagrep -rln "@page" --include=*.razor .y agrupa los resultados por proyecto. - Cada lugar que los registra.
grep -rn "AdditionalAssemblies" --include=*.razor --include=*.cs .debería mostrar unRoutery una cadenaMapRazorComponentspor host. Si solo muestra uno, ya tienes el error de la matriz anterior. - Una lista de las URL que deben seguir funcionando, incluida una URL de cada biblioteca y una que deba dar 404. Les harás curl después de cada paso.
Pasos de la migración
-
Cambia el destino de cada proyecto y actualiza el paquete de la biblioteca. Cambia
<TargetFramework>net8.0</TargetFramework>anet10.0en el host, el proyecto.Clienty cada biblioteca de clases Razor. La plantilla de RCL referenciaMicrosoft.AspNetCore.Components.Webcomo paquete, así que también necesita una versión nueva:<!-- 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>Si otras aplicaciones todavía consumen la biblioteca en .NET 8, haz que tenga varios destinos con
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>y condiciona la versión del paquete a$(TargetFramework). Verifica condotnet buildy lee las advertencias: un CS0108 en un componente de la biblioteca significa que declara un miembro que ahora oculta un miembro deComponentBase(consulta los problemas frecuentes). -
Pon la lista de assemblies en un solo lugar y úsala en ambos registros. La mayoría de los errores del tipo “la página funciona al refrescar pero no al hacer clic” vienen de dos listas que se desincronizan. Una pequeña clase estática elimina el desfase:
// 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>Verifica con
curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/reports, que debe devolver200para una URL por biblioteca, y luego abre la misma URL en un navegador y espera a que arranque el circuito (o el runtime de WebAssembly). La página debe seguir ahí después. -
Reemplaza
<NotFound>por unNotFoundPageenrutable. En .NET 10 el fragmento de renderizadoNotFoundestá marcado como[Obsolete("NotFound is deprecated. Use NotFoundPage instead.")]. Las notas de la versión van más lejos y dicen que “isn’t supported in .NET 10 or later”, pero en mi prueba el fragmento todavía se renderizaba en 10.0.10 cuando no se definíaNotFoundPage, así que es una obsolescencia que puedes programar y no una interrupción. Definir ambos lanzaInvalidOperationException: Setting NotFound and NotFoundPage properties simultaneously is not supported. La página necesita una ruta, porque el enrutador valida que tenga un atributo[Route]:@* Shared.Pages/Pages/NotFound.razor, .NET 10 *@ @page "/not-found" <h1>Not found (from Shared.Pages)</h1>Luego agrega el middleware de reejecución de códigos de estado que usa la plantilla de .NET 10, para que las URL desconocidas de la primera solicitud HTTP también terminen en esa página:
// Host/Program.cs, .NET 10 app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseAntiforgery();Verifica que
GET /missingdevuelva404con el<h1>de tu página en el cuerpo, y queGET /not-founddevuelva200. Si el cuerpo del 404 está vacío, lee el siguiente paso. -
Registra el assembly de la página de no encontrado en el lado del endpoint. La reejecución es una segunda solicitud a través del enrutamiento de endpoints hacia
/not-found. Si la página vive en una biblioteca que solo conoce elRouter, no hay un endpoint para ella. Medido en 10.0.10 con la página enShared.Pages:AddAdditionalAssemblies(Shared.Pages)GET /missingGET /not-foundfalta 404, cuerpo vacío 404, cuerpo vacío presente 404, “Not found (from Shared.Pages)“ 200 El paso 2 ya corrige esto si la biblioteca está en la lista compartida. Verifica con las mismas dos solicitudes.
-
Cambia
UseStaticFilesporMapStaticAssets. La guía de migración de .NET 9 reemplazaapp.UseStaticFiles()porapp.MapStaticAssets(), y la plantilla de .NET 10 lo usa. Sirve los recursos de la biblioteca bajo_content/Shared.Pages/...con variantes.bry.gzprecomprimidas. Comprobé que mantenerUseStaticFilesen .NET 10 sigue sirviendoblazor.web.jsdesde una carpeta de publicación, así que no es obligatorio, pero es lo que espera el resto de la plantilla (@Assets["..."],<ResourcePreloader />,<ImportMap />):// Host/Program.cs, .NET 10 app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents<App>() .AddInteractiveServerRenderMode() .AddAdditionalAssemblies(RoutableAssemblies.Additional);Verifica que
GET /_framework/blazor.web.jsdevuelva200y que una hoja de estilos de la biblioteca como/_content/Shared.Pages/Shared.Pages.bundle.scp.cssse siga cargando. -
Si el host no tiene archivos
.razor, fuerza la inclusión del script de Blazor. Este es el fallo que solo afecta a las arquitecturas más limpias. CuandoApp.razor, el layout y cada página viven en bibliotecas y el host es soloProgram.cs, .NET 8 seguía sirviendoblazor.web.js, porque era un recurso incrustado en el shared framework. En .NET 10 el script es un recurso web estático que el SDK agrega solo si el proyecto contiene al menos un componente. Mi host delgado, medido desde una carpeta de publicación:Host GET /reportsGET /_framework/blazor.web.js.NET 8.0.32, UseStaticFiles200 200 .NET 10.0.10, MapStaticAssets200 404 .NET 10.0.10, MapStaticAssets+RequiresAspNetWebAssets200 200 La página sigue prerenderizándose, y por eso es fácil pasarlo por alto: nada es interactivo, y la única señal es un 404 en la pestaña de red del navegador. La solución es una propiedad de MSBuild:
<!-- Host.csproj, .NET 10, host project without any .razor file --> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets> </PropertyGroup>Verifica que
publish/wwwroot/_framework/blazor.web.jsexista después dedotnet publish, y luego haz clic en algo interactivo en el navegador. -
Para páginas WebAssembly y Auto en una biblioteca, revisa también el lado
.Client. Nada de esto cambió entre 8 y 10, pero la migración es un buen momento para corregirlo. Una página de biblioteca con@rendermode InteractiveWebAssemblytiene que poder descargarse en el navegador, así que el proyecto.Clientdebe referenciar la biblioteca, y el servidor debe seguir listándola enAddAdditionalAssembliespara la primera solicitud. SiRoutesse ejecuta en WebAssembly (interactividad global), vive en.Clienty suRouternecesita la biblioteca enAdditionalAssemblies. Verifica cargando una página WebAssembly directamente y luego navegando a ella desde otra página.
Verificación
Ejecuta esto contra la aplicación publicada (dotnet publish -c Release -o out, y luego dotnet out/Host.dll), no 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
Resultado esperado: 200, 200, 404, 200. Luego, en un navegador:
- Abre una página de la biblioteca directamente y espera dos segundos. No debe convertirse en la página de no encontrado.
- Navega de una página del host a una página de la biblioteca haciendo clic en un enlace, y de regreso.
- Dispara
NavigationManager.NotFound()desde una página de la biblioteca si lo usas, y confirma que se muestra elNotFoundPage. - Revisa la pestaña de red en busca de cualquier 404 en
_framework/o_content/.
Reversión
El cambio es reversible siempre que mantengas la biblioteca compatible. Si la biblioteca tiene varios destinos net8.0;net10.0, puedes devolver el host a net8.0 revirtiendo su .csproj. Recuerda qué no existe en 8: MapStaticAssets (usa UseStaticFiles), NotFoundPage (usa el fragmento <NotFound>), el parámetro createScopeForStatusCodePages de UseStatusCodePagesWithReExecute y RequiresAspNetWebAssets (inofensivo pero sin uso). Mantén esos cambios en un commit separado del cambio de destino para que la reversión sea un revert limpio.
Problemas frecuentes de las pruebas
“Assembly already defined” se movió. Agregar el assembly propio de la aplicación a AddAdditionalAssemblies siempre ha sido un error. En 8.0.32 la traza de pila apunta directamente a tu 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
En 10.0.10 el registro se difiere hasta que los endpoints se construyen por primera vez, así que el mismo error se registra como Application startup exception desde RazorComponentEndpointDataSource.UpdateEndpoints(), y ningún frame menciona Program.cs. Si ves ese mensaje después de la actualización, busca typeof(Program).Assembly en tu lista de assemblies adicionales, o la misma biblioteca agregada dos veces desde dos métodos auxiliares.
Las rutas duplicadas entre assemblies fallan al momento de la solicitud, no en el arranque. Cuando el host y una biblioteca declararon ambos @page "/reports", la aplicación arrancó bien en 10.0.10 y la solicitud devolvió 500 con AmbiguousMatchException: The request matched multiple endpoints. Solo falla la URL que colisiona, así que una prueba de humo que la omita pasará. La salida de grep -rn '@page "' --include=*.razor de la lista de verificación previa, ordenada por ruta, las encuentra antes de que lleguen a producción.
Un miembro de la biblioteca llamado RendererInfo ahora oculta un miembro del framework. Mi primera versión de la página de prueba tenía una propiedad string RendererInfo. Compilaba sin problemas en .NET 8 y produjo warning CS0108: 'Reports.RendererInfo' hides inherited member 'ComponentBase.RendererInfo' en .NET 10, porque ComponentBase incorporó RendererInfo y AssignedRenderMode en .NET 9. La advertencia es inofensiva hasta que un compañero lee RendererInfo.IsInteractive en ese componente y obtiene tu cadena en su lugar. Renombra el miembro.
Mantener UseStaticFiles y probar desde bin/ da un falso 404. Con la canalización de .NET 8 intacta (UseStaticFiles, sin MapStaticAssets), ejecutar dotnet bin/Release/net10.0/Host.dll en el entorno Production devolvió 404 para blazor.web.js incluso en un host normal con componentes, mientras que lo mismo en .NET 8 devolvió 200. UseStaticFiles solo ve los archivos que están físicamente en wwwroot, y fuera de Development nada agrega los recursos web estáticos de los proyectos referenciados ni del SDK. En .NET 8 el script de Blazor venía de un recurso incrustado, así que no le afectaba; en .NET 10 es un recurso web estático. La misma ejecución desde bin/ con MapStaticAssets devolvió 200, porque lee el manifiesto de endpoints generado, y la carpeta de publicación devolvió 200 con cualquiera de los dos middleware. Prueba desde una carpeta de dotnet publish o con dotnet run, o terminarás persiguiendo el problema equivocado.
El prerenderizado oculta un registro faltante en el Router. Como muestra la matriz, una página registrada solo en el lado del endpoint se renderiza perfectamente en curl y en pruebas automatizadas de instantáneas de HTML. El fallo aparece solo después de que arranca el enrutador interactivo. Cualquier prueba de extremo a extremo para páginas de biblioteca tiene que esperar a la interactividad, por ejemplo haciendo clic en un botón y comprobando el resultado.
Relacionado
- Si todavía estás en el modelo de hospedaje de .NET 7, empieza por migrar una aplicación Blazor Server a Blazor United, y luego vuelve para la parte de varios assemblies.
- Para saber en qué modo de renderizado se ejecuta realmente una página de biblioteca una vez enrutada, consulta qué es un modo de renderizado de Blazor y cuál ejecuta mi componente.
- Un fallo relacionado con recursos estáticos y una causa distinta: 404 Not Found para blazor.server.js después de instalar un nuevo SDK de .NET.
- Si una página de biblioteca declara un modo de renderizado que su componente padre no puede alojar, te encontrarás con el error “render mode is not supported by the parent component’s render mode”.
- El diseño de proyectos detrás de
.ClientyAddAdditionalAssembliesse compara en Blazor Server vs WebAssembly vs United.
Fuentes
- Route to components from multiple assemblies (Microsoft Learn, ASP.NET Core Blazor routing)
- Migrate from ASP.NET Core in .NET 8 to ASP.NET Core in .NET 9, incluido “Replace UseStaticFiles with MapStaticAssets” (Microsoft Learn)
- Migrate from ASP.NET Core in .NET 9 to ASP.NET Core in .NET 10 (Microsoft Learn)
- What’s new in ASP.NET Core in .NET 10: “Blazor router has a NotFoundPage parameter” y “Blazor script as static web asset”
- Map static files in ASP.NET Core (Microsoft Learn)
- ASP.NET Core Razor class libraries (Microsoft Learn)
Router.csat v10.0.0, el origen de la validación deNotFoundPagey del parámetro obsoletoNotFound(dotnet/aspnetcore)- .NET support policy para las fechas de fin de soporte de .NET 8 y .NET 10
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.