Merged PR 9371: Revive support for globalization and localization in Blazor WASM (#24773)

* 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 K 2020-08-18 06:33:50 -07:00 committed by GitHub
commit 2ad1b6d835
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 140 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 { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
import { Platform, System_Array, Pointer, System_Object, System_String, HeapLock } from '../Platform';
import { loadTimezoneData } from './TimezoneDataFile';
import { WebAssemblyBootResourceType } from '../WebAssemblyStartOptions';
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
const appBinDirName = 'appBinDir';
const icuDataResourceName = 'icudt.dat';
const uint64HighOrderShift = Math.pow(2, 32);
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
@ -239,14 +239,23 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
/* hash */ resourceLoader.bootConfig.resources.runtime[dotnetWasmResourceName],
/* type */ 'dotnetwasm');
const dotnetTimeZoneResourceName = 'dotnet.timezones.dat';
const dotnetTimeZoneResourceName = 'dotnet.timezones.blat';
let timeZoneResource: LoadingResource | undefined;
if (resourceLoader.bootConfig.resources.runtime.hasOwnProperty(dotnetTimeZoneResourceName)) {
timeZoneResource = resourceLoader.loadResource(
dotnetTimeZoneResourceName,
`_framework/${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');
}
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
@ -274,6 +283,13 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
loadTimezone(timeZoneResource);
}
if (icuDataResource) {
loadICUData(icuDataResource);
} else {
// Use invariant culture if the app does not carry icu data.
MONO.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1");
}
// 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
// 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
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.
const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']);
mono_wasm_enable_on_demand_gc(0);
@ -459,8 +479,27 @@ async function loadTimezone(timeZoneResource: LoadingResource) : Promise<void> {
const request = await timeZoneResource.response;
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);
}

View File

@ -6,6 +6,9 @@ declare interface MONO {
loaded_files: string[];
mono_wasm_runtime_ready (): 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

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.
// Instead, this represents the public API through which certain aspects
// 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.webassembly.js");
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", DotNetJsFileName);
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazorwasm-minimal.dll");
@ -169,7 +170,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
Assert.Null(bootJsonData.resources.satelliteResources);
}
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/22975")]
[Fact]
public async Task Build_WithBlazorEnableTimeZoneSupportDisabled_DoesNotCopyTimeZoneInfo()
{
// Arrange
@ -192,10 +193,39 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var runtime = bootJsonData.resources.runtime.Keys;
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.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "wasm", "dotnet.timezones.dat");
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.timezones.blat");
}
[Fact]
public async Task Build_WithInvariantGlobalizationEnabled_DoesNotCopyGlobalizationData()
{
// Arrange
using var project = ProjectDirectory.Create("blazorwasm-minimal");
project.AddProjectFileContent(
@"
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>");
var result = await MSBuildProcessManager.DotnetMSBuild(project);
Assert.BuildPassed(result);
var buildOutputDirectory = project.BuildOutputDirectory;
var bootJsonPath = Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
var bootJsonData = ReadBootJsonData(result, bootJsonPath);
var runtime = bootJsonData.resources.runtime.Keys;
Assert.Contains("dotnet.wasm", runtime);
Assert.Contains("dotnet.timezones.blat", runtime);
Assert.DoesNotContain("icudt.dat", runtime);
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
Assert.FileDoesNotExist(result, buildOutputDirectory, "wwwroot", "_framework", "icudt.dat");
}
[Fact]

View File

@ -823,6 +823,34 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
assetsManifestPath: "custom-service-worker-assets.js");
}
[Fact]
public async Task Publish_WithInvariantGlobalizationEnabled_DoesNotCopyGlobalizationData()
{
// Arrange
using var project = ProjectDirectory.Create("blazorwasm-minimal");
project.AddProjectFileContent(
@"
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>");
var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
Assert.BuildPassed(result);
var publishOutputDirectory = project.PublishOutputDirectory;
var bootJsonPath = Path.Combine(publishOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
var bootJsonData = ReadBootJsonData(result, bootJsonPath);
var runtime = bootJsonData.resources.runtime.Keys;
Assert.Contains("dotnet.wasm", runtime);
Assert.DoesNotContain("icudt.dat", runtime);
Assert.FileExists(result, publishOutputDirectory, "wwwroot", "_framework", "dotnet.wasm");
Assert.FileDoesNotExist(result, publishOutputDirectory, "wwwroot", "_framework", "icudt.dat");
}
private static void AddWasmProjectContent(ProjectDirectory project, string content)
{
var path = Path.Combine(project.SolutionPath, "blazorwasm", "blazorwasm.csproj");

View File

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

View File

@ -123,6 +123,12 @@ Copyright (c) .NET Foundation. All rights reserved.
<!-- Clear out temporary build artifacts that the runtime packages -->
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)" Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.a'" />
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
Condition="'$(BlazorEnableTimeZoneSupport)' == 'false' AND '%(ReferenceCopyLocalPaths.FileName)%(ReferenceCopyLocalPaths.Extension)' == 'dotnet.timezones.blat'" />
<ReferenceCopyLocalPaths Remove="@(ReferenceCopyLocalPaths)"
Condition="'$(InvariantGlobalization)' == 'true' AND '%(ReferenceCopyLocalPaths.FileName)%(ReferenceCopyLocalPaths.Extension)' == 'icudt.dat'" />
<!--
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
@ -430,6 +436,12 @@ Copyright (c) .NET Foundation. All rights reserved.
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Extension)' == '.a'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="'$(BlazorEnableTimeZoneSupport)' == 'false' AND '%(ResolvedFileToPublish.FileName)%(ResolvedFileToPublish.Extension)' == 'dotnet.timezones.blat'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="'$(InvariantGlobalization)' == 'true' AND '%(ResolvedFileToPublish.FileName)%(ResolvedFileToPublish.Extension)' == 'icudt.dat'" />
<!-- Remove dotnet.js from publish output -->
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(ResolvedFileToPublish.RelativePath)' == 'dotnet.js'" />

View File

@ -20,6 +20,7 @@
<RazorSdkCurrentVersionTargets>$(RepoRoot)src\Razor\Microsoft.NET.Sdk.Razor\src\build\netstandard2.0\Sdk.Razor.CurrentVersion.targets</RazorSdkCurrentVersionTargets>
<RazorSdkArtifactsDirectory>$(RepoRoot)artifacts\bin\Microsoft.NET.Sdk.Razor\</RazorSdkArtifactsDirectory>
<BlazorWebAssemblySdkArtifactsDirectory>$(RepoRoot)artifacts\bin\Microsoft.NET.Sdk.BlazorWebAssembly\</BlazorWebAssemblySdkArtifactsDirectory>
<_BlazorWebAssemblyTargetsFile>$(RepoRoot)src\Components\WebAssembly\Sdk\src\targets\Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets</_BlazorWebAssemblyTargetsFile>
<BlazorWebAssemblyJSPath>$(MSBuildThisFileDirectory)blazor.webassembly.js</BlazorWebAssemblyJSPath>
</PropertyGroup>

View File

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

View File

@ -3,7 +3,9 @@
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;
@ -55,7 +57,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
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
// back to the last valid value
target.SendKeys(Keys.Control + "a"); // select all
target.SendKeys("05/06A");
Browser.Equal("05/06A", () => target.GetAttribute("value"));
target.SendKeys("05/06X");
Browser.Equal("05/06X", () => target.GetAttribute("value"));
target.SendKeys("\t");
Browser.Equal(expected, () => DateTime.Parse(target.GetAttribute("value")));
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
// back to the last valid value
target.SendKeys(Keys.Control + "a"); // select all
target.SendKeys("05/06A");
Browser.Equal("05/06A", () => target.GetAttribute("value"));
target.SendKeys("05/06X");
Browser.Equal("05/06X", () => target.GetAttribute("value"));
target.SendKeys("\t");
Browser.Equal(expected.DateTime, () => DateTimeOffset.Parse(target.GetAttribute("value")).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("fr-FR", "Bonjour!")]
public void CanSetCultureAndReadLocalizedResources(string culture, string message)

View File

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