Skip to content

Commit de1bf0a

Browse files
authored
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
1 parent da3f97b commit de1bf0a

File tree

10 files changed

+99
-26
lines changed

10 files changed

+99
-26
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webassembly.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,6 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
309309
const satelliteResources = resourceLoader.bootConfig.resources.satelliteResources;
310310
const applicationCulture = resourceLoader.startOptions.applicationCulture || (navigator.languages && navigator.languages[0]);
311311

312-
if (resourceLoader.bootConfig.icuDataMode == ICUDataMode.Sharded && culturesToLoad && culturesToLoad[0] !== applicationCulture) {
313-
// 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.
314-
throw new Error('To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application\'s project file.');
315-
}
316-
317312
if (satelliteResources) {
318313
const resourcePromises = Promise.all(culturesToLoad
319314
.filter(culture => satelliteResources.hasOwnProperty(culture))
@@ -404,6 +399,14 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
404399
}
405400
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
406401

402+
if (resourceLoader.bootConfig.icuDataMode === ICUDataMode.Sharded) {
403+
MONO.mono_wasm_setenv('__BLAZOR_SHARDED_ICU', '1');
404+
405+
if (resourceLoader.startOptions.applicationCulture) {
406+
// If a culture is specified via start options use that to initialize the Emscripten \ .NET culture.
407+
MONO.mono_wasm_setenv('LANG', `${resourceLoader.startOptions.applicationCulture}.UTF-8`);
408+
}
409+
}
407410
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
408411
let timeZone = "UTC";
409412
try {
@@ -521,7 +524,7 @@ async function loadTimezone(timeZoneResource: LoadingResource): Promise<void> {
521524

522525
function getICUResourceName(bootConfig: BootJsonData, culture: string | undefined): string {
523526
const combinedICUResourceName = 'icudt.dat';
524-
if (!culture || bootConfig.icuDataMode == ICUDataMode.All) {
527+
if (!culture || bootConfig.icuDataMode === ICUDataMode.All) {
525528
return combinedICUResourceName;
526529
}
527530

src/Components/WebAssembly/WebAssembly/src/Hosting/EntrypointInvoker.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Globalization;
56
using System.Linq;
67
using System.Reflection;
78
using System.Threading.Tasks;
@@ -17,6 +18,8 @@ internal static class EntrypointInvoker
1718
// do change this it will be non-breaking.
1819
public static async void InvokeEntrypoint(string assemblyName, string[] args)
1920
{
21+
WebAssemblyCultureProvider.Initialize();
22+
2023
try
2124
{
2225
var assembly = Assembly.Load(assemblyName);

src/Components/WebAssembly/WebAssembly/src/Hosting/SatelliteResourcesLoader.cs renamed to src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,62 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System;
45
using System.Collections.Generic;
56
using System.Globalization;
67
using System.IO;
7-
using System.Reflection;
88
using System.Runtime.Loader;
99
using System.Threading.Tasks;
1010
using Microsoft.AspNetCore.Components.WebAssembly.Services;
1111

1212
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
1313
{
14-
internal class SatelliteResourcesLoader
14+
internal class WebAssemblyCultureProvider
1515
{
1616
internal const string GetSatelliteAssemblies = "window.Blazor._internal.getSatelliteAssemblies";
1717
internal const string ReadSatelliteAssemblies = "window.Blazor._internal.readSatelliteAssemblies";
1818

1919
private readonly WebAssemblyJSRuntimeInvoker _invoker;
2020

2121
// For unit testing.
22-
internal SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker invoker)
22+
internal WebAssemblyCultureProvider(WebAssemblyJSRuntimeInvoker invoker, CultureInfo initialCulture, CultureInfo initialUICulture)
2323
{
2424
_invoker = invoker;
25+
InitialCulture = initialCulture;
26+
InitialUICulture = initialUICulture;
27+
}
28+
29+
public static WebAssemblyCultureProvider Instance { get; private set; }
30+
31+
public CultureInfo InitialCulture { get; }
32+
33+
public CultureInfo InitialUICulture { get; }
34+
35+
internal static void Initialize()
36+
{
37+
Instance = new WebAssemblyCultureProvider(
38+
WebAssemblyJSRuntimeInvoker.Instance,
39+
initialCulture: CultureInfo.CurrentCulture,
40+
initialUICulture: CultureInfo.CurrentUICulture);
41+
}
42+
43+
public void ThrowIfCultureChangeIsUnsupported()
44+
{
45+
// With ICU sharding enabled, bootstrapping WebAssembly will download a ICU shard based on the browser language.
46+
// If the application author was to change the culture as part of their Program.MainAsync, we might have
47+
// incomplete icu data for their culture. We would like to flag this as an error and notify the author to
48+
// use the combined icu data file instead.
49+
//
50+
// The Initialize method is invoked as one of the first steps bootstrapping the app prior to any user code running.
51+
// It allows us to capture the initial .NET culture that is configured based on the browser language.
52+
// The current method is invoked as part of WebAssemblyHost.RunAsync i.e. after user code in Program.MainAsync has run
53+
// thus allows us to detect if the culture was changed by user code.
54+
if (Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" &&
55+
((CultureInfo.CurrentCulture != InitialCulture) || (CultureInfo.CurrentUICulture != InitialUICulture)))
56+
{
57+
throw new InvalidOperationException("Blazor detected a change in the application's culture that is not supported with the current project configuration. " +
58+
"To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application's project file.");
59+
}
2560
}
2661

2762
public virtual async ValueTask LoadCurrentCultureResourcesAsync()

src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ internal WebAssemblyHost(IServiceProvider services, IServiceScope scope, IConfig
5959
/// </summary>
6060
public IServiceProvider Services => _scope.ServiceProvider;
6161

62-
internal SatelliteResourcesLoader SatelliteResourcesLoader { get; set; } = new SatelliteResourcesLoader(WebAssemblyJSRuntimeInvoker.Instance);
62+
internal WebAssemblyCultureProvider CultureProvider { get; set; } = WebAssemblyCultureProvider.Instance;
6363

6464
/// <summary>
6565
/// Disposes the host asynchronously.
@@ -121,11 +121,13 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken)
121121

122122
_started = true;
123123

124+
CultureProvider.ThrowIfCultureChangeIsUnsupported();
125+
124126
// EntryPointInvoker loads satellite assemblies for the application default culture.
125127
// Application developers might have configured the culture based on some ambient state
126128
// such as local storage, url etc as part of their Program.Main(Async).
127129
// This is the earliest opportunity to fetch satellite assemblies for this selection.
128-
await SatelliteResourcesLoader.LoadCurrentCultureResourcesAsync();
130+
await CultureProvider.LoadCurrentCultureResourcesAsync();
129131

130132
var tcs = new TaskCompletionSource<object>();
131133

src/Components/WebAssembly/WebAssembly/test/Hosting/SatelliteResourcesLoaderTest.cs renamed to src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System;
45
using System.Globalization;
56
using System.IO;
7+
using System.Net.NetworkInformation;
68
using System.Threading.Tasks;
79
using Microsoft.AspNetCore.Components.WebAssembly.Services;
810
using Microsoft.AspNetCore.Testing;
911
using Moq;
1012
using Xunit;
11-
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.SatelliteResourcesLoader;
13+
using static Microsoft.AspNetCore.Components.WebAssembly.Hosting.WebAssemblyCultureProvider;
1214

1315
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
1416
{
15-
public class SatelliteResourcesLoaderTest
17+
public class WebAssemblyCultureProviderTest
1618
{
1719
[Theory]
1820
[InlineData("fr-FR", new[] { "fr-FR", "fr" })]
@@ -23,7 +25,7 @@ public void GetCultures_ReturnsCultureClosure(string cultureName, string[] expec
2325
var culture = new CultureInfo(cultureName);
2426

2527
// Act
26-
var actual = SatelliteResourcesLoader.GetCultures(culture);
28+
var actual = WebAssemblyCultureProvider.GetCultures(culture);
2729

2830
// Assert
2931
Assert.Equal(expected, actual);
@@ -43,7 +45,7 @@ public async Task LoadCurrentCultureResourcesAsync_ReadsAssemblies()
4345
.Returns(new object[] { File.ReadAllBytes(GetType().Assembly.Location) })
4446
.Verifiable();
4547

46-
var loader = new SatelliteResourcesLoader(invoker.Object);
48+
var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
4749

4850
// Act
4951
await loader.LoadCurrentCultureResourcesAsync();
@@ -62,13 +64,37 @@ public async Task LoadCurrentCultureResourcesAsync_DoesNotReadAssembliesWhenTher
6264
.Returns(Task.FromResult<object>(0))
6365
.Verifiable();
6466

65-
var loader = new SatelliteResourcesLoader(invoker.Object);
67+
var loader = new WebAssemblyCultureProvider(invoker.Object, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture);
6668

6769
// Act
6870
await loader.LoadCurrentCultureResourcesAsync();
6971

7072
// Assert
7173
invoker.Verify(i => i.InvokeUnmarshalled<object, object, object, object[]>(ReadSatelliteAssemblies, null, null, null), Times.Never());
7274
}
75+
76+
[Fact]
77+
public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICUShardingIsUsed()
78+
{
79+
// Arrange
80+
Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1");
81+
try
82+
{
83+
// WebAssembly is initialized with en-US
84+
var cultureProvider = new WebAssemblyCultureProvider(WebAssemblyJSRuntimeInvoker.Instance, new CultureInfo("en-US"), new CultureInfo("en-US"));
85+
86+
// Culture is changed to fr-FR as part of the app
87+
using var cultureReplacer = new CultureReplacer("fr-FR");
88+
89+
var ex = Assert.Throws<InvalidOperationException>(() => cultureProvider.ThrowIfCultureChangeIsUnsupported());
90+
Assert.Equal("Blazor detected a change in the application's culture that is not supported with the current project configuration. " +
91+
"To change culture dynamically during startup, set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> in the application's project file.",
92+
ex.Message);
93+
}
94+
finally
95+
{
96+
Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null);
97+
}
98+
}
7399
}
74100
}

src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostTest.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Globalization;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.AspNetCore.Components.WebAssembly.Services;
@@ -21,7 +22,7 @@ public async Task RunAsync_CanExitBasedOnCancellationToken()
2122
// Arrange
2223
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
2324
var host = builder.Build();
24-
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
25+
host.CultureProvider = new TestSatelliteResourcesLoader();
2526

2627
var cts = new CancellationTokenSource();
2728

@@ -40,7 +41,7 @@ public async Task RunAsync_CallingTwiceCausesException()
4041
// Arrange
4142
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
4243
var host = builder.Build();
43-
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
44+
host.CultureProvider = new TestSatelliteResourcesLoader();
4445

4546
var cts = new CancellationTokenSource();
4647
var task = host.RunAsyncCore(cts.Token);
@@ -62,7 +63,7 @@ public async Task DisposeAsync_CanDisposeAfterCallingRunAsync()
6263
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
6364
builder.Services.AddSingleton<DisposableService>();
6465
var host = builder.Build();
65-
host.SatelliteResourcesLoader = new TestSatelliteResourcesLoader();
66+
host.CultureProvider = new TestSatelliteResourcesLoader();
6667

6768
var disposable = host.Services.GetRequiredService<DisposableService>();
6869

@@ -92,10 +93,10 @@ public ValueTask DisposeAsync()
9293
}
9394
}
9495

95-
private class TestSatelliteResourcesLoader : SatelliteResourcesLoader
96+
private class TestSatelliteResourcesLoader : WebAssemblyCultureProvider
9697
{
9798
internal TestSatelliteResourcesLoader()
98-
: base(WebAssemblyJSRuntimeInvoker.Instance)
99+
: base(WebAssemblyJSRuntimeInvoker.Instance, CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)
99100
{
100101
}
101102

src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,4 @@
1414
<Reference Include="Microsoft.Extensions.Localization" />
1515
</ItemGroup>
1616

17-
1817
</Project>

src/Components/test/testassets/GlobalizationWasmApp/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ private static void ConfigureCulture(WebAssemblyHost host)
2929
{
3030
var uri = new Uri(host.Services.GetService<NavigationManager>().Uri);
3131

32-
var cultureName = HttpUtility.ParseQueryString(uri.Query)["dotNetCulture"] ?? HttpUtility.ParseQueryString(uri.Query)["culture"];
32+
var cultureName = HttpUtility.ParseQueryString(uri.Query)["dotNetCulture"];
33+
if (cultureName is null)
34+
{
35+
return;
36+
}
3337

3438
var culture = new CultureInfo(cultureName);
3539
CultureInfo.DefaultThreadCurrentCulture = culture;

0 commit comments

Comments
 (0)