Search code examples
c#blazorinternationalizationblazor-server-side

.Net 8 Blazor Web App (server) -> Start with browser language and allow user to change language via dropdown


What I want to achieve
I want my Blazor Web App (server rendering) to start with translations that fit to the browser language but also offer a language selection to the user to change the app language after the initial start.

Implementation for browser language
To retrieve the browser language, I added following code to my Program.cs file, following an example I found online:

builder.Services.AddLocalization();

app.UseRequestLocalization(options =>
{
    var supportedCultures = new List<CultureInfo>
    {
        new CultureInfo("en-US"),
        new CultureInfo("de-DE"),
    };

    options.DefaultRequestCulture = new RequestCulture(supportedCultures.First());
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
    options.RequestCultureProviders.Clear();
    options.RequestCultureProviders.Insert(0, new CustomRequestCultureProvider(async context =>
    {
        var userLanguages = context.Request.Headers["Accept-Language"].ToString();
        var primaryLanguage = userLanguages.Split(',').FirstOrDefault();
        if (primaryLanguage == "de") primaryLanguage = "de-DE";

        var userCulture = new CultureInfo(primaryLanguage);
        return await Task.FromResult(new ProviderCultureResult(userCulture?.Name ?? supportedCultures[0].Name));
    }));
});

which works absolutly fine: my app always starts corresponding to the browser language

Implementation for language selection
The implementation for language selection, also based on an online example, looks like:

Program.cs:

builder.Services.AddLocalization();
builder.Services.AddControllers();

var defaultCulture = new CultureInfo("en-US");
string[] supportedCultures = ["en-US", "de-DE"];
var localizationOptions = new RequestLocalizationOptions()
    .SetDefaultCulture(supportedCultures[0])
    .AddSupportedCultures(supportedCultures)
    .AddSupportedUICultures(supportedCultures);
app.UseRequestLocalization(localizationOptions);

CultureController:

[Route("[controller]/[action]")]
public class CultureController : Controller
{
    public IActionResult Set(string culture, string redirectUri)
    {
        if (culture != null)
        {
            var requestCulture = new RequestCulture(culture, culture);
            var cookieName = CookieRequestCultureProvider.DefaultCookieName;
            var cookieValue = CookieRequestCultureProvider.MakeCookieValue(requestCulture);

            HttpContext.Response.Cookies.Append(cookieName, cookieValue);
        }
        return Redirect(redirectUri);
        return LocalRedirect(redirectUri);
    }
}

CultureSelector.razor:

@inject NavigationManager Navigation

<div>
    <select @bind="Culture">
        <option value="en-US">English</option>
        <option value="de-DE">Deutsch</option>
    </select>
</div>

@code {
    protected override void OnInitialized()
    {
        Culture = CultureInfo.CurrentCulture;
    }

    private CultureInfo Culture
    {
        get
        {
            return CultureInfo.CurrentCulture;
        }
        set
        {
            if(CultureInfo.CurrentCulture != value)
            {
                var uri = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
                var cultureEscaped = Uri.EscapeDataString(value.Name);
                var uriEscaped = Uri.EscapeDataString(uri);

                Navigation.NavigateTo($"Culture/Set?culture={cultureEscaped}&redirectUri={uriEscaped}", forceLoad: true);
            }
        }
    }
}

and using the CultureSelector.razor component in another razor component with:

<CultureSelector />

and of course making use of IStringLocalizer to really show the translation fetched from corresponding Resx files. This implementation also works perfectly fine: Choosing a different language in the selector changes the translation in the UI.

Where everything goes wrong
If I keep the above implementations separated, so either I am using browser language OR I allow the user to change the language, both implementations work perfectly. But if I want to have both functionalities at the same time, the user selection is always overwritten by the browser language. In debugger, I see that after selecting a different language it is still overwritting the langauge settings but after forcing the reload it also jumps into the CustomRequestCultureProvider which then overwrites language with the browser language.

My questions
Why does the CustomRequestCultureProvider is triggered after user changed the language? Is it because of the forced reload? Do both implementation concepts are even supposed to work together? If not, what is the desired solution to support initial load with browser language and still allow the user to choose the application language?

Thanks in advance!


Solution

  • Solution found
    So Tiny Wang's answer, and following the example in the official documentation, led me in the right direction, but there was another misunderstanding / issue that is missing in the answer. So I will share the final implementation in my own answer but also give additional information to what was happening.

    Code solution

    Program.cs:

    builder.Services.AddLocalization();
    string[] supportedCultures = ["en", "de", "en-US", "de-DE"];
    var localizationOptions = new RequestLocalizationOptions()
        .SetDefaultCulture(supportedCultures[0])
        .AddSupportedCultures(supportedCultures)
        .AddSupportedUICultures(supportedCultures);
    app.UseRequestLocalization(localizationOptions);
    

    App.razor:

    @code
    {
        protected override void OnInitialized()
        {
            var httpContext = httpContextAccessor.HttpContext;
            if (httpContext != null)
            {
                CultureInfo cultureInfo = CultureInfo.CurrentCulture;
                if (cultureInfo.Name == "de")
                    cultureInfo = new CultureInfo("de-DE");
                if (cultureInfo.Name == "en-US")
                    cultureInfo = new CultureInfo("en");
                if (cultureInfo.Name == "en-GB")
                    cultureInfo = new CultureInfo("en");
    
                httpContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(
                    new RequestCulture(
                        cultureInfo,
                        cultureInfo)));
            }
        }
    }
    

    CultureSelector.razor

    @inject NavigationManager Navigation
    
    <div>
        <select @bind="Culture">
            <option value="en">English</option>
            <option value="de-DE">Deutsch</option>
        </select>
    </div>
    
    @code {
    
        protected override void OnInitialized()
        {
            Culture = CultureInfo.CurrentCulture;
        }
    
        private CultureInfo Culture
        {
            get
            {
                return CultureInfo.CurrentCulture;
            }
            set
            {
                if (CultureInfo.CurrentCulture != value)
                {
                    var uri = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
                    var cultureEscaped = Uri.EscapeDataString(value.Name);
                    var uriEscaped = Uri.EscapeDataString(uri);
    
                    Navigation.NavigateTo($"Culture/Set?culture={cultureEscaped}&redirectUri={uriEscaped}", forceLoad: true);
                }
            }
        }
    }
    

    CultureController.cs:

    using Microsoft.AspNetCore.Localization;
    using Microsoft.AspNetCore.Mvc;
    
    namespace ProductExplorer.Controllers
    {
        [Route("[controller]/[action]")]
        public class CultureController : Controller
        {
            public IActionResult Set(string culture, string redirectUri)
            {
                if (culture != null)
                {
                    var requestCulture = new RequestCulture(culture, culture);
                    var cookieName = CookieRequestCultureProvider.DefaultCookieName;
                    var cookieValue = CookieRequestCultureProvider.MakeCookieValue(requestCulture);
    
                    HttpContext.Response.Cookies.Append(cookieName, cookieValue);
                }
    
                return LocalRedirect(redirectUri);
            }
        }
    }
    

    and of course a line where I call the CultureSelector in a razor component:

    <CultureSelector />
    

    What is different now?
    So the major difference is, that the what I called "Implementation for browser language" is gone. So the whole part of

    app.UseRequestLocalization(options =>
    {
        var supportedCultures = new List<CultureInfo>
        {
            new CultureInfo("en-US"),
            new CultureInfo("de-DE"),
        };
    
        options.DefaultRequestCulture = new RequestCulture(supportedCultures.First());
        options.SupportedCultures = supportedCultures;
        options.SupportedUICultures = supportedCultures;
        options.RequestCultureProviders.Clear();
        options.RequestCultureProviders.Insert(0, new CustomRequestCultureProvider(async context =>
        {
            var userLanguages = context.Request.Headers["Accept-Language"].ToString();
            var primaryLanguage = userLanguages.Split(',').FirstOrDefault();
            if (primaryLanguage == "de") primaryLanguage = "de-DE";
    
            var userCulture = new CultureInfo(primaryLanguage);
            return await Task.FromResult(new ProviderCultureResult(userCulture?.Name ?? supportedCultures[0].Name));
        }));
    });
    

    is not needed. That's where Tiny Wang's answer was on spot.

    BUT
    some small details have changed in the following lines in Program.cs:

    string[] supportedCultures = ["en", "de", "en-US", "de-DE"];
    

    and the App.razor received an OnInitialized() implemenation which is completly new. What I also added here is the mapping of the different supported cultures:

    protected override void OnInitialized()
    {
        var httpContext = httpContextAccessor.HttpContext;
        if (httpContext != null)
        {
            CultureInfo cultureInfo = CultureInfo.CurrentCulture;
            if (cultureInfo.Name == "de")
                cultureInfo = new CultureInfo("de-DE");
            if (cultureInfo.Name == "en-US")
                cultureInfo = new CultureInfo("en");
            if (cultureInfo.Name == "en-GB")
                cultureInfo = new CultureInfo("en");
    
            httpContext.Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,
            CookieRequestCultureProvider.MakeCookieValue(
                new RequestCulture(
                    cultureInfo,
                    cultureInfo)));
        }
    }
    

    The root of all evil (or why did I add supported languages)
    Having a look into the browsers supported languages, we see that e.g. Firefox differentiates between neutral and country specific languages like
    "en" and "en-US" or
    "de" and "de-DE":

    Firefox language configuration

    and those will be treated as cultures in C#. So if my preferred language is "de" (not "de-DE") the value of

    CurrentCulture.CultureInfo
    

    is "de". If this is not in the array/list of your supported cultures (which wasn't in my original question):

    string[] supportedCultures = ["en-US", "de-DE"];
    

    the configured default culture will be taken, which was defined as

    SetDefaultCulture(supportedCultures[0])
    

    which was "en-US". So the solution to the real root cause is to make sure the web app supports all the languages (cultures) you expect to be configured in your users browsers.

    Small additional information
    The App.razor code block contains some mapping like

    CultureInfo cultureInfo = CultureInfo.CurrentCulture;
    if (cultureInfo.Name == "de")
        cultureInfo = new CultureInfo("de-DE");
    if (cultureInfo.Name == "en-US")
        cultureInfo = new CultureInfo("en");
    if (cultureInfo.Name == "en-GB")
        cultureInfo = new CultureInfo("en");
    

    Why? Because I use resource files. If I would not map from e.g. "de" to "de-DE", it would mean that I have to create a resource file for culture "de" too. Like:
    Resource.de-DE.resx
    Resource.de.resx
    In my application I do not differentiate in translations between "de" and "de-DE" so I do the culture mapping to avoid additional resource files that are redundant for me. Additionally I set the supported default culture to "en" as my resx default culture is also always "en".