Skip to content

Make Blazor WASM respect current UI culture #62905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;

namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting;
Expand Down Expand Up @@ -62,7 +63,7 @@ public virtual async ValueTask LoadCurrentCultureResourcesAsync()
throw new PlatformNotSupportedException("This method is only supported in the browser.");
}

var culturesToLoad = GetCultures(CultureInfo.CurrentCulture);
var culturesToLoad = GetCultures(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);

if (culturesToLoad.Length == 0)
{
Expand All @@ -72,30 +73,47 @@ public virtual async ValueTask LoadCurrentCultureResourcesAsync()
await WebAssemblyCultureProviderInterop.LoadSatelliteAssemblies(culturesToLoad);
}

internal static string[] GetCultures(CultureInfo cultureInfo)
internal static string[] GetCultures(CultureInfo cultureInfo, CultureInfo? uiCultureInfo = null)
{
var culturesToLoad = new List<string>();

// Once WASM is ready, we have to use .NET's assembly loading to load additional assemblies.
// First calculate all possible cultures that the application might want to load. We do this by
// starting from the current culture and walking up the graph of parents.
// At the end of the the walk, we'll have a list of culture names that look like
// [ "fr-FR", "fr" ]
while (cultureInfo != null && cultureInfo != CultureInfo.InvariantCulture)
{
culturesToLoad.Add(cultureInfo.Name);

if (cultureInfo.Parent == cultureInfo)
var culturesToLoad = GetCultureHierarchy(cultureInfo);
if (cultureInfo != uiCultureInfo)
{
foreach (var culture in GetCultureHierarchy(uiCultureInfo))
{
break;
if (!culturesToLoad.Contains(culture))
{
culturesToLoad = culturesToLoad.Append(culture);
}
// If the culture is in the list, we can break because we found the common parent.
else
{
break;
}
}

cultureInfo = cultureInfo.Parent;
}

return culturesToLoad.ToArray();
}

private static IEnumerable<string> GetCultureHierarchy(CultureInfo? culture)
{
while (culture != CultureInfo.InvariantCulture && culture != null)
{
yield return culture.Name;
if (culture == culture.Parent)
{
break;
}
culture = culture.Parent;
}
}

private partial class WebAssemblyCultureProviderInterop
{
[JSImport("INTERNAL.loadSatelliteAssemblies")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ public void GetCultures_ReturnsCultureClosure(string cultureName, string[] expec
Assert.Equal(expected, actual);
}

[Theory]
[InlineData("fr-FR", "tzm-Latn-DZ", new[] { "fr-FR", "fr", "tzm-Latn-DZ", "tzm-Latn", "tzm" })]
[InlineData("en-US", "en-GB", new[] { "en-US", "en", "en-GB" })]
[InlineData("fr-FR", null, new[] { "fr-FR", "fr" })]
public void GetCultures_ReturnCultureClosureWithUICulture(string cultureName, string uiCultureName, string[] expected)
{
// Arrange
var culture = cultureName != null ? new CultureInfo(cultureName) : null;
var uiCulture = uiCultureName != null ? new CultureInfo(uiCultureName) : null;
// Act
var actual = WebAssemblyCultureProvider.GetCultures(culture, uiCulture);
// Assert
Assert.Equal(expected, actual);
}

[Fact]
public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICUShardingIsUsed()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,23 @@ public void CanSetCultureAndReadLocalizedResources(string culture, string messag
var messageDisplay = Browser.Exists(By.Id("message-display"));
Assert.Equal(message, messageDisplay.Text);
}

[Theory]
[InlineData("en-US", "fr-FR", "Bonjour!")]
[InlineData("fr-FR", "en-US", "Hello!")]
public void CanSetCultureAndDifferentiateBetweenCurrentAndUICulture(string culture, string cultureUI, string message)
{
Navigate($"{ServerPathBase}/?culture={culture}&cultureUI={cultureUI}");

Browser.MountTestComponent<LocalizedText>();

var cultureDisplay = Browser.Exists(By.Id("culture-name-display"));
Assert.Equal($"Culture is: {culture}", cultureDisplay.Text);

var cultureUIDisplay = Browser.Exists(By.Id("culture-ui-display"));
Assert.Equal($"CultureUI is: {cultureUI}", cultureUIDisplay.Text);

var messageDisplay = Browser.Exists(By.Id("message-display"));
Assert.Equal(message, messageDisplay.Text);
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
<h3 id="culture-name-display">Culture is: @System.Globalization.CultureInfo.CurrentCulture.Name</h3>
<h3 id="culture-ui-display">CultureUI is: @System.Globalization.CultureInfo.CurrentUICulture.Name</h3>
<p id="message-display">@Resources.Message</p>
9 changes: 8 additions & 1 deletion src/Components/test/testassets/BasicTestApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ private static void ConfigureCulture(WebAssemblyHost host)
{
// In the absence of a specified value, we want the culture to be en-US so that the tests for bind can work consistently.
var culture = new CultureInfo("en-US");
var cultureUI = new CultureInfo("en-US");

Uri uri = null;
try
Expand All @@ -77,12 +78,18 @@ private static void ConfigureCulture(WebAssemblyHost host)
if (uri != null && HttpUtility.ParseQueryString(uri.Query)["culture"] is string cultureName)
{
culture = new CultureInfo(cultureName);
cultureUI = culture; // Default to the same culture for UI if not specified
}

if (uri != null && HttpUtility.ParseQueryString(uri.Query)["cultureUI"] is string cultureUIName)
{
cultureUI = new CultureInfo(cultureUIName);
}

// CultureInfo.CurrentCulture is async-scoped and will not affect the culture in sibling scopes.
// Use CultureInfo.DefaultThreadCurrentCulture instead to modify the application's default scope.
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = cultureUI;
}

// Supports E2E tests in StartupErrorNotificationTest
Expand Down
Loading