Detect culture change in .NET (#26192)

* Detect culture change in .NET

With ICU sharding enabled, blazor wasm attempts to detect if the application culture was changed by the
application code as part of Program.MainAsync and tell them they need to opt out of sharding.

Prior to this change, Blazor compared the .NET culture string with a JS representation for language. With iOS 14,
the two culture strings differ in casing which prevents the use of any Blazor WASM app the latest version installed.

As part of this change, the comparison is performed entirely in .NET which avoids relying on the JS representation.

* Fixups
This commit is contained in:
Pranav K 2020-09-23 09:00:38 -07:00 committed by GitHub
parent da3f97b0ad
commit de1bf0abe4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 99 additions and 26 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -309,11 +309,6 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
const satelliteResources = resourceLoader.bootConfig.resources.satelliteResources;
const applicationCulture = resourceLoader.startOptions.applicationCulture || (navigator.languages && navigator.languages[0]);
if (resourceLoader.bootConfig.icuDataMode == ICUDataMode.Sharded && culturesToLoad && culturesToLoad[0] !== applicationCulture) {
// We load an initial icu file based on the browser's locale. However if the application's culture requires a different set, flag this as an error.
throw new Error('To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application\'s project file.');
}
if (satelliteResources) {
const resourcePromises = Promise.all(culturesToLoad
.filter(culture => satelliteResources.hasOwnProperty(culture))
@ -404,6 +399,14 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
}
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
if (resourceLoader.bootConfig.icuDataMode === ICUDataMode.Sharded) {
MONO.mono_wasm_setenv('__BLAZOR_SHARDED_ICU', '1');
if (resourceLoader.startOptions.applicationCulture) {
// If a culture is specified via start options use that to initialize the Emscripten \ .NET culture.
MONO.mono_wasm_setenv('LANG', `${resourceLoader.startOptions.applicationCulture}.UTF-8`);
}
}
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
let timeZone = "UTC";
try {
@ -521,7 +524,7 @@ async function loadTimezone(timeZoneResource: LoadingResource): Promise<void> {
function getICUResourceName(bootConfig: BootJsonData, culture: string | undefined): string {
const combinedICUResourceName = 'icudt.dat';
if (!culture || bootConfig.icuDataMode == ICUDataMode.All) {
if (!culture || bootConfig.icuDataMode === ICUDataMode.All) {
return combinedICUResourceName;
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@ -17,6 +18,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// do change this it will be non-breaking.
public static async void InvokeEntrypoint(string assemblyName, string[] args)
{
WebAssemblyCultureProvider.Initialize();
try
{
var assembly = Assembly.Load(assemblyName);

View File

@ -1,17 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{
internal class SatelliteResourcesLoader
internal class WebAssemblyCultureProvider
{
internal const string GetSatelliteAssemblies = "window.Blazor._internal.getSatelliteAssemblies";
internal const string ReadSatelliteAssemblies = "window.Blazor._internal.readSatelliteAssemblies";
@ -19,9 +19,44 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
private readonly WebAssemblyJSRuntimeInvoker _invoker;
// For unit testing.
internal SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker invoker)
internal WebAssemblyCultureProvider(WebAssemblyJSRuntimeInvoker invoker, CultureInfo initialCulture, CultureInfo initialUICulture)
{
_invoker = invoker;
InitialCulture = initialCulture;
InitialUICulture = initialUICulture;
}
public static WebAssemblyCultureProvider Instance { get; private set; }
public CultureInfo InitialCulture { get; }
public CultureInfo InitialUICulture { get; }
internal static void Initialize()
{
Instance = new WebAssemblyCultureProvider(
WebAssemblyJSRuntimeInvoker.Instance,
initialCulture: CultureInfo.CurrentCulture,
initialUICulture: CultureInfo.CurrentUICulture);
}
public void ThrowIfCultureChangeIsUnsupported()
{
// With ICU sharding enabled, bootstrapping WebAssembly will download a ICU shard based on the browser language.
// If the application author was to change the culture as part of their Program.MainAsync, we might have
// incomplete icu data for their culture. We would like to flag this as an error and notify the author to
// use the combined icu data file instead.
//
// The Initialize method is invoked as one of the first steps bootstrapping the app prior to any user code running.
// It allows us to capture the initial .NET culture that is configured based on the browser language.
// The current method is invoked as part of WebAssemblyHost.RunAsync i.e. after user code in Program.MainAsync has run
// thus allows us to detect if the culture was changed by user code.
if (Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" &&
((CultureInfo.CurrentCulture != InitialCulture) || (CultureInfo.CurrentUICulture != InitialUICulture)))
{
throw new InvalidOperationException("Blazor detected a change in the application's culture that is not supported with the current project configuration. " +
"To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application's project file.");
}
}
public virtual async ValueTask LoadCurrentCultureResourcesAsync()

View File

@ -59,7 +59,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
/// </summary>
public IServiceProvider Services => _scope.ServiceProvider;
internal SatelliteResourcesLoader SatelliteResourcesLoader { get; set; } = new SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker.Instance);
internal WebAssemblyCultureProvider CultureProvider { get; set; } = WebAssemblyCultureProvider.Instance;
/// <summary>
/// Disposes the host asynchronously.
@ -121,11 +121,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
_started = true;
CultureProvider.ThrowIfCultureChangeIsUnsupported();
// EntryPointInvoker loads satellite assemblies for the application default culture.
// Application developers might have configured the culture based on some ambient state
// such as local storage, url etc as part of their Program.Main(Async).
// This is the earliest opportunity to fetch satellite assemblies for this selection.
await SatelliteResourcesLoader.LoadCurrentCultureResourcesAsync();
await CultureProvider.LoadCurrentCultureResourcesAsync();
var tcs = new TaskCompletionSource<object>();

View File

@ -1,18 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.IO;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.AspNetCore.Testing;
using Moq;
using Xunit;
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.SatelliteResourcesLoader;
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.WebAssemblyCultureProvider;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{
public class SatelliteResourcesLoaderTest
public class WebAssemblyCultureProviderTest
{
[Theory]
[InlineData("fr-FR", new[] { "fr-FR", "fr" })]
@ -23,7 +25,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
var culture = new CultureInfo(cultureName);
// Act
var actual = SatelliteResourcesLoader.GetCultures(culture);
var actual = WebAssemblyCultureProvider.GetCultures(culture);
// Assert
Assert.Equal(expected, actual);
@ -43,7 +45,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
.Returns(new object[] { File.ReadAllBytes(GetType().Assembly.Location) })
.Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object);
var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
// Act
await loader.LoadCurrentCultureResourcesAsync();
@ -62,7 +64,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
.Returns(Task.FromResult<object>(0))
.Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object);
var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
// Act
await loader.LoadCurrentCultureResourcesAsync();
@ -70,5 +72,29 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Assert
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, object[]>(ReadSatelliteAssemblies, null, null, null), Times.Never());
}
[Fact]
public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICUShardingIsUsed()
{
// Arrange
Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1");
try
{
// WebAssembly is initialized with en-US
var cultureProvider = new WebAssemblyCultureProvider(WebAssemblyJSRuntimeInvoker.Instance, new CultureInfo("en-US"), new CultureInfo("en-US"));
// Culture is changed to fr-FR as part of the app
using var cultureReplacer = new CultureReplacer("fr-FR");
var ex = Assert.Throws<InvalidOperationException>(() => cultureProvider.ThrowIfCultureChangeIsUnsupported());
Assert.Equal("Blazor detected a change in the application's culture that is not supported with the current project configuration. " +
"To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application's project file.",
ex.Message);
}
finally
{
Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null);
}
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
host.CultureProvider = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource();
@ -40,7 +41,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
host.CultureProvider = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource();
var task = host.RunAsyncCore(cts.Token);
@ -62,7 +63,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
builder.Services.AddSingleton<DisposableService>();
var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
host.CultureProvider = new TestSatelliteResourcesLoader();
var disposable = host.Services.GetRequiredService<DisposableService>();
@ -92,10 +93,10 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
}
}
private class TestSatelliteResourcesLoader : SatelliteResourcesLoader
private class TestSatelliteResourcesLoader : WebAssemblyCultureProvider
{
internal TestSatelliteResourcesLoader()
: base(WebAssemblyJSRuntimeInvoker.Instance)
: base(WebAssemblyJSRuntimeInvoker.Instance, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)
{
}

View File

@ -14,5 +14,4 @@
<Reference Include="Microsoft.Extensions.Localization" />
</ItemGroup>
</Project>

View File

@ -29,7 +29,11 @@ namespace GlobalizationWasmApp
{
var uri = new Uri(host.Services.GetService<NavigationManager>().Uri);
var cultureName = HttpUtility.ParseQueryString(uri.Query)["dotNetCulture"] ?? HttpUtility.ParseQueryString(uri.Query)["culture"];
var cultureName = HttpUtility.ParseQueryString(uri.Query)["dotNetCulture"];
if (cultureName is null)
{
return;
}
var culture = new CultureInfo(cultureName);
CultureInfo.DefaultThreadCurrentCulture = culture;