Merged PR 9371: Revive support for globalization and localization in Blazor WASM

Revive support for globalization and localization in Blazor WASM

* Load icu and timezone data files
* Unskip tests

Fixes https://github.com/dotnet/aspnetcore/issues/24174
Fixes https://github.com/dotnet/aspnetcore/issues/22975
Fixes https://github.com/dotnet/aspnetcore/issues/23260
This commit is contained in:
Pranav Krishnamoorthy 2020-07-28 21:28:52 +00:00 committed by Pranav K
parent 524c8cda63
commit 4e57d1b041
No known key found for this signature in database
GPG Key ID: F748807460A27E91
14 changed files with 72 additions and 64 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

@ -3,11 +3,11 @@ import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
import { showErrorNotification } from '../../BootErrors'; import { showErrorNotification } from '../../BootErrors';
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader'; import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform'; import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform';
import { loadTimezoneData } from './TimezoneDataFile';
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions'; import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void; let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
const appBinDirName = 'appBinDir'; const appBinDirName = 'appBinDir';
const icuDataResourceName = 'icudt.dat';
const uint64HighOrderShift = Math.pow(2, 32); const uint64HighOrderShift = Math.pow(2, 32);
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
@ -239,14 +239,26 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName], /* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName],
/* type */ 'dotnetwasm'); /* type */ 'dotnetwasm');
const dotnetTimeZoneResourceName = 'dotnet.timezones.dat'; const dotnetTimeZoneResourceName = 'dotnet.timezones.blat';
let timeZoneResource: LoadingResource | undefined; let timeZoneResource: LoadingResource | undefined;
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(dotnetTimeZoneResourceName)) { if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(dotnetTimeZoneResourceName)) {
timeZoneResource = resourceLoader.loadResource( timeZoneResource = resourceLoader.loadResource(
dotnetTimeZoneResourceName, dotnetTimeZoneResourceName,
`_framework/${dotnetTimeZoneResourceName}`, `_framework/${dotnetTimeZoneResourceName}`,
resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName], resourceLoader.bootConfig.resources.runtime[dotnetTimeZoneResourceName],
'timezonedata'); 'globalization');
}
let icuDataResource: LoadingResource | undefined;
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(icuDataResourceName)) {
icuDataResource = resourceLoader.loadResource(
icuDataResourceName,
`_framework/${icuDataResourceName}`,
resourceLoader.bootConfig.resources.runtime[icuDataResourceName],
'globalization');
} else {
// Use invariant culture if the app does not carry icu data.
MONO.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1");
} }
// Override the mechanism for fetching the main wasm file so we can connect it to our cache // Override the mechanism for fetching the main wasm file so we can connect it to our cache
@ -274,6 +286,10 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
loadTimezone(timeZoneResource); loadTimezone(timeZoneResource);
} }
if (icuDataResource) {
loadICUData(icuDataResource);
}
// Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded // Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded
// Mono requires the assembly filenames to have a '.dll' extension, so supply such names regardless // Mono requires the assembly filenames to have a '.dll' extension, so supply such names regardless
// of the extensions in the URLs. This allows loading assemblies with arbitrary filenames. // of the extensions in the URLs. This allows loading assemblies with arbitrary filenames.
@ -358,7 +374,11 @@ 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
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true"); MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
MONO.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1"); let timeZone = "UTC";
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch { }
MONO.mono_wasm_setenv("TZ", timeZone);
// Turn off full-gc to prevent browser freezing. // Turn off full-gc to prevent browser freezing.
const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']); const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']);
mono_wasm_enable_on_demand_gc(0); mono_wasm_enable_on_demand_gc(0);
@ -459,8 +479,27 @@ async function loadTimezone(timeZoneResource: LoadingResource) : Promise<void> {
const request = await timeZoneResource.response; const request = await timeZoneResource.response;
const arrayBuffer = await request.arrayBuffer(); const arrayBuffer = await request.arrayBuffer();
loadTimezoneData(arrayBuffer)
Module['FS_createPath']('/', 'usr', true, true);
Module['FS_createPath']('/usr/', 'share', true, true);
Module['FS_createPath']('/usr/share/', 'zoneinfo', true, true);
MONO.mono_wasm_load_data_archive(new Uint8Array(arrayBuffer), '/usr/share/zoneinfo/');
removeRunDependency(runDependencyId);
}
async function loadICUData(icuDataResource: LoadingResource) : Promise<void> {
const runDependencyId = `blazor:icudata`;
addRunDependency(runDependencyId);
const request = await icuDataResource.response;
const array = new Uint8Array(await request.arrayBuffer());
const offset = MONO.mono_wasm_load_bytes_into_heap(array);
if (!MONO.mono_wasm_load_icu_data(offset))
{
throw new Error("Error loading ICU asset.");
}
removeRunDependency(runDependencyId); removeRunDependency(runDependencyId);
} }

View File

@ -6,6 +6,9 @@ declare interface MONO {
loaded_files: string[]; loaded_files: string[];
mono_wasm_runtime_ready (): void; mono_wasm_runtime_ready (): void;
mono_wasm_setenv (name: string, value: string): void; mono_wasm_setenv (name: string, value: string): void;
mono_wasm_load_data_archive(data: Uint8Array, prefix: string): void;
mono_wasm_load_bytes_into_heap (data: Uint8Array): Pointer;
mono_wasm_load_icu_data(heapAddress: Pointer): boolean;
} }
// Mono uses this global to hold low-level interop APIs // Mono uses this global to hold low-level interop APIs

View File

@ -1,43 +0,0 @@
import { readInt32LE } from "../../BinaryDecoder";
import { decodeUtf8 } from "../../Utf8Decoder";
export function loadTimezoneData(arrayBuffer: ArrayBuffer) {
let remainingData = new Uint8Array(arrayBuffer);
// The timezone file is generated by https://github.com/dotnet/blazor/tree/master/src/TimeZoneData.
// The file format of the TZ file look like so
//
// [4 - byte length of manifest]
// [json manifest]
// [data bytes]
//
// The json manifest is an array that looks like so:
//
// [...["America/Fort_Nelson",2249],["America/Glace_Bay",2206]..]
//
// where the first token in each array is the relative path of the file on disk, and the second is the
// length of the file. The starting offset of a file can be calculated using the lengths of all files
// that appear prior to it.
const manifestSize = readInt32LE(remainingData, 0);
remainingData = remainingData.slice(4);
const manifestContent = decodeUtf8(remainingData.slice(0, manifestSize));
const manifest = JSON.parse(manifestContent) as ManifestEntry[];
remainingData = remainingData.slice(manifestSize);
// Create the folder structure
// /zoneinfo
// /zoneinfo/Africa
// /zoneinfo/Asia
// ..
Module['FS_createPath']('/', 'zoneinfo', true, true);
new Set(manifest.map(m => m[0].split('/')![0])).forEach(folder =>
Module['FS_createPath']('/zoneinfo', folder, true, true));
for (const [name, length] of manifest) {
const bytes = remainingData.slice(0, length);
Module['FS_createDataFile'](`/zoneinfo/${name}`, null, bytes, true, true, true);
remainingData = remainingData.slice(length);
}
}
type ManifestEntry = [string, number];

View File

@ -19,4 +19,4 @@ export interface WebAssemblyStartOptions {
// This type doesn't have to align with anything in BootConfig. // This type doesn't have to align with anything in BootConfig.
// Instead, this represents the public API through which certain aspects // Instead, this represents the public API through which certain aspects
// of boot resource loading can be customized. // of boot resource loading can be customized.
export type WebAssemblyBootResourceType = 'assembly' | 'pdb' | 'dotnetjs' | 'dotnetwasm' | 'timezonedata'; export type WebAssemblyBootResourceType = 'assembly' | 'pdb' | 'dotnetjs' | 'dotnetwasm' | 'globalization';

View File

@ -31,6 +31,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.webassembly.js"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.webassembly.js");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.timezones.blat");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm.gz"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm.gz");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", DotNetJsFileName); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", DotNetJsFileName);
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazorwasm-minimal.dll"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazorwasm-minimal.dll");
@ -169,7 +170,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
Assert.Null(bootJsonData.resources.satelliteResources); Assert.Null(bootJsonData.resources.satelliteResources);
} }
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/22975")] [Fact]
public async Task Build_WithBlazorEnableTimeZoneSupportDisabled_DoesNotCopyTimeZoneInfo() public async Task Build_WithBlazorEnableTimeZoneSupportDisabled_DoesNotCopyTimeZoneInfo()
{ {
// Arrange // Arrange
@ -192,10 +193,10 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var runtime = bootJsonData.resources.runtime.Keys; var runtime = bootJsonData.resources.runtime.Keys;
Assert.Contains("dotnet.wasm", runtime); Assert.Contains("dotnet.wasm", runtime);
Assert.DoesNotContain("dotnet.timezones.dat", runtime); Assert.DoesNotContain("dotnet.timezones.blat", runtime);
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "wasm", "dotnet.wasm"); Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "wasm", "dotnet.timezones.dat"); Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.timezones.blat");
} }
[Fact] [Fact]

View File

@ -2,6 +2,7 @@
<configuration> <configuration>
<system.webServer> <system.webServer>
<staticContent> <staticContent>
<remove fileExtension=".blat" />
<remove fileExtension=".dat" /> <remove fileExtension=".dat" />
<remove fileExtension=".dll" /> <remove fileExtension=".dll" />
<remove fileExtension=".json" /> <remove fileExtension=".json" />

View File

@ -123,6 +123,8 @@ Copyright (c) .NET Foundation. All rights reserved.
<!-- Clear out temporary build artifacts that the runtime packages --> <!-- Clear out temporary build artifacts that the runtime packages -->
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.a'" /> <ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.a'" />
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'$(BlazorEnableTimeZoneSupport)' == 'false' AND '%(ReferenceCopyLocalPaths.FileName)' == 'dotnet.timezones'" />
<!-- <!--
ReferenceCopyLocalPaths includes satellite assemblies from referenced projects but are inexpicably missing ReferenceCopyLocalPaths includes satellite assemblies from referenced projects but are inexpicably missing
any metadata that might allow them to be differentiated. We'll explicitly add those any metadata that might allow them to be differentiated. We'll explicitly add those

View File

@ -77,6 +77,7 @@ namespace Microsoft.AspNetCore.Builder
AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet); AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet);
AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet); AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet);
AddMapping(contentTypeProvider, ".dat", MediaTypeNames.Application.Octet); AddMapping(contentTypeProvider, ".dat", MediaTypeNames.Application.Octet);
AddMapping(contentTypeProvider, ".blat", MediaTypeNames.Application.Octet);
options.ContentTypeProvider = contentTypeProvider; options.ContentTypeProvider = contentTypeProvider;

View File

@ -3,7 +3,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.AspNetCore.Components.WebAssembly.Services;
@ -55,7 +57,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
for (var i = 0; i < assemblies.Length; i++) for (var i = 0; i < assemblies.Length; i++)
{ {
Assembly.Load((byte[])assemblies[i]); using var stream = new MemoryStream((byte[])assemblies[i]);
AssemblyLoadContext.Default.LoadFromStream(stream);
} }
} }

View File

@ -979,8 +979,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
// Modify target to something invalid - the invalid change is reverted // Modify target to something invalid - the invalid change is reverted
// back to the last valid value // back to the last valid value
target.SendKeys(Keys.Control + "a"); // select all target.SendKeys(Keys.Control + "a"); // select all
target.SendKeys("05/06A"); target.SendKeys("05/06X");
Browser.Equal("05/06A", () => target.GetAttribute("value")); Browser.Equal("05/06X", () => target.GetAttribute("value"));
target.SendKeys("\t"); target.SendKeys("\t");
Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value"))); Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value")));
Assert.Equal(expected, DateTime.Parse(boundValue.Text)); Assert.Equal(expected, DateTime.Parse(boundValue.Text));
@ -1017,8 +1017,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
// Modify target to something invalid - the invalid change is reverted // Modify target to something invalid - the invalid change is reverted
// back to the last valid value // back to the last valid value
target.SendKeys(Keys.Control + "a"); // select all target.SendKeys(Keys.Control + "a"); // select all
target.SendKeys("05/06A"); target.SendKeys("05/06X");
Browser.Equal("05/06A", () => target.GetAttribute("value")); Browser.Equal("05/06X", () => target.GetAttribute("value"));
target.SendKeys("\t"); target.SendKeys("\t");
Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(target.GetAttribute("value")).DateTime); Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(target.GetAttribute("value")).DateTime);
Assert.Equal(expected.DateTime, DateTimeOffset.Parse(boundValue.Text).DateTime); Assert.Equal(expected.DateTime, DateTimeOffset.Parse(boundValue.Text).DateTime);

View File

@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{ {
} }
[Theory(Skip = "https://github.com/dotnet/runtime/issues/38124")] [Theory]
[InlineData("en-US", "Hello!")] [InlineData("en-US", "Hello!")]
[InlineData("fr-FR", "Bonjour!")] [InlineData("fr-FR", "Bonjour!")]
public void CanSetCultureAndReadLocalizedResources(string culture, string message) public void CanSetCultureAndReadLocalizedResources(string culture, string message)

View File

@ -2,6 +2,7 @@
<configuration> <configuration>
<system.webServer> <system.webServer>
<staticContent> <staticContent>
<remove fileExtension=".blat" />
<remove fileExtension=".dat" /> <remove fileExtension=".dat" />
<remove fileExtension=".dll" /> <remove fileExtension=".dll" />
<remove fileExtension=".json" /> <remove fileExtension=".json" />