diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
index f00ce9a12b..e8a5d16015 100644
--- a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
+++ b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
@@ -6,7 +6,7 @@ Consider a book-store app where users browse in their language:
- A Spanish user shares a product link. The recipient opens it in English because the cookie on *their* machine says `en`.
- Search engines crawl the same URL in every language, making it impossible to create separate sitemaps per locale.
-- A user bookmarks `/Books/Detail?id=42&culture=es`. After a cookie reset, the `?culture=` parameter is missing from the bookmark — the page loads in the wrong language.
+- A user shares a link like `/Books/Detail?id=42&culture=es`. When the server processes the request, it sets the culture cookie and then redirects to `/Books/Detail?id=42` — stripping the `?culture=` parameter. The shared link no longer carries the intended language.
Embedding the culture in the URL path — `/es/books`, `/zh-Hans/about` — solves all three. Each language has its own stable URL, readable by humans and index-friendly for search engines.
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs
new file mode 100644
index 0000000000..26a25b7f0e
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Components;
+using Volo.Abp.AspNetCore.Components;
+
+namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
+
+///
+/// Shared base for WASM theme Authentication pages.
+/// Provides a helper so the culture-aware
+/// home URL construction is not duplicated across theme packages.
+///
+public abstract class CultureAwareAuthenticationBase : AbpComponentBase
+{
+ [Inject]
+ protected NavigationManager Navigation { get; set; } = default!;
+
+ [Parameter]
+ public string? Culture { get; set; }
+
+ protected virtual string GetCultureAwareHomeUrl()
+ {
+ return string.IsNullOrEmpty(Culture)
+ ? Navigation.BaseUri
+ : Navigation.BaseUri + Culture + "/";
+ }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs
new file mode 100644
index 0000000000..02c2b749bf
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs
@@ -0,0 +1,35 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
+using Microsoft.Extensions.Options;
+using Volo.Abp.AspNetCore.Components.Web;
+using Volo.Abp.AspNetCore.Components.WebAssembly;
+
+namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
+
+///
+/// Provides the shared culture-aware login redirect logic for all WASM theme
+/// WebAssemblyRedirectToLogin components. Each theme must keep
+/// @inherits RedirectToLogin for ABP service-replacement assignability,
+/// so a common component base class is not feasible; this static helper
+/// centralises the logic instead.
+///
+public static class CultureAwareRedirectToLoginHelper
+{
+ public static async Task RedirectAsync(
+ NavigationManager navigation,
+ string loginUrl,
+ IRouteBasedCultureUrlHelper cultureUrlHelper,
+ IOptions webOptions)
+ {
+ var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl);
+ if (webOptions.Value.IsBlazorWebApp)
+ {
+ navigation.NavigateTo(cultureLoginUrl, forceLoad: true);
+ }
+ else
+ {
+ navigation.NavigateToLogin(cultureLoginUrl);
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
index b3fc2633a7..76b63c76c7 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
+++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
@@ -9,6 +9,10 @@
Nullable
+
+
+
+
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
index 012d7e5705..6a16f7430a 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
@@ -16,16 +16,41 @@ public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHel
IEnumerable allLanguages)
{
var relativePath = navigationManager.ToBaseRelativePath(navigationManager.Uri);
- var slashIndex = relativePath.IndexOf('/');
- var firstSegment = slashIndex >= 0 ? relativePath.Substring(0, slashIndex) : relativePath;
- var allCultures = allLanguages.Select(l => l.CultureName);
+ // Separate the path from any query string or fragment so the culture segment
+ // is correctly identified even for URLs like "tr?x=1" (no slash after culture).
+ var suffixIndex = relativePath.IndexOfAny(['?', '#']);
+ var pathPart = suffixIndex >= 0 ? relativePath.Substring(0, suffixIndex) : relativePath;
+ var suffix = suffixIndex >= 0 ? relativePath.Substring(suffixIndex) : string.Empty;
- var newRelativePath = allCultures.Any(c => string.Equals(c, firstSegment, StringComparison.OrdinalIgnoreCase))
- ? newLanguage.CultureName + (slashIndex >= 0 ? relativePath.Substring(slashIndex) : string.Empty)
- : newLanguage.CultureName + "/" + relativePath;
+ var slashIndex = pathPart.IndexOf('/');
+ var firstSegment = GetFirstPathSegment(relativePath);
+ var pathRemainder = slashIndex >= 0 ? pathPart.Substring(slashIndex) : string.Empty;
+
+ // No-op: the current URL already shows the target culture — no navigation needed.
+ if (string.Equals(firstSegment, newLanguage.CultureName, StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.CompletedTask;
+ }
+
+ var newRelativePath = allLanguages.Any(l => string.Equals(l.CultureName, firstSegment, StringComparison.OrdinalIgnoreCase))
+ ? newLanguage.CultureName + pathRemainder + suffix
+ : newLanguage.CultureName + "/" + pathPart + suffix;
navigationManager.NavigateTo(navigationManager.ToAbsoluteUri(newRelativePath).ToString(), forceLoad: true);
return Task.CompletedTask;
}
+
+ ///
+ /// Returns the first path segment of ,
+ /// stripping any query string or fragment before splitting on '/'.
+ /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
+ ///
+ protected virtual string GetFirstPathSegment(string baseRelativePath)
+ {
+ var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']);
+ var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath;
+ var slashIndex = pathPart.IndexOf('/');
+ return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart;
+ }
}
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
index b9f6a07b64..0c03e56b5b 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
@@ -18,8 +18,23 @@ public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransien
public virtual async Task PrependCulturePrefixAsync(string url)
{
- var config = await _configurationClient.GetAsync();
+ if (string.IsNullOrEmpty(url))
+ {
+ return url;
+ }
+
+ // Skip absolute URLs with a web scheme and protocol-relative URLs.
+ // Intentionally avoids Uri.TryCreate here: on Unix, root-relative paths such as
+ // "/account/login" are parsed as absolute file:// URIs, which would incorrectly
+ // skip them before the culture prefix could be applied.
+ if (url.StartsWith("//", StringComparison.Ordinal) ||
+ url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ return url;
+ }
+ var config = await _configurationClient.GetAsync();
if (config?.Localization.UseRouteBasedCulture != true)
{
return url;
@@ -29,6 +44,47 @@ public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransien
var isKnownCulture = config.Localization.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
- return isKnownCulture ? $"{currentCulture}/{url}" : url;
+ if (!isKnownCulture)
+ {
+ return url;
+ }
+
+ // Idempotency guard: if the URL already carries the culture prefix, return it unchanged.
+ // Strip the leading scheme prefix (~/ or /) before checking the first path segment.
+ var pathForSegmentCheck = url.StartsWith("~/", StringComparison.Ordinal) ? url.Substring(2)
+ : url.StartsWith("/", StringComparison.Ordinal) ? url.Substring(1)
+ : url;
+
+ if (string.Equals(GetFirstPathSegment(pathForSegmentCheck),
+ currentCulture, StringComparison.OrdinalIgnoreCase))
+ {
+ return url;
+ }
+
+ if (url.StartsWith("~/", StringComparison.Ordinal))
+ {
+ return "~/" + currentCulture + "/" + url.Substring(2);
+ }
+
+ if (url.StartsWith("/", StringComparison.Ordinal))
+ {
+ return "/" + currentCulture + url;
+ }
+
+ // Bare relative path (e.g. "authentication/login")
+ return currentCulture + "/" + url;
+ }
+
+ ///
+ /// Returns the first path segment of ,
+ /// stripping any query string or fragment before splitting on '/'.
+ /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
+ ///
+ protected virtual string GetFirstPathSegment(string baseRelativePath)
+ {
+ var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']);
+ var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath;
+ var slashIndex = pathPart.IndexOf('/');
+ return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart;
}
}
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
index da48686c03..14a9ba5c51 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
@@ -1,22 +1,16 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.WebUtilities;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
-public partial class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCultureReplacement, ITransientDependency
+public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCultureReplacement, ITransientDependency
{
- private static readonly Regex CultureQueryStringRegex = GetCultureQueryStringRegex();
- private static readonly Regex UiCultureQueryStringRegex = GetUiCultureQueryStringRegex();
-
- [GeneratedRegex("culture=[A-Za-z-]+", RegexOptions.IgnoreCase)]
- private static partial Regex GetCultureQueryStringRegex();
-
- [GeneratedRegex("ui-culture=[A-Za-z-]+", RegexOptions.IgnoreCase)]
- private static partial Regex GetUiCultureQueryStringRegex();
-
public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context)
{
if (string.IsNullOrWhiteSpace(context.ReturnUrl))
@@ -30,26 +24,60 @@ public partial class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStrin
if (!string.IsNullOrEmpty(currentCulture))
{
var escapedCulture = Regex.Escape(currentCulture);
+ // Replace only the first occurrence so that paths like /en/products/en/details
+ // only have the leading culture segment replaced, while tenant-prefixed paths
+ // like /tenant-a/en/... are also handled correctly.
var pattern = $"/{escapedCulture}(?=/|$|\\?|#)";
- context.ReturnUrl = Regex.Replace(
- context.ReturnUrl,
- pattern,
- "/" + context.RequestCulture.Culture.Name,
- RegexOptions.IgnoreCase);
+ context.ReturnUrl = new Regex(pattern, RegexOptions.IgnoreCase)
+ .Replace(context.ReturnUrl, "/" + context.RequestCulture.Culture.Name, 1);
+ }
+
+ context.ReturnUrl = ReplaceQueryStringCulture(context.ReturnUrl, context);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Replaces culture and ui-culture query parameters in
+ /// with the values from . Each parameter is handled independently —
+ /// the presence of one does not require the other. Uses a proper query parser instead of
+ /// regex to avoid false-positive matches inside other parameter values.
+ ///
+ protected virtual string ReplaceQueryStringCulture(string url, QueryStringCultureReplacementContext context)
+ {
+ var fragmentIndex = url.IndexOf('#');
+ var fragment = fragmentIndex >= 0 ? url.Substring(fragmentIndex) : string.Empty;
+ var urlWithoutFragment = fragmentIndex >= 0 ? url.Substring(0, fragmentIndex) : url;
+
+ var queryIndex = urlWithoutFragment.IndexOf('?');
+ if (queryIndex < 0)
+ {
+ return url;
+ }
+
+ var path = urlWithoutFragment.Substring(0, queryIndex);
+ var queryString = urlWithoutFragment.Substring(queryIndex);
+ var query = QueryHelpers.ParseQuery(queryString);
+
+ if (!query.ContainsKey("culture") && !query.ContainsKey("ui-culture"))
+ {
+ return url;
}
- if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) &&
- context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase))
+ if (query.ContainsKey("culture"))
{
- context.ReturnUrl = CultureQueryStringRegex.Replace(
- context.ReturnUrl,
- $"culture={context.RequestCulture.Culture}");
+ query["culture"] = context.RequestCulture.Culture.Name;
+ }
- context.ReturnUrl = UiCultureQueryStringRegex.Replace(
- context.ReturnUrl,
- $"ui-culture={context.RequestCulture.UICulture}");
+ if (query.ContainsKey("ui-culture"))
+ {
+ query["ui-culture"] = context.RequestCulture.UICulture.Name;
}
- return Task.CompletedTask;
+ var rebuiltUrl = QueryHelpers.AddQueryString(
+ path,
+ query.SelectMany(kvp => kvp.Value.Select(v => KeyValuePair.Create(kvp.Key, v))));
+
+ return rebuiltUrl + fragment;
}
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
index 8a929dd38a..496dba12ed 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
@@ -24,6 +24,7 @@
+
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
index 5ee339e6ee..1c5fd4cb22 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
@@ -206,13 +206,45 @@ public class LanguageSwitchRouteCultureReplacement_Tests
}
[Fact]
- public async Task Should_Not_Replace_Query_String_When_Only_One_Param()
+ public async Task Should_Replace_Culture_When_Only_Culture_Param_Present()
{
- // Only culture= without ui-culture= — should not replace
+ // culture= and ui-culture= are now handled independently
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr");
await _replacement.ReplaceAsync(context);
- context.ReturnUrl.ShouldBe("/?culture=tr");
+ context.ReturnUrl.ShouldBe("/?culture=en");
+ }
+
+ [Fact]
+ public async Task Should_Replace_UiCulture_When_Only_UiCulture_Param_Present()
+ {
+ var context = new QueryStringCultureReplacementContext(
+ new DefaultHttpContext(), new RequestCulture("en"), "/?ui-culture=tr");
+ await _replacement.ReplaceAsync(context);
+ context.ReturnUrl.ShouldBe("/?ui-culture=en");
+ }
+
+ [Fact]
+ public async Task Should_Support_Numeric_Region_Culture_Tag()
+ {
+ // es-419 (Latin America Spanish) contains a digit — previously the regex
+ // [A-Za-z-]+ would not match it, leaving the query string unreplaced.
+ var context = new QueryStringCultureReplacementContext(
+ new DefaultHttpContext(),
+ new RequestCulture("es-419", "es-419"),
+ "/home?culture=tr&ui-culture=tr");
+ await _replacement.ReplaceAsync(context);
+ context.ReturnUrl.ShouldBe("/home?culture=es-419&ui-culture=es-419");
+ }
+
+ [Fact]
+ public async Task Should_Replace_Only_First_Culture_Occurrence_In_Path()
+ {
+ // /en/products/en/details — the second "/en" is part of the path content,
+ // not a culture prefix, and must not be replaced.
+ var context = CreateContext("en", "tr", "/en/products/en/details");
+ await _replacement.ReplaceAsync(context);
+ context.ReturnUrl.ShouldBe("/tr/products/en/details");
}
private static QueryStringCultureReplacementContext CreateContext(
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs
new file mode 100644
index 0000000000..08706b3179
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs
@@ -0,0 +1,103 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Shouldly;
+using Volo.Abp.AspNetCore.Components.WebAssembly;
+using Volo.Abp.Localization;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Mvc.Localization;
+
+public class RouteBasedCultureNavigationHelper_Tests
+{
+ private static readonly IEnumerable AllLanguages = new[]
+ {
+ new LanguageInfo("en"),
+ new LanguageInfo("tr"),
+ new LanguageInfo("zh-Hans"),
+ };
+
+ private readonly RouteBasedCultureNavigationHelper _helper = new();
+
+ [Fact]
+ public async Task Should_Replace_Culture_In_Simple_Path()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/en/home");
+ }
+
+ [Fact]
+ public async Task Should_Replace_Culture_When_No_Path_After_Culture()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/en");
+ }
+
+ [Fact]
+ public async Task Should_Replace_Culture_When_Query_String_Follows_Culture_Directly()
+ {
+ // Regression: "tr?x=1" was being treated as a single segment "tr?x=1"
+ // instead of culture="tr" + suffix="?x=1".
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr?x=1");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/en?x=1");
+ }
+
+ [Fact]
+ public async Task Should_Replace_Culture_When_Fragment_Follows_Culture_Directly()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr#section");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/en#section");
+ }
+
+ [Fact]
+ public async Task Should_Replace_Culture_Preserving_Path_Query_And_Fragment()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/about?ref=main#top");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/about?ref=main#top");
+ }
+
+ [Fact]
+ public async Task Should_Prepend_Culture_When_No_Existing_Culture_Prefix()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/identity/users");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/identity/users");
+ }
+
+ [Fact]
+ public async Task Should_Prepend_Culture_When_At_Root()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages);
+ nav.LastNavigatedUri.ShouldBe("https://example.com/tr/");
+ }
+
+ [Fact]
+ public async Task Should_Not_Navigate_When_Target_Culture_Matches_Current()
+ {
+ var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home");
+ await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages);
+ // Already on /tr/home — no navigation should occur
+ nav.LastNavigatedUri.ShouldBeNull();
+ }
+
+ private sealed class TestNavigationManager : NavigationManager
+ {
+ public string? LastNavigatedUri { get; private set; }
+
+ public TestNavigationManager(string baseUri, string uri)
+ {
+ Initialize(baseUri, uri);
+ }
+
+ protected override void NavigateToCore(string uri, bool forceLoad)
+ {
+ LastNavigatedUri = uri;
+ }
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs
new file mode 100644
index 0000000000..b2e8ca129a
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading.Tasks;
+using NSubstitute;
+using Shouldly;
+using Volo.Abp.AspNetCore.Components.WebAssembly;
+using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
+using Volo.Abp.AspNetCore.Mvc.Client;
+using Volo.Abp.Localization;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Mvc.Localization;
+
+public class RouteBasedCultureUrlHelper_Tests
+{
+ private readonly ICachedApplicationConfigurationClient _configClient;
+ private readonly RouteBasedCultureUrlHelper _helper;
+ private readonly ApplicationConfigurationDto _config;
+
+ public RouteBasedCultureUrlHelper_Tests()
+ {
+ _config = new ApplicationConfigurationDto
+ {
+ Localization = new ApplicationLocalizationConfigurationDto
+ {
+ UseRouteBasedCulture = true,
+ Languages = new List
+ {
+ new LanguageInfo("en"),
+ new LanguageInfo("zh-Hans"),
+ new LanguageInfo("tr"),
+ new LanguageInfo("es-419"),
+ }
+ }
+ };
+
+ _configClient = Substitute.For();
+ _configClient.GetAsync().Returns(_config);
+
+ _helper = new RouteBasedCultureUrlHelper(_configClient);
+ }
+
+ [Theory]
+ [InlineData("https://auth-server.example.com/connect/authorize")]
+ [InlineData("http://example.com/login")]
+ public async Task Should_Not_Modify_Absolute_Urls(string url)
+ {
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync(url);
+ result.ShouldBe(url);
+ }
+
+ [Fact]
+ public async Task Should_Not_Modify_Protocol_Relative_Url()
+ {
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("//cdn.example.com/asset.js");
+ result.ShouldBe("//cdn.example.com/asset.js");
+ }
+
+ [Fact]
+ public async Task Should_Prepend_Culture_To_Root_Relative_Url()
+ {
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("/account/manage-profile");
+ result.ShouldBe("/zh-Hans/account/manage-profile");
+ }
+
+ [Fact]
+ public async Task Should_Prepend_Culture_To_Tilde_Slash_Url()
+ {
+ using var _ = CultureScope("tr");
+ var result = await _helper.PrependCulturePrefixAsync("~/account/manage-profile");
+ result.ShouldBe("~/tr/account/manage-profile");
+ }
+
+ [Fact]
+ public async Task Should_Prepend_Culture_To_Bare_Relative_Url()
+ {
+ // Default auth URLs like "authentication/login" have no leading slash.
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("authentication/login");
+ result.ShouldBe("zh-Hans/authentication/login");
+ }
+
+ [Fact]
+ public async Task Should_Not_Modify_Url_When_Feature_Disabled()
+ {
+ _config.Localization.UseRouteBasedCulture = false;
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("/home");
+ result.ShouldBe("/home");
+ }
+
+ [Fact]
+ public async Task Should_Not_Modify_Url_When_Culture_Not_In_Language_List()
+ {
+ using var _ = CultureScope("fr");
+ var result = await _helper.PrependCulturePrefixAsync("/home");
+ result.ShouldBe("/home");
+ }
+
+ [Fact]
+ public async Task Should_Return_Empty_String_Unchanged()
+ {
+ var result = await _helper.PrependCulturePrefixAsync(string.Empty);
+ result.ShouldBe(string.Empty);
+ }
+
+ [Fact]
+ public async Task Should_Support_Numeric_Region_Culture_Tag()
+ {
+ using var _ = CultureScope("es-419");
+ var result = await _helper.PrependCulturePrefixAsync("/home");
+ result.ShouldBe("/es-419/home");
+ }
+
+ [Fact]
+ public async Task Should_Be_Idempotent_On_Root_Relative_Url()
+ {
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("/zh-Hans/account/manage-profile");
+ result.ShouldBe("/zh-Hans/account/manage-profile");
+ }
+
+ [Fact]
+ public async Task Should_Be_Idempotent_On_Tilde_Slash_Url()
+ {
+ using var _ = CultureScope("tr");
+ var result = await _helper.PrependCulturePrefixAsync("~/tr/account/manage-profile");
+ result.ShouldBe("~/tr/account/manage-profile");
+ }
+
+ [Fact]
+ public async Task Should_Be_Idempotent_On_Bare_Relative_Url()
+ {
+ using var _ = CultureScope("zh-Hans");
+ var result = await _helper.PrependCulturePrefixAsync("zh-Hans/authentication/login");
+ result.ShouldBe("zh-Hans/authentication/login");
+ }
+
+ private static IDisposable CultureScope(string cultureName)
+ {
+ var previous = CultureInfo.CurrentCulture;
+ CultureInfo.CurrentCulture = new CultureInfo(cultureName);
+ return new DelegateDisposable(() => CultureInfo.CurrentCulture = previous);
+ }
+
+ private sealed class DelegateDisposable : IDisposable
+ {
+ private readonly System.Action _onDispose;
+ public DelegateDisposable(System.Action onDispose) => _onDispose = onDispose;
+ public void Dispose() => _onDispose();
+ }
+}
diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor
index f4f3e152ce..246682edf8 100644
--- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor
+++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor
@@ -1,25 +1,22 @@
-@inject NavigationManager Navigation
@using Volo.Abp.DependencyInjection
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic
-@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
+@using Volo.Abp.AspNetCore.Components.WebAssembly.Theming
@using Microsoft.Extensions.Options
+@using Volo.Abp.AspNetCore.Components.WebAssembly
@using Volo.Abp.AspNetCore.Components.Web
@inherits RedirectToLogin
@attribute [ExposeServices(typeof(RedirectToLogin))]
@attribute [Dependency(ReplaceServices = true)]
-@inject IOptions AuthenticationOptions
+@inject NavigationManager Navigation
+@inject IOptions AuthOptions
+@inject IRouteBasedCultureUrlHelper CultureUrlHelper
@inject IOptions AbpAspNetCoreComponentsWebOptions
@code {
- protected override void OnInitialized()
+ protected override void OnInitialized() { }
+
+ protected override Task OnInitializedAsync()
{
- if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp)
- {
- Navigation.NavigateTo(AuthenticationOptions.Value.LoginUrl, forceLoad: true);
- }
- else
- {
- Navigation.NavigateToLogin(AuthenticationOptions.Value.LoginUrl);
- }
+ return CultureAwareRedirectToLoginHelper.RedirectAsync(Navigation, AuthOptions.Value.LoginUrl, CultureUrlHelper, AbpAspNetCoreComponentsWebOptions);
}
}