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:
parent
da3f97b0ad
commit
de1bf0abe4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,5 +14,4 @@
|
|||
<Reference Include="Microsoft.Extensions.Localization" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue