Skip to content

Added Culture persistence between Client and Server #63144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<Compile Include="$(RepoRoot)src\Shared\ClosedGenericMatcher\ClosedGenericMatcher.cs" LinkBase="FormMapping" />
<Compile Include="$(ComponentsSharedSourceRoot)src\CacheHeaderSettings.cs" Link="Shared\CacheHeaderSettings.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ResourceCollectionProvider.cs" Link="Shared\ResourceCollectionProvider.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\CultureStateProvider.cs" Link="Shared\CultureStateProvider.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\DefaultAntiforgeryStateProvider.cs" LinkBase="Forms" />
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)Components\ComponentsActivityLinkStore.cs" LinkBase="Shared" />
Expand Down
46 changes: 46 additions & 0 deletions src/Components/Shared/src/CultureStateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;

namespace Microsoft.AspNetCore.Components.Forms;

internal class CultureStateProvider
{
protected string? _currentCultureName;
protected string? _currentUICultureName;

[PersistentState]
public string? CurrentCultureName { get; set; }

[PersistentState]
public string? CurrentUICultureName { get; set; }

/// <summary>
/// Captures the current thread culture for persistence.
/// Called from server-side during prerendering.
/// </summary>
public void CaptureCurrentCulture()
{
CurrentCultureName = CultureInfo.CurrentCulture.Name;
CurrentUICultureName = CultureInfo.CurrentUICulture.Name;
}

/// <summary>
/// Applies the stored culture to the current thread.
/// Called on WebAssembly side after hydration.
/// </summary>
public void ApplyStoredCulture()
{
if (!string.IsNullOrEmpty(CurrentCultureName))
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(CurrentCultureName);
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo(CurrentCultureName);
}

if (!string.IsNullOrEmpty(CurrentUICultureName))
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(CurrentUICultureName);
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo(CurrentUICultureName);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ItemGroup>
<Compile Include="$(ComponentsSharedSourceRoot)\src\CacheHeaderSettings.cs" Link="Shared\CacheHeaderSettings.cs" />
<Compile Include="$(SharedSourceRoot)\CommandLineUtils\Utilities\DotNetMuxer.cs" Link="Shared\DotNetMuxer.cs" />

<Compile Include="$(ComponentsSharedSourceRoot)src\CultureStateProvider.cs" Link="Shared\CultureStateProvider.cs" />
<Content Include="build\**" Pack="true" PackagePath="build\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Extensions.DependencyInjection.WebAssemblyRazorComponentsBuilderExtensions.EnforceServerCultureOnClient(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder!
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
using Microsoft.AspNetCore.Components.WebAssembly.Server;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Infrastructure;

namespace Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -48,4 +51,25 @@ public static IRazorComponentsBuilder AddAuthenticationStateSerialization(this I

return builder;
}

/// <summary>
/// Adds services to enforce Server culture on the Client side.
/// </summary>
/// <param name="builder">The <see cref="IRazorComponentsBuilder"/>.</param>
/// <returns>An <see cref="IRazorComponentsBuilder"/> that can be used to further customize the configuration.</returns>
public static IRazorComponentsBuilder EnforceServerCultureOnClient(this IRazorComponentsBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

builder.Services.TryAddScoped(_ =>
{
var provider = new CultureStateProvider();
provider.CaptureCurrentCulture();
return provider;
});
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<CultureStateProvider>(
builder.Services,
RenderMode.InteractiveWebAssembly);
return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Infrastructure;
Expand Down Expand Up @@ -121,14 +122,6 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl

_started = true;

cultureProvider ??= WebAssemblyCultureProvider.Instance!;
cultureProvider.ThrowIfCultureChangeIsUnsupported();

// 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 cultureProvider.LoadCurrentCultureResourcesAsync();

var manager = Services.GetRequiredService<ComponentStatePersistenceManager>();
var store = !string.IsNullOrEmpty(_persistedState) ?
new PrerenderComponentApplicationStore(_persistedState) :
Expand All @@ -137,6 +130,19 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl
manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly);
await manager.RestoreStateAsync(store);

cultureProvider ??= WebAssemblyCultureProvider.Instance!;
cultureProvider.ThrowIfCultureChangeIsUnsupported();

if (Services.GetService<CultureStateProvider>() is CultureStateProvider cultureStateProvider)
{
cultureStateProvider.ApplyStoredCulture();
}

// 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 cultureProvider.LoadCurrentCultureResourcesAsync();

var tcs = new TaskCompletionSource();
using (cancellationToken.Register(() => tcs.TrySetResult()))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ internal void InitializeDefaultServices()
});
Services.AddSingleton<AntiforgeryStateProvider, DefaultAntiforgeryStateProvider>();
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<AntiforgeryStateProvider>(Services, RenderMode.InteractiveWebAssembly);
Services.AddSingleton<CultureStateProvider>();
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<CultureStateProvider>(Services, RenderMode.InteractiveWebAssembly);
Services.AddSupplyValueFromQueryProvider();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\DefaultAntiforgeryStateProvider.cs" LinkBase="Forms" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ResourceCollectionProvider.cs" Link="Shared\ResourceCollectionProvider.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\CultureStateProvider.cs" Link="Shared\CultureStateProvider.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,23 @@ public void CanPersistPrerenderedState_WebAssemblyPrerenderedStateAvailableOnlyO
Browser.Equal("not restored", () => Browser.FindElement(By.Id("wasm")).Text);
}

[Fact]
public void DoesNotPersistCultureFromServerAsDefault()
{
Navigate($"{ServerPathBase}/Culture/SetCulture?culture=fr-FR&redirectUri={Uri.EscapeDataString($"{ServerPathBase}/persist-culture-state")}");
Browser.Exists(By.ClassName("return-from-culture-setter")).Click();

Browser.Equal("Prerender", () => Browser.FindElement(By.Id("prerender")).Text);
Browser.Equal("fr-FR", () => Browser.FindElement(By.Id("culture-set")).Text);
Browser.Equal("fr-FR", () => Browser.FindElement(By.Id("culture-ui-set")).Text);

Browser.Exists(By.Id("start-blazor")).Click();

Browser.Equal("Interactive", () => Browser.FindElement(By.Id("interactive")).Text);
Browser.NotEqual("fr-FR", () => Browser.FindElement(By.Id("culture-set")).Text);
Browser.NotEqual("fr-FR", () => Browser.FindElement(By.Id("culture-ui-set")).Text);
}

[Fact]
public void NavigationManagerCanRefreshSSRPageWhenServerInteractivityEnabled()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using TestServer;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;

[CollectionDefinition(nameof(InteractivityTest), DisableParallelization = true)]
public class LocalizationTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
{
public LocalizationTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

protected override void InitializeAsyncCore()
{
_serverFixture.AdditionalArguments.Add("EnforceServerCultureOnClient=true");
base.InitializeAsyncCore();
}

[Fact]
public void CanPersistCultureFromServer()
{
Navigate($"{ServerPathBase}/Culture/SetCulture?culture=fr-FR&redirectUri={Uri.EscapeDataString($"{ServerPathBase}/persist-culture-state")}");
Browser.Exists(By.ClassName("return-from-culture-setter")).Click();

Browser.Equal("Prerender", () => Browser.FindElement(By.Id("prerender")).Text);
Browser.Equal("fr-FR", () => Browser.FindElement(By.Id("culture-set")).Text);
Browser.Equal("fr-FR", () => Browser.FindElement(By.Id("culture-ui-set")).Text);

Browser.Exists(By.Id("start-blazor")).Click();

Browser.Equal("Interactive", () => Browser.FindElement(By.Id("interactive")).Text);
Browser.Equal("fr-FR", () => Browser.FindElement(By.Id("culture-set")).Text);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Server;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using TestContentPackage;
using TestContentPackage.Services;
Expand All @@ -30,9 +31,10 @@ public RazorComponentEndpointsStartup(IConfiguration configuration)
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddValidation();

services.AddRazorComponents(options =>
var razorComponentsBuilder = services.AddRazorComponents(options =>
{
options.MaxFormMappingErrorCount = 10;
options.MaxFormMappingRecursionDepth = 5;
Expand All @@ -57,6 +59,11 @@ public void ConfigureServices(IServiceCollection services)
options.SerializeAllClaims = serializeAllClaims;
});

if (Configuration.GetValue<bool>("EnforceServerCultureOnClient"))
{
razorComponentsBuilder.EnforceServerCultureOnClient();
}

if (Configuration.GetValue<bool>("UseHybridCache"))
{
services.AddHybridCache();
Expand Down Expand Up @@ -118,6 +125,15 @@ private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironmen
{
WebAssemblyTestHelper.ServeCoopHeadersIfWebAssemblyThreadingEnabled(app);

app.UseRequestLocalization(options =>
{
options.AddSupportedCultures("en-US", "fr-FR");
options.AddSupportedUICultures("en-US", "fr-FR");
options.RequestCultureProviders.Clear();
options.RequestCultureProviders.Add(new CookieRequestCultureProvider());
options.SetDefaultCulture("en-US");
});

if (!env.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
Expand All @@ -142,6 +158,8 @@ private void ConfigureEndpoints(IApplicationBuilder app, IWebHostEnvironment env
{
_ = app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();

var contentRootStaticAssetsPath = Path.Combine(env.ContentRootPath, "Components.TestServer.staticwebassets.endpoints.json");
if (File.Exists(contentRootStaticAssetsPath))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@page "/persist-culture-state"
@using System.Globalization
@using Microsoft.AspNetCore.Components.Web
@rendermode RenderMode.InteractiveWebAssembly

<script>
function start() {
Blazor.start({
logLevel: 1 // LogLevel.Debug
});
}
</script>

<script src="_framework/blazor.webassembly.js" autostart="false"></script>

<h3>CulturePersistence</h3>

<p id="culture-set">@CultureInfo.CurrentCulture</p>
<p id="culture-ui-set">@CultureInfo.CurrentUICulture</p>

@if(RendererInfo.IsInteractive)
{
<p id="interactive">Interactive</p>
}
else
{
<p id="prerender">Prerender</p>
}

<button id="start-blazor" onclick="start()">Start Blazor</button>

@code {
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
StateHasChanged();
}
base.OnAfterRender(firstRender);
}
}
Loading