Skip to content
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ and are derived from git tags by MinVer (see [the published versioning policy](h

## [Unreleased]

### Fixed

- **Language switching on MAUI Blazor Hybrid heads no longer sticks to the launch language**
(`MMCA.Common.UI`, ADR-027 Decision 10). MudBlazor 9.7+ reads its built-in English strings by
assigning `CultureInfo.CurrentUICulture` (invariant, then the previous value back), and that
restore writes an `AsyncLocal` culture into the calling context. On a hybrid head the renderer
dispatches on the main thread, which nothing resets, so the first MudBlazor chrome string a page
rendered (a pager, a bell badge, a dialog close button) pinned the app: `MauiCultureApplier` set
the thread defaults and reloaded the WebView, and the pinned `AsyncLocal` still won. Anonymous
landing pages render little MudBlazor chrome, which is why the symptom surfaced as "switching
works signed out but not signed in". `AddUIShared` now installs `InvariantMudLocalizationInterceptor`
through MudBlazor's `AddLocalizationInterceptor`: the same resolution order as the default (English
or no `MudLocalizer` reads the built-ins, otherwise `MudTranslations` with a built-in fallback),
reading the built-ins through a `ResourceManager` under an explicit invariant culture instead of a
culture swap. Web heads are unaffected. A canary test pins the upstream behaviour so the
replacement can be retired when MudBlazor stops assigning the culture.

## [1.202.0] - 2026-09-13

### Changed
Expand Down
10 changes: 10 additions & 0 deletions Source/Presentation/MMCA.Common.UI/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using MMCA.Common.UI.Services.Navigation;
using MMCA.Common.UI.Services.Preferences;
using MMCA.Common.UI.Theme;
using MudBlazor.Services;

namespace MMCA.Common.UI;

Expand Down Expand Up @@ -77,6 +78,15 @@ public IServiceCollection AddUIShared(IConfiguration configuration)
// authoritative regardless of host registration order.
services.TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>();

// MudBlazor's default interceptor reads its built-in English strings by assigning
// CultureInfo.CurrentUICulture (invariant, then the previous value back), which writes an
// AsyncLocal culture into the calling context. On a MAUI hybrid head that is the main
// thread, nothing resets it, and the app is pinned to its launch language: a switch sets
// the thread defaults and reloads, and the pinned AsyncLocal still wins. This interceptor
// reads the same resource under an explicit invariant culture instead. Replace semantics,
// so it wins whether AddMudServices ran before or after this call (ADR-027 Decision 10).
services.AddLocalizationInterceptor<InvariantMudLocalizationInterceptor>();

// Auth handler injects Bearer token into every outgoing API request; culture handler forwards
// the active UI culture as Accept-Language so the API localizes error messages to match.
services.AddTransient<AuthDelegatingHandler>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Collections;
using System.Globalization;
using System.Resources;
using Microsoft.Extensions.Localization;
using MudBlazor;

namespace MMCA.Common.UI.Globalization;

/// <summary>
/// The MudBlazor <see cref="ILocalizationInterceptor"/> that <c>AddUIShared</c> installs in place of
/// MudBlazor's <c>DefaultLocalizationInterceptor</c> (ADR-027). The resolution order is the default
/// one: an English UI culture, or no <see cref="MudLocalizer"/>, reads MudBlazor's built-in strings;
/// any other culture asks the <see cref="MudLocalizer"/> (<see cref="ResxMudLocalizer"/> here) and
/// falls back to the built-in string when the key is untranslated. The one difference is HOW the
/// built-in strings are read.
/// <para>
/// MudBlazor 9.7+ reads them by assigning <see cref="CultureInfo.CurrentUICulture"/> to the invariant
/// culture and then assigning the previous value back. Both assignments write the culture into an
/// <c>AsyncLocal</c>, and that "restore" leaves the calling thread's <c>ExecutionContext</c> carrying
/// an explicit culture from then on. On a MAUI Blazor Hybrid head the renderer dispatches on the
/// process's main thread, whose context nothing ever resets, so the first MudBlazor chrome string a
/// page renders pins the app to the language it launched in: a later switch sets the thread defaults
/// (the only thing <c>MauiCultureApplier</c> may set, see ADR-027 Decision 10), reloads the WebView,
/// and still renders the old language because the pinned <c>AsyncLocal</c> wins over the defaults.
/// A web head never notices: request localization sets the culture per request and a browser reload
/// tears the WASM runtime down, so nothing long-lived is pinned.
/// </para>
/// <para>
/// This interceptor reads the same embedded resource through a <see cref="ResourceManager"/> with an
/// explicit invariant culture, which is what MudBlazor's swap was for (no satellite-assembly probing
/// under non-English cultures) without any culture assignment. Registered with MudBlazor's
/// <c>AddLocalizationInterceptor</c>, which replaces the default regardless of whether
/// <c>AddMudServices</c> ran before or after <c>AddUIShared</c>.
/// </para>
/// </summary>
/// <param name="mudLocalizer">The app-supplied translations; <see langword="null"/> when none is registered.</param>
internal sealed class InvariantMudLocalizationInterceptor(MudLocalizer? mudLocalizer = null)
: AbstractLocalizationInterceptor(BuiltInStrings.Instance, mudLocalizer)
{
/// <inheritdoc />
public override LocalizedString Handle(string key, params object[] arguments)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(arguments);

// Same test MudBlazor's default applies: the parent's language, so "en-US" counts as English
// while the neutral "en" (whose parent is the invariant culture) goes through the MudLocalizer
// like every other culture and lands on the built-in string only as a fallback.
var isEnglish = string.Equals(
CultureInfo.CurrentUICulture.Parent.TwoLetterISOLanguageName,
"en",
StringComparison.OrdinalIgnoreCase);

if (MudLocalizer is null || isEnglish)
{
return BuiltIn(key, arguments);
}

var translated = MudLocalizer[key, arguments];
return translated.ResourceNotFound ? BuiltIn(key, arguments) : translated;
}

// The no-argument indexer is deliberate: formatting a value that contains literal braces with an
// empty argument list would throw, and MudBlazor's own strings are looked up that way too.
private LocalizedString BuiltIn(string key, object[] arguments) =>
arguments.Length > 0 ? Localizer[key, arguments] : Localizer[key];

/// <summary>
/// MudBlazor's built-in English strings, read from its embedded <c>LanguageResource</c> under the
/// invariant culture. The resource is addressed by manifest name because the generated
/// <c>LanguageResource</c> class is internal to MudBlazor.
/// </summary>
private sealed class BuiltInStrings : IStringLocalizer
{
private const string ResourceBaseName = "MudBlazor.Resources.LanguageResource";

private static readonly ResourceManager Resources = new(ResourceBaseName, typeof(MudLocalizer).Assembly);

private BuiltInStrings()
{
}

public static BuiltInStrings Instance { get; } = new();

public LocalizedString this[string name]
{
get
{
var value = Resources.GetString(name, CultureInfo.InvariantCulture);
return new LocalizedString(name, value ?? name, resourceNotFound: value is null);
}
}

public LocalizedString this[string name, params object[] arguments]
{
get
{
var format = Resources.GetString(name, CultureInfo.InvariantCulture);
var value = format is null
? name
: string.Format(CultureInfo.CurrentCulture, format, arguments);
return new LocalizedString(name, value, resourceNotFound: format is null);
}
}

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var set = Resources.GetResourceSet(CultureInfo.InvariantCulture, createIfNotExists: true, tryParents: true);
if (set is null)
{
yield break;
}

foreach (DictionaryEntry entry in set)
{
if (entry.Key is string key && entry.Value is string value)
{
yield return new LocalizedString(key, value, resourceNotFound: false);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Globalization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using MMCA.Common.UI.Globalization;
using MudBlazor;
using MudBlazor.Services;

namespace MMCA.Common.UI.Tests.Globalization;

/// <summary>
/// Proves the MudBlazor localization interceptor replacement (ADR-027 Decision 10): MudBlazor chrome
/// resolves exactly as it did through MudBlazor's default interceptor, but reading a built-in English
/// string never assigns <see cref="CultureInfo.CurrentUICulture"/>. That assignment is what pins a
/// MAUI hybrid head to its launch language: the renderer's thread carries the written
/// <c>AsyncLocal</c> forever, and a later switch of the thread defaults cannot override it.
/// </summary>
public sealed class InvariantMudLocalizationInterceptorTests
{
private const string BuiltInKey = "MudDataGridPager_RowsPerPage";

private static ServiceProvider BuildProvider()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Api:ApiEndpoint"] = "https://localhost:6001" })
.Build();

var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging();
services.AddMudServices();
services.AddUIShared(configuration);

return services.BuildServiceProvider();
}

// Runs the probe on a dedicated thread so the culture assigned for the probe cannot leak into the
// test runner's context, and so the ExecutionContext comparison sees only what the probe wrote.
private static (ExecutionContext? Before, ExecutionContext? After, LocalizedString Result) ProbeUnderEnglish(
ILocalizationInterceptor interceptor,
string key)
{
ExecutionContext? before = null;
ExecutionContext? after = null;
LocalizedString? result = null;

var thread = new Thread(() =>
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
before = ExecutionContext.Capture();
result = interceptor.Handle(key);
after = ExecutionContext.Capture();
});

thread.Start();
thread.Join();

return (before, after, result!);
}

[Fact]
public void AddUIShared_ReplacesMudBlazorDefaultInterceptor()
{
using var provider = BuildProvider();

var interceptor = provider.GetRequiredService<ILocalizationInterceptor>();

Assert.IsType<InvariantMudLocalizationInterceptor>(interceptor);
}

[Fact]
public void Handle_ReadsBuiltInEnglish_WithoutWritingTheAsyncLocalCulture()
{
using var provider = BuildProvider();
var interceptor = provider.GetRequiredService<ILocalizationInterceptor>();

var (before, after, result) = ProbeUnderEnglish(interceptor, BuiltInKey);

Assert.False(result.ResourceNotFound);
Assert.Equal("Rows per page:", result.Value);

// An unchanged ExecutionContext instance is the proof: any AsyncLocal write, including one that
// "restores" the previous culture, produces a new context.
Assert.Same(before, after);
}

// Canary for the upstream behaviour this interceptor exists to avoid. MudBlazor's default reads
// the built-in strings through an assign-invariant-then-restore of CurrentUICulture, and the
// restore leaves the thread with an explicit AsyncLocal culture. When this test starts failing,
// MudBlazor stopped assigning the culture and the replacement can be retired.
[Fact]
public void MudBlazorDefaultInterceptor_WritesTheAsyncLocalCulture_WhichIsWhyItIsReplaced()
{
var mudDefault = new DefaultLocalizationInterceptor(NullLoggerFactory.Instance, mudLocalizer: null);

var (before, after, result) = ProbeUnderEnglish(mudDefault, BuiltInKey);

Assert.Equal("Rows per page:", result.Value);
Assert.NotSame(before, after);
}

[Fact]
public void Handle_UsesMudTranslations_ForSpanish_AndFallsBackToBuiltInForUnknownKey()
{
using var provider = BuildProvider();
var interceptor = provider.GetRequiredService<ILocalizationInterceptor>();
var original = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("es");

var translated = interceptor.Handle(BuiltInKey);
Assert.False(translated.ResourceNotFound);
Assert.Equal("Filas por página:", translated.Value);

var unknown = interceptor.Handle("NotARealMudBlazorKey");
Assert.True(unknown.ResourceNotFound);
Assert.Equal("NotARealMudBlazorKey", unknown.Value);
}
finally
{
CultureInfo.CurrentUICulture = original;
}
}

[Fact]
public void Handle_FormatsArguments_IntoBuiltInStrings()
{
using var provider = BuildProvider();
var interceptor = provider.GetRequiredService<ILocalizationInterceptor>();

var (_, _, result) = ProbeUnderEnglish(interceptor, "MudPagination_CurrentPage");
Assert.Equal("Current page {0}", result.Value);

var original = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
Assert.Equal("Current page 3", interceptor.Handle("MudPagination_CurrentPage", 3).Value);
}
finally
{
CultureInfo.CurrentUICulture = original;
}
}

[Fact]
public void Handle_TreatsNeutralEnglishLikeAnyOtherCulture_AndStillResolves()
{
using var provider = BuildProvider();
var interceptor = provider.GetRequiredService<ILocalizationInterceptor>();
var original = CultureInfo.CurrentUICulture;
try
{
// The neutral "en" has the invariant culture as its parent, so MudBlazor's English shortcut
// does not apply and the MudTranslations pair answers (its neutral resx is English).
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en");
var result = interceptor.Handle(BuiltInKey);

Assert.False(result.ResourceNotFound);
Assert.Equal("Rows per page:", result.Value);
}
finally
{
CultureInfo.CurrentUICulture = original;
}
}
}