diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs index 64867d99a4..d475b332b6 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs @@ -54,10 +54,10 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDep var httpContext = HttpContextAccessor.HttpContext; if (httpContext != null) { - return httpContext.GetRouteValue("culture")?.ToString(); + return AbpRequestCultureCookieHelper.GetRouteCulture(httpContext); } - // No HttpContext: Blazor interactive circuit or WASM client. + // No HttpContext: fallback to CurrentCulture. var currentCulture = CultureInfo.CurrentCulture.Name; var isKnownCulture = AbpLocalizationOptions.Value.Languages .Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs index 9bb5b61d28..552c3bfc84 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs @@ -1,11 +1,41 @@ using System; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.RequestLocalization; public static class AbpRequestCultureCookieHelper { + public const string HasRouteCultureCookieName = "Abp.HasRouteCulture"; + + /// + /// Gets the current route culture from the request. First checks route values, + /// then falls back to the HasRouteCulture cookie (set during Blazor SSR) with CurrentCulture. + /// Returns null if the request has no route-based culture. + /// + public static string? GetRouteCulture(HttpContext? httpContext) + { + if (httpContext == null) + { + return null; + } + + var routeCulture = httpContext.GetRouteValue("culture")?.ToString(); + if (!string.IsNullOrEmpty(routeCulture)) + { + return routeCulture; + } + + if (httpContext.Request.Cookies.ContainsKey(HasRouteCultureCookieName)) + { + return CultureInfo.CurrentCulture.Name; + } + + return null; + } + public static void SetCultureCookie( HttpContext httpContext, RequestCulture requestCulture) @@ -20,4 +50,21 @@ public static class AbpRequestCultureCookieHelper } ); } + + public static void SetHasRouteCultureCookie(HttpContext httpContext, bool hasRouteCulture) + { + if (hasRouteCulture) + { + httpContext.Response.Cookies.Append( + HasRouteCultureCookieName, "1", + new CookieOptions + { + IsEssential = true + }); + } + else + { + httpContext.Response.Cookies.Delete(HasRouteCultureCookieName); + } + } } diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs index 07451c1cbb..e71a5e6b7b 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; @@ -48,6 +49,17 @@ public class AbpRequestLocalizationMiddleware : AbpMiddlewareBase, ITransientDep requestCultureFeature.RequestCulture ); } + + // Only manage HasRouteCulture cookie for Blazor component page requests. + // This cookie is used by AbpCultureMenuItemUrlProvider to determine if the + // initial SSR page had a culture prefix, since the Blazor interactive circuit + // (/_blazor) does not carry the original route values. + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.Any(m => m.GetType().Name == "ComponentTypeMetadata") == true) + { + AbpRequestCultureCookieHelper.SetHasRouteCultureCookie( + context, requestCultureFeature?.Provider is RouteDataRequestCultureProvider); + } } return Task.CompletedTask; diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs index f2543f336a..c032f4c3d2 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -38,33 +39,51 @@ public class AbpCultureMenuItemUrlProvider_Tests } [Fact] - public async Task Should_Not_Add_Prefix_When_HttpContext_Has_No_Culture_Route() + public async Task Should_Not_Add_Prefix_For_Mvc_Request_Without_Culture() { - // HttpContext exists but has no {culture} route value (e.g. MVC request to /about). - // No prefix should be added to keep URL style consistent with the current page. - var provider = CreateProvider(useRouteBasedCulture: true, cultureName: null); + // MVC request to /about (no culture, no HasRouteCulture cookie). + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["controller"] = "Home"; + httpContext.Request.RouteValues["action"] = "About"; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var localizationOptions = MsOptions.Create( + new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); + var abpLocOptions = new AbpLocalizationOptions(); + abpLocOptions.Languages.Add(new LanguageInfo("en")); + abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); + var provider = new AbpCultureMenuItemUrlProvider( + httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions)); + var menu = CreateMenuWithItems("/home", "/about"); - await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } menu.Items[0].Url.ShouldBe("/home"); menu.Items[1].Url.ShouldBe("/about"); } [Fact] - public async Task Should_Not_Add_Prefix_When_HttpContext_Has_No_Culture_Route_Even_With_Known_Languages() + public async Task Should_Fallback_To_CurrentCulture_In_Blazor_Circuit() { - // HttpContext exists but has no {culture} route value, even though CurrentCulture - // matches a known language. No prefix should be added because the current request - // does not have a culture segment in the URL. - var httpContext = new DefaultHttpContext(); // no culture route value + // Blazor Server interactive circuit: HttpContext exists (SignalR) but has + // no route culture. Cookie was set during SSR indicating route culture was used. + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Cookie"] = $"{AbpRequestCultureCookieHelper.HasRouteCultureCookieName}=1"; var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; var localizationOptions = MsOptions.Create( new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); var abpLocOptions = new AbpLocalizationOptions(); abpLocOptions.Languages.Add(new LanguageInfo("en")); abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); - abpLocOptions.Languages.Add(new LanguageInfo("tr")); var provider = new AbpCultureMenuItemUrlProvider( httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions)); @@ -81,8 +100,8 @@ public class AbpCultureMenuItemUrlProvider_Tests CultureInfo.CurrentCulture = previousCulture; } - menu.Items[0].Url.ShouldBe("/home"); - menu.Items[1].Url.ShouldBe("/about"); + menu.Items[0].Url.ShouldBe("/zh-Hans/home"); + menu.Items[1].Url.ShouldBe("/zh-Hans/about"); } [Fact] @@ -257,4 +276,5 @@ public class AbpCultureMenuItemUrlProvider_Tests } return menu; } + } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs index f2bb705391..0ce5132a25 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.RequestLocalization; using Volo.Abp.UI.Navigation; namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic; @@ -27,7 +27,7 @@ public partial class LoginDisplay : IDisposable protected string GetLoginUrl() { - var culture = HttpContextAccessor.HttpContext?.GetRouteValue("culture")?.ToString(); + var culture = AbpRequestCultureCookieHelper.GetRouteCulture(HttpContextAccessor.HttpContext); return string.IsNullOrEmpty(culture) ? "Account/Login" : $"{culture}/Account/Login"; }