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 satelliteResources = resourceLoader.bootConfig.resources.satelliteResources;
const applicationCulture = resourceLoader.startOptions.applicationCulture || (navigator.languages && navigator.languages[0]); 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) { if (satelliteResources) {
const resourcePromises = Promise.all(culturesToLoad const resourcePromises = Promise.all(culturesToLoad
.filter(culture => satelliteResources.hasOwnProperty(culture)) .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 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"); MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
let timeZone = "UTC"; let timeZone = "UTC";
try { try {
@ -521,7 +524,7 @@ async function loadTimezone(timeZoneResource: LoadingResource): Promise<void> {
function getICUResourceName(bootConfig: BootJsonData, culture: string | undefined): string { function getICUResourceName(bootConfig: BootJsonData, culture: string | undefined): string {
const combinedICUResourceName = 'icudt.dat'; const combinedICUResourceName = 'icudt.dat';
if (!culture || bootConfig.icuDataMode == ICUDataMode.All) { if (!culture || bootConfig.icuDataMode === ICUDataMode.All) {
return combinedICUResourceName; 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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -17,6 +18,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// do change this it will be non-breaking. // do change this it will be non-breaking.
public static async void InvokeEntrypoint(string assemblyName, string[] args) public static async void InvokeEntrypoint(string assemblyName, string[] args)
{ {
WebAssemblyCultureProvider.Initialize();
try try
{ {
var assembly = Assembly.Load(assemblyName); var assembly = Assembly.Load(assemblyName);

View File

@ -1,17 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // 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.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Reflection;
using System.Runtime.Loader; using System.Runtime.Loader;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.AspNetCore.Components.WebAssembly.Services;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{ {
internal class SatelliteResourcesLoader internal class WebAssemblyCultureProvider
{ {
internal const string GetSatelliteAssemblies = "window.Blazor._internal.getSatelliteAssemblies"; internal const string GetSatelliteAssemblies = "window.Blazor._internal.getSatelliteAssemblies";
internal const string ReadSatelliteAssemblies = "window.Blazor._internal.readSatelliteAssemblies"; internal const string ReadSatelliteAssemblies = "window.Blazor._internal.readSatelliteAssemblies";
@ -19,9 +19,44 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
private readonly WebAssemblyJSRuntimeInvoker _invoker; private readonly WebAssemblyJSRuntimeInvoker _invoker;
// For unit testing. // For unit testing.
internal SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker invoker) internal WebAssemblyCultureProvider(WebAssemblyJSRuntimeInvoker invoker, CultureInfo initialCulture, CultureInfo initialUICulture)
{ {
_invoker = invoker; _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() public virtual async ValueTask LoadCurrentCultureResourcesAsync()

View File

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

View File

@ -1,18 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // 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.Globalization;
using System.IO; using System.IO;
using System.Net.NetworkInformation;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing;
using Moq; using Moq;
using Xunit; using Xunit;
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.SatelliteResourcesLoader; using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.WebAssemblyCultureProvider;
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
{ {
public class SatelliteResourcesLoaderTest public class WebAssemblyCultureProviderTest
{ {
[Theory] [Theory]
[InlineData("fr-FR", new[] { "fr-FR", "fr" })] [InlineData("fr-FR", new[] { "fr-FR", "fr" })]
@ -23,7 +25,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
var culture = new CultureInfo(cultureName); var culture = new CultureInfo(cultureName);
// Act // Act
var actual = SatelliteResourcesLoader.GetCultures(culture); var actual = WebAssemblyCultureProvider.GetCultures(culture);
// Assert // Assert
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
@ -43,7 +45,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
.Returns(new object[] { File.ReadAllBytes(GetType().Assembly.Location) }) .Returns(new object[] { File.ReadAllBytes(GetType().Assembly.Location) })
.Verifiable(); .Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object); var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
// Act // Act
await loader.LoadCurrentCultureResourcesAsync(); await loader.LoadCurrentCultureResourcesAsync();
@ -62,7 +64,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
.Returns(Task.FromResult<object>(0)) .Returns(Task.FromResult<object>(0))
.Verifiable(); .Verifiable();
var loader = new SatelliteResourcesLoader(invoker.Object); var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
// Act // Act
await loader.LoadCurrentCultureResourcesAsync(); await loader.LoadCurrentCultureResourcesAsync();
@ -70,5 +72,29 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Assert // Assert
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, object[]>(ReadSatelliteAssemblies, null, null, null), Times.Never()); 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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.Globalization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.AspNetCore.Components.WebAssembly.Services;
@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange // Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build(); var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader(); host.CultureProvider = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@ -40,7 +41,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
// Arrange // Arrange
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var host = builder.Build(); var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader(); host.CultureProvider = new TestSatelliteResourcesLoader();
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
var task = host.RunAsyncCore(cts.Token); var task = host.RunAsyncCore(cts.Token);
@ -62,7 +63,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
builder.Services.AddSingleton<DisposableService>(); builder.Services.AddSingleton<DisposableService>();
var host = builder.Build(); var host = builder.Build();
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader(); host.CultureProvider = new TestSatelliteResourcesLoader();
var disposable = host.Services.GetRequiredService<DisposableService>(); 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() internal TestSatelliteResourcesLoader()
: base(WebAssemblyJSRuntimeInvoker.Instance) : base(WebAssemblyJSRuntimeInvoker.Instance, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)
{ {
} }

View File

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

View File

@ -29,7 +29,11 @@ namespace GlobalizationWasmApp
{ {
var uri = new Uri(host.Services.GetService<NavigationManager>().Uri); 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); var culture = new CultureInfo(cultureName);
CultureInfo.DefaultThreadCurrentCulture = culture; CultureInfo.DefaultThreadCurrentCulture = culture;