From 239b61b6eb13aa078705965085333640722ac6cb Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 21 Jan 2026 11:56:53 +0100 Subject: [PATCH 01/11] Create SupplyParameterFromSessionAttribute and connected infrastructure --- .../Components/src/PublicAPI.Unshipped.txt | 4 ++ .../SupplyParameterFromSessionAttribute.cs | 20 ++++++++++ ...orComponentsServiceCollectionExtensions.cs | 2 + .../src/Rendering/EndpointHtmlRenderer.cs | 5 +++ .../Endpoints/src/SessionValueMapper.cs | 26 ++++++++++++ .../Web/src/PublicAPI.Unshipped.txt | 4 ++ .../Web/src/Session/ISessionValueMapper.cs | 15 +++++++ ...rFromSessionServiceCollectionExtensions.cs | 25 ++++++++++++ ...SupplyParameterFromSessionValueProvider.cs | 40 +++++++++++++++++++ .../Components.TestServer.csproj | 1 + .../Components.TestServer/Program.cs | 1 + ...omponentEndpointsNoInteractivityStartup.cs | 9 +++++ .../SupplyParameterFromSessionComponent.razor | 27 +++++++++++++ 13 files changed, 179 insertions(+) create mode 100644 src/Components/Components/src/SupplyParameterFromSessionAttribute.cs create mode 100644 src/Components/Endpoints/src/SessionValueMapper.cs create mode 100644 src/Components/Web/src/Session/ISessionValueMapper.cs create mode 100644 src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs create mode 100644 src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 8bba68ef9bd1..2eddec351c76 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -6,3 +6,7 @@ Microsoft.AspNetCore.Components.IComponentPropertyActivator.GetActivator(System. *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void +Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute +Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.get -> string? +Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.set -> void +Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void diff --git a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs new file mode 100644 index 000000000000..d61238a00279 --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Indicates that the value of the associated property should be supplied from +/// the session attribute with the specified name. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class SupplyParameterFromSessionAttribute : CascadingParameterAttributeBase +{ + /// + /// Gets or sets the name of the session attribute. If not specified, the property name will be used. + /// + public string? Name { get; set; } + + /// + internal override bool SingleDelivery => true; +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index dc365194fcbe..166104f9f0f0 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -69,7 +69,9 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(sp => sp.GetRequiredService()); services.TryAddScoped(); + services.TryAddScoped(); services.AddSupplyValueFromQueryProvider(); + services.AddSupplyValueFromSessionProvider(); services.AddSupplyValueFromPersistentComponentStateProvider(); services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index ac24baa2f7d5..b8fc15aacf16 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -122,6 +122,11 @@ internal async Task InitializeStandardComponentServicesAsync( antiforgery.SetRequestContext(httpContext); } + if (httpContext.RequestServices.GetService() is SessionValueMapper sessionValueMapper) + { + sessionValueMapper.SetRequestContext(httpContext); + } + // It's important that this is initialized since a component might try to restore state during prerendering // (which will obviously not work, but should not fail) var componentApplicationLifetime = httpContext.RequestServices.GetRequiredService(); diff --git a/src/Components/Endpoints/src/SessionValueMapper.cs b/src/Components/Endpoints/src/SessionValueMapper.cs new file mode 100644 index 000000000000..33345e9c345d --- /dev/null +++ b/src/Components/Endpoints/src/SessionValueMapper.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class SessionValueMapper : ISessionValueMapper +{ + private HttpContext? _httpContext; + + internal void SetRequestContext(HttpContext httpContext) + { + _httpContext = httpContext; + } + + public object? GetValue(string sessionKey) + { + var session = _httpContext?.Session; + if (session is null) + { + return null; + } + return session.GetString(sessionKey); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 92f616da9831..9928f0fab63a 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,6 +1,10 @@ #nullable enable +Microsoft.AspNetCore.Components.ISessionValueMapper +Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey) -> object? Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.set -> void +Microsoft.Extensions.DependencyInjection.SupplyParameterFromSessionServiceCollectionExtensions +static Microsoft.Extensions.DependencyInjection.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.get -> int *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.set -> void diff --git a/src/Components/Web/src/Session/ISessionValueMapper.cs b/src/Components/Web/src/Session/ISessionValueMapper.cs new file mode 100644 index 000000000000..b57049b4d58b --- /dev/null +++ b/src/Components/Web/src/Session/ISessionValueMapper.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Maps session data values to a model. +/// +public interface ISessionValueMapper +{ + /// + /// Returns the session value with the specified name. + /// + object? GetValue(string sessionKey); +} diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs b/src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs new file mode 100644 index 000000000000..a6dd64a9794b --- /dev/null +++ b/src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Enables component parameters to be supplied from the session with . +/// +public static class SupplyParameterFromSessionServiceCollectionExtensions +{ + /// + /// Enables component parameters to be supplied from the session with . + /// + /// The . + /// The . + public static IServiceCollection AddSupplyValueFromSessionProvider(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + return services; + } +} diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs new file mode 100644 index 000000000000..9326f5cb47e5 --- /dev/null +++ b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Web; + +internal class SupplyParameterFromSessionValueProvider : ICascadingValueSupplier +{ + private readonly ISessionValueMapper _sessionValueMapper; + + public SupplyParameterFromSessionValueProvider(ISessionValueMapper sessionValueMapper) + { + _sessionValueMapper = sessionValueMapper; + } + + public bool IsFixed => true; + + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) + => parameterInfo.Attribute is SupplyParameterFromSessionAttribute; + + public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) + { + if (_sessionValueMapper is null) + { + return null; + } + + var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; + var sessionKey = attribute.Name ?? parameterInfo.PropertyName; + + return _sessionValueMapper.GetValue(sessionKey); + } + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + => throw new NotSupportedException(); // IsFixed = true, so the framework won't call this + + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + => throw new NotSupportedException(); // IsFixed = true, so the framework won't call this +} diff --git a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj index 44f1fcb9fc7f..61f6efd9d77d 100644 --- a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj +++ b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj @@ -78,6 +78,7 @@ + diff --git a/src/Components/test/testassets/Components.TestServer/Program.cs b/src/Components/test/testassets/Components.TestServer/Program.cs index 2f06b00b72ac..f42fbdc26f2e 100644 --- a/src/Components/test/testassets/Components.TestServer/Program.cs +++ b/src/Components/test/testassets/Components.TestServer/Program.cs @@ -38,6 +38,7 @@ public static async Task Main(string[] args) ["Hot Reload"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Dev server client-side blazor"] = CreateDevServerHost(CreateAdditionalArgs(args)), ["Global Interactivity"] = (BuildWebHost>(CreateAdditionalArgs(args)), "/subdir"), + ["SSR (No Interactivity)"] = (BuildWebHost>(CreateAdditionalArgs(args)), "/subdir"), }; var mainHost = BuildWebHost(args); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index bed15d4a46d0..841fc524748d 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Security.Claims; using System.Web; + using Components.TestServer.RazorComponents; using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; @@ -33,6 +34,12 @@ public void ConfigureServices(IServiceCollection services) }); services.AddHttpContextAccessor(); services.AddCascadingAuthenticationState(); + services.AddDistributedMemoryCache(); + services.AddSession(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -88,6 +95,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) }); }); + app.UseSession(); + ConfigureSubdirPipeline(app, env); }); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor new file mode 100644 index 000000000000..9e249e632faf --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor @@ -0,0 +1,27 @@ +@page "/supply-parameter-from-session" +@using Microsoft.AspNetCore.Components.Forms +@inject IHttpContextAccessor HttpContextAccessor + +

SupplyParameterFromSessionComponent

+ +

@Email

+ +
+ + + + + +@code { + [SupplyParameterFromSession] + public string? Email { get; set; } + + [SupplyParameterFromForm] + public string? EmailInput { get; set; } + + void SetEmail() + { + HttpContextAccessor.HttpContext?.Session.SetString(nameof(Email), EmailInput); + Email = EmailInput; + } +} From 596df7fbebccc3bee6a4df40e2b5bdd3d2c8bb80 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 22 Jan 2026 13:46:44 +0100 Subject: [PATCH 02/11] E2E Tests --- ...SupplyParameterFromSessionAttributeTest.cs | 31 +++++++++++++++++++ .../SupplyParameterFromSessionComponent.razor | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs diff --git a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs new file mode 100644 index 000000000000..464126001a0d --- /dev/null +++ b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs @@ -0,0 +1,31 @@ +// 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 System.Collections.Generic; +using System.Text; +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.Tests; + +public class SupplyParameterFromSessionAttributeTest : ServerTestBase>> +{ + public SupplyParameterFromSessionAttributeTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture> serverFixture, ITestOutputHelper output) : base(browserFixture, serverFixture, output) + { + } + + [Fact] + public void SupplyParameterCanReadFromSession() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-email")).SendKeys("email"); + Browser.Exists(By.Id("set-email")).Click(); + Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor index 9e249e632faf..e0421c62372e 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor @@ -9,7 +9,7 @@
- + @code { From 215c5a7b88fe4e00124938280f72945113a94ed4 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 22 Jan 2026 16:01:22 +0100 Subject: [PATCH 03/11] Added serialization, deserialization to the SessionValueMapper --- .../SupplyParameterFromSessionAttribute.cs | 2 +- .../Endpoints/src/SessionValueMapper.cs | 29 +++++++++++++++++-- .../Web/src/PublicAPI.Unshipped.txt | 3 +- .../Web/src/Session/ISessionValueMapper.cs | 7 ++++- ...SupplyParameterFromSessionValueProvider.cs | 27 ++++++++++++++--- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs index d61238a00279..6e20887f0837 100644 --- a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs +++ b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs @@ -16,5 +16,5 @@ public sealed class SupplyParameterFromSessionAttribute : CascadingParameterAttr public string? Name { get; set; } /// - internal override bool SingleDelivery => true; + internal override bool SingleDelivery => false; } diff --git a/src/Components/Endpoints/src/SessionValueMapper.cs b/src/Components/Endpoints/src/SessionValueMapper.cs index 33345e9c345d..3a3d9eb3eebf 100644 --- a/src/Components/Endpoints/src/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/SessionValueMapper.cs @@ -1,12 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.Components.Endpoints; internal class SessionValueMapper : ISessionValueMapper { + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private HttpContext? _httpContext; internal void SetRequestContext(HttpContext httpContext) @@ -14,13 +16,36 @@ internal void SetRequestContext(HttpContext httpContext) _httpContext = httpContext; } - public object? GetValue(string sessionKey) + public object? GetValue(string sessionKey, Type type) { var session = _httpContext?.Session; if (session is null) { return null; } - return session.GetString(sessionKey); + var json = session.GetString(sessionKey); + if (String.IsNullOrEmpty(json)) + { + return null; + } + return JsonSerializer.Deserialize(json, type, _jsonOptions); + } + + public void SetValue(string sessionKey, object? value) + { + var session = _httpContext?.Session; + if (session is null) + { + return; + } + if (value is null) + { + session.Remove(sessionKey); + } + else + { + var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); + session.SetString(sessionKey, json); + } } } diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 3e9781951806..afbe29e73019 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ #nullable enable Microsoft.AspNetCore.Components.ISessionValueMapper -Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey) -> object? +Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! type) -> object? +Microsoft.AspNetCore.Components.ISessionValueMapper.SetValue(string! sessionKey, object? value) -> void Microsoft.AspNetCore.Components.Web.EnvironmentBoundary Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.set -> void diff --git a/src/Components/Web/src/Session/ISessionValueMapper.cs b/src/Components/Web/src/Session/ISessionValueMapper.cs index b57049b4d58b..ab39a40cc1fe 100644 --- a/src/Components/Web/src/Session/ISessionValueMapper.cs +++ b/src/Components/Web/src/Session/ISessionValueMapper.cs @@ -11,5 +11,10 @@ public interface ISessionValueMapper /// /// Returns the session value with the specified name. /// - object? GetValue(string sessionKey); + object? GetValue(string sessionKey, Type type); + + /// + /// Sets the session value with the specified name. + /// + void SetValue(string sessionKey, object? value); } diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs index 9326f5cb47e5..cbee51dcdd9a 100644 --- a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.Web; @@ -14,7 +15,7 @@ public SupplyParameterFromSessionValueProvider(ISessionValueMapper sessionValueM _sessionValueMapper = sessionValueMapper; } - public bool IsFixed => true; + public bool IsFixed => false; public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterFromSessionAttribute; @@ -29,12 +30,30 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; var sessionKey = attribute.Name ?? parameterInfo.PropertyName; - return _sessionValueMapper.GetValue(sessionKey); + return _sessionValueMapper.GetValue(sessionKey, parameterInfo.GetType()); } public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - => throw new NotSupportedException(); // IsFixed = true, so the framework won't call this + { + + } + [UnconditionalSuppressMessage( + "Trimming", + "IL2075:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'", + Justification = "Component properties are preserved through other means.")] public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - => throw new NotSupportedException(); // IsFixed = true, so the framework won't call this + { + var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; + var sessionKey = attribute.Name ?? parameterInfo.PropertyName; + + var componentType = subscriber.Component.GetType(); + var propertyInfo = componentType.GetProperty(parameterInfo.PropertyName); + if (propertyInfo is null) + { + return; + } + var value = propertyInfo.GetValue(subscriber.Component); + _sessionValueMapper.SetValue(sessionKey, value); + } } From 89422d9c4fdd19d19ba39eb9ed02052ea415caae Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Thu, 22 Jan 2026 18:53:30 +0100 Subject: [PATCH 04/11] Check for callback --- .../SupplyParameterFromSessionAttribute.cs | 2 +- .../Endpoints/src/SessionValueMapper.cs | 36 ++++++++++++++++++- .../Web/src/PublicAPI.Unshipped.txt | 3 +- .../Web/src/Session/ISessionValueMapper.cs | 23 +++++++++--- ...SupplyParameterFromSessionValueProvider.cs | 25 ++++++------- ...SupplyParameterFromSessionAttributeTest.cs | 30 ++++++++++++++++ .../SupplyParameterFromSessionComponent.razor | 36 ++++++++++++++++++- ...ameterFromSessionNavigationComponent.razor | 10 ++++++ 8 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor diff --git a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs index 6e20887f0837..8e1f7820c3fb 100644 --- a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs +++ b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs @@ -5,7 +5,7 @@ namespace Microsoft.AspNetCore.Components; /// /// Indicates that the value of the associated property should be supplied from -/// the session attribute with the specified name. +/// the session with the specified name. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class SupplyParameterFromSessionAttribute : CascadingParameterAttributeBase diff --git a/src/Components/Endpoints/src/SessionValueMapper.cs b/src/Components/Endpoints/src/SessionValueMapper.cs index 3a3d9eb3eebf..2fd88d8513a4 100644 --- a/src/Components/Endpoints/src/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/SessionValueMapper.cs @@ -10,10 +10,14 @@ internal class SessionValueMapper : ISessionValueMapper { private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private HttpContext? _httpContext; + private readonly Dictionary> _valueCallbacks = new(); internal void SetRequestContext(HttpContext httpContext) { _httpContext = httpContext; + + // Register Response.OnStarting once to persist all values before response starts + _httpContext.Response.OnStarting(PersistAllValues); } public object? GetValue(string sessionKey, Type type) @@ -24,13 +28,18 @@ internal void SetRequestContext(HttpContext httpContext) return null; } var json = session.GetString(sessionKey); - if (String.IsNullOrEmpty(json)) + if (string.IsNullOrEmpty(json)) { return null; } return JsonSerializer.Deserialize(json, type, _jsonOptions); } + public void RegisterValueCallback(string sessionKey, Func valueGetter) + { + _valueCallbacks[sessionKey] = valueGetter; + } + public void SetValue(string sessionKey, object? value) { var session = _httpContext?.Session; @@ -48,4 +57,29 @@ public void SetValue(string sessionKey, object? value) session.SetString(sessionKey, json); } } + + private Task PersistAllValues() + { + var session = _httpContext?.Session; + if (session is null) + { + return Task.CompletedTask; + } + + foreach (var (key, valueGetter) in _valueCallbacks) + { + var value = valueGetter(); + if (value is not null) + { + var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); + session.SetString(key, json); + } + else + { + session.Remove(key); + } + } + + return Task.CompletedTask; + } } diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index afbe29e73019..2c192e70f361 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ #nullable enable Microsoft.AspNetCore.Components.ISessionValueMapper -Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! type) -> object? +Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? +Microsoft.AspNetCore.Components.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void Microsoft.AspNetCore.Components.ISessionValueMapper.SetValue(string! sessionKey, object? value) -> void Microsoft.AspNetCore.Components.Web.EnvironmentBoundary Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? diff --git a/src/Components/Web/src/Session/ISessionValueMapper.cs b/src/Components/Web/src/Session/ISessionValueMapper.cs index ab39a40cc1fe..c4db9a2973dd 100644 --- a/src/Components/Web/src/Session/ISessionValueMapper.cs +++ b/src/Components/Web/src/Session/ISessionValueMapper.cs @@ -9,12 +9,25 @@ namespace Microsoft.AspNetCore.Components; public interface ISessionValueMapper { /// - /// Returns the session value with the specified name. + /// Returns the session value with the specified name, deserialized to the specified type. /// - object? GetValue(string sessionKey, Type type); + /// The session key. + /// The type to deserialize to. + /// The deserialized value, or null if not found. + object? GetValue(string sessionKey, Type targetType); - /// - /// Sets the session value with the specified name. - /// + /// + /// Stores the value in the session with the specified key. + /// + /// The session key. + /// The value to store. void SetValue(string sessionKey, object? value); + + /// + /// Registers a callback to retrieve the current value of a session property. + /// The callback will be invoked when the response starts to persist the value. + /// + /// The session key. + /// A function that returns the current value. + void RegisterValueCallback(string sessionKey, Func valueGetter); } diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs index cbee51dcdd9a..4a893a43dfc9 100644 --- a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -29,31 +29,32 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; var sessionKey = attribute.Name ?? parameterInfo.PropertyName; - - return _sessionValueMapper.GetValue(sessionKey, parameterInfo.GetType()); - } - - public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - { - + return _sessionValueMapper.GetValue(sessionKey, parameterInfo.PropertyType); } [UnconditionalSuppressMessage( "Trimming", "IL2075:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'", Justification = "Component properties are preserved through other means.")] - public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; var sessionKey = attribute.Name ?? parameterInfo.PropertyName; - var componentType = subscriber.Component.GetType(); - var propertyInfo = componentType.GetProperty(parameterInfo.PropertyName); + // Capture these in the closure + var component = subscriber.Component; + var propertyName = parameterInfo.PropertyName; + var componentType = component.GetType(); + var propertyInfo = componentType.GetProperty(propertyName); + if (propertyInfo is null) { return; } - var value = propertyInfo.GetValue(subscriber.Component); - _sessionValueMapper.SetValue(sessionKey, value); + _sessionValueMapper.RegisterValueCallback(sessionKey, () => propertyInfo.GetValue(component)); + } + + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { } } diff --git a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs index 464126001a0d..8e7b7ba64ed1 100644 --- a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs +++ b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs @@ -28,4 +28,34 @@ public void SupplyParameterCanReadFromSession() Browser.Exists(By.Id("set-email")).Click(); Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); } + + [Fact] + public void SupplyParameterNullWhenNoKeyInSession() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-email")).SendKeys("email"); + Browser.Exists(By.Id("set-email")).Click(); + Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); + Browser.Equal("", () => Browser.Exists(By.Id("text-null")).Text); + } + + [Fact] + public void SupplyParameterWorksWhenDifferentName() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-another-email")).SendKeys("email"); + Browser.Exists(By.Id("set-another-email")).Click(); + Browser.Equal("email", () => Browser.Exists(By.Id("text-another-email")).Text); + Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); + Browser.Equal("", () => Browser.Exists(By.Id("text-null")).Text); + } + + [Fact] + public void SupplyParameterWorksWithRedirect() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-email-redirect")).SendKeys("email"); + Browser.Exists(By.Id("set-email-redirect")).Click(); + Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor index e0421c62372e..db46c864c88f 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor @@ -1,10 +1,13 @@ @page "/supply-parameter-from-session" @using Microsoft.AspNetCore.Components.Forms @inject IHttpContextAccessor HttpContextAccessor +@inject NavigationManager NavigationManager

SupplyParameterFromSessionComponent

@Email

+

@AnotherEmail

+

@NoElement

@@ -12,16 +15,47 @@ +
+ + + + + +
+ + + + + @code { [SupplyParameterFromSession] public string? Email { get; set; } + [SupplyParameterFromSession(Name = "Email")] + public string? AnotherEmail { get; set; } + + [SupplyParameterFromSession] + public string? NoElement { get; set; } + [SupplyParameterFromForm] public string? EmailInput { get; set; } void SetEmail() { - HttpContextAccessor.HttpContext?.Session.SetString(nameof(Email), EmailInput); Email = EmailInput; + EmailInput = string.Empty; + } + + void SetAnotherEmail() + { + AnotherEmail = EmailInput; + EmailInput = string.Empty; + } + + void SetEmailWithRedirect() + { + Email = EmailInput; + EmailInput = string.Empty; + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session/navigation", forceLoad: true); } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor new file mode 100644 index 000000000000..4918d4c3901c --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor @@ -0,0 +1,10 @@ +@page "/supply-parameter-from-session/navigation" + +

SupplyParameterFromSessionNavigationComponent

+ +

@Email

+ +@code { + [SupplyParameterFromSession] + public string? Email { get; set; } +} From b47e153c1043518c037224f978bee8ee4b0a70e6 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Fri, 23 Jan 2026 17:03:30 +0100 Subject: [PATCH 05/11] Fixes callbacks and added new tests --- .../Endpoints/src/SessionValueMapper.cs | 83 +++-- .../Endpoints/test/SessionValueMapperTest.cs | 293 ++++++++++++++++++ .../Web/src/PublicAPI.Unshipped.txt | 1 - .../Web/src/Session/ISessionValueMapper.cs | 7 - ...SupplyParameterFromSessionValueProvider.cs | 18 +- ...SupplyParameterFromSessionAttributeTest.cs | 27 ++ ...omponentEndpointsNoInteractivityStartup.cs | 17 +- .../SupplyParameterFromSessionComponent.razor | 59 +++- ...ameterFromSessionNavigationComponent.razor | 13 + 9 files changed, 467 insertions(+), 51 deletions(-) create mode 100644 src/Components/Endpoints/test/SessionValueMapperTest.cs diff --git a/src/Components/Endpoints/src/SessionValueMapper.cs b/src/Components/Endpoints/src/SessionValueMapper.cs index 2fd88d8513a4..ff80d8556793 100644 --- a/src/Components/Endpoints/src/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/SessionValueMapper.cs @@ -3,72 +3,89 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Endpoints; -internal class SessionValueMapper : ISessionValueMapper +internal partial class SessionValueMapper : ISessionValueMapper { private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private HttpContext? _httpContext; - private readonly Dictionary> _valueCallbacks = new(); + private readonly Dictionary>> _valueCallbacks = new(); + private readonly ILogger _logger; + + public SessionValueMapper(ILogger logger) + { + _logger = logger; + } internal void SetRequestContext(HttpContext httpContext) { _httpContext = httpContext; - - // Register Response.OnStarting once to persist all values before response starts _httpContext.Response.OnStarting(PersistAllValues); } public object? GetValue(string sessionKey, Type type) { - var session = _httpContext?.Session; + var session = _httpContext?.Features.Get()?.Session; if (session is null) { return null; } - var json = session.GetString(sessionKey); - if (string.IsNullOrEmpty(json)) + try + { + var json = session.GetString(sessionKey); + if (string.IsNullOrEmpty(json)) + { + return null; + } + return JsonSerializer.Deserialize(json, type, _jsonOptions); + } + catch (JsonException ex) { + Log.SessionDeserializeFail(_logger, ex); return null; } - return JsonSerializer.Deserialize(json, type, _jsonOptions); } public void RegisterValueCallback(string sessionKey, Func valueGetter) { - _valueCallbacks[sessionKey] = valueGetter; - } - - public void SetValue(string sessionKey, object? value) - { - var session = _httpContext?.Session; - if (session is null) + if (!_valueCallbacks.TryGetValue(sessionKey, out var callbacks)) { - return; - } - if (value is null) - { - session.Remove(sessionKey); - } - else - { - var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); - session.SetString(sessionKey, json); + callbacks = new List>(); + _valueCallbacks[sessionKey] = callbacks; } + callbacks.Add(valueGetter); } private Task PersistAllValues() { - var session = _httpContext?.Session; + var session = _httpContext?.Features.Get()?.Session; if (session is null) { return Task.CompletedTask; } - foreach (var (key, valueGetter) in _valueCallbacks) + foreach (var (key, callbacks) in _valueCallbacks) { - var value = valueGetter(); + object? value = null; + foreach (var valueGetter in callbacks) + { + try + { + var candidateValue = valueGetter(); + if (candidateValue is not null) + { + value = candidateValue; + break; + } + } + catch (Exception ex) + { + Log.SessionPersistFail(_logger, ex); + } + } if (value is not null) { var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); @@ -79,7 +96,15 @@ private Task PersistAllValues() session.Remove(key); } } - return Task.CompletedTask; } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Persisting of the element failed.", EventName = "SessionPersistFail")] + public static partial void SessionPersistFail(ILogger logger, Exception exception); + + [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from Session failed.", EventName = "SessionDeserializeFail")] + public static partial void SessionDeserializeFail(ILogger logger, JsonException exception); + } } diff --git a/src/Components/Endpoints/test/SessionValueMapperTest.cs b/src/Components/Endpoints/test/SessionValueMapperTest.cs new file mode 100644 index 000000000000..68f6e7f36f99 --- /dev/null +++ b/src/Components/Endpoints/test/SessionValueMapperTest.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class SessionValueMapperTest +{ + private SessionValueMapper GetSessionValueMapper() + { + return new SessionValueMapper(new Microsoft.Extensions.Logging.Abstractions.NullLogger()); + } + + [Fact] + public void GetValue_ReturnsNull_WhenKeyNotFound() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(); + mapper.SetRequestContext(httpContext); + + // Act + var result = mapper.GetValue("nonexistent", typeof(string)); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValue_ReturnsDeserializedValue_WhenKeyExists() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("email", "\"test@example.com\""); + mapper.SetRequestContext(httpContext); + + // Act + var result = mapper.GetValue("email", typeof(string)); + + // Assert + Assert.Equal("test@example.com", result); + } + + [Fact] + public void GetValue_DeserializesComplexType() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("user", "{\"name\":\"John\",\"age\":30}"); + mapper.SetRequestContext(httpContext); + + // Act + var result = mapper.GetValue("user", typeof(TestUser)); + + // Assert + var user = Assert.IsType(result); + Assert.Equal("John", user.Name); + Assert.Equal(30, user.Age); + } + + [Fact] + public void RegisterValueCallback_AddsCallbackToList() + { + // Arrange + var mapper = GetSessionValueMapper(); + var callCount = 0; + + // Act + mapper.RegisterValueCallback("key", () => { callCount++; return "value"; }); + + // Assert + Assert.Equal(0, callCount); + } + + [Fact] + public async Task RegisterValueCallback_AllowsMultipleCallbacksForSameKey() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + var callOrder = new List(); + mapper.RegisterValueCallback("key", () => { callOrder.Add(1); return null; }); + mapper.RegisterValueCallback("key", () => { callOrder.Add(2); return "value2"; }); + mapper.RegisterValueCallback("key", () => { callOrder.Add(3); return "value3"; }); + + // Act + await responseFeature.FireOnStartingAsync(); + + // Assert + Assert.Equal(new[] { 1, 2 }, callOrder); + Assert.Equal("\"value2\"", httpContext.Session.GetString("key")); + } + + [Fact] + public async Task PersistAllValues_PersistsFirstNonNullValue() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("email", () => null); + mapper.RegisterValueCallback("email", () => "test@example.com"); + mapper.RegisterValueCallback("email", () => "other@example.com"); + + // Act + await responseFeature.FireOnStartingAsync(); + + // Assert + Assert.Equal("\"test@example.com\"", httpContext.Session.GetString("email")); + } + + [Fact] + public async Task PersistAllValues_RemovesKey_WhenAllCallbacksReturnNull() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + httpContext.Session.SetString("email", "\"existing@example.com\""); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("email", () => null); + mapper.RegisterValueCallback("email", () => null); + + // Act + await responseFeature.FireOnStartingAsync(); + + // Assert + Assert.Null(httpContext.Session.GetString("email")); + } + + [Fact] + public async Task PersistAllValues_HandlesMultipleKeys() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("key1", () => "value1"); + mapper.RegisterValueCallback("key2", () => "value2"); + + // Act + await responseFeature.FireOnStartingAsync(); + + // Assert + Assert.Equal("\"value1\"", httpContext.Session.GetString("key1")); + Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); + } + + [Fact] + public async Task PersistAllValues_SerializesComplexTypes() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("user", () => new TestUser { Name = "Jane", Age = 25 }); + + // Act + await responseFeature.FireOnStartingAsync(); + + // Assert + var json = httpContext.Session.GetString("user"); + Assert.NotNull(json); + Assert.Contains("\"name\":\"Jane\"", json); + Assert.Contains("\"age\":25", json); + } + + [Fact] + public async Task HandlesIncorrectValuesInSession() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("number", () => "not-a-number"); + + // Act + await responseFeature.FireOnStartingAsync(); + var result = mapper.GetValue("number", typeof(int)); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task HandlesCallbackThrowing() + { + // Arrange + var mapper = GetSessionValueMapper(); + var httpContext = CreateHttpContextWithSession(out var responseFeature); + mapper.SetRequestContext(httpContext); + + mapper.RegisterValueCallback("number", () => throw new Exception("Callback exception")); + + // Act + await responseFeature.FireOnStartingAsync(); + var result = mapper.GetValue("number", typeof(int)); + + // Assert + Assert.Null(result); + } + + private static DefaultHttpContext CreateHttpContextWithSession() + { + return CreateHttpContextWithSession(out _); + } + + private static DefaultHttpContext CreateHttpContextWithSession(out TestHttpResponseFeature responseFeature) + { + var httpContext = new DefaultHttpContext(); + var session = new TestSession(); + httpContext.Features.Set(new TestSessionFeature(session)); + + responseFeature = new TestHttpResponseFeature(); + httpContext.Features.Set(responseFeature); + + return httpContext; + } + + private class TestUser + { + public string? Name { get; set; } + public int Age { get; set; } + } + + private class TestSession : ISession + { + private readonly Dictionary _store = new(); + + public bool IsAvailable => true; + public string Id => "test-session"; + public IEnumerable Keys => _store.Keys; + + public void Clear() => _store.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _store.Remove(key); + public void Set(string key, byte[] value) => _store[key] = value; + public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out byte[]? value) => _store.TryGetValue(key, out value); + } + + private class TestSessionFeature : ISessionFeature + { + public TestSessionFeature(ISession session) + { + Session = session; + } + + public ISession Session { get; set; } + } + + private class TestHttpResponseFeature : IHttpResponseFeature + { + private readonly Stack<(Func Callback, object State)> _onStarting = new(); + + public int StatusCode { get; set; } = 200; + public string? ReasonPhrase { get; set; } + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = new MemoryStream(); + public bool HasStarted { get; private set; } + + public void OnCompleted(Func callback, object state) + { + } + + public void OnStarting(Func callback, object state) + { + _onStarting.Push((callback, state)); + } + + public async Task FireOnStartingAsync() + { + foreach (var (callback, state) in _onStarting) + { + await callback(state); + } + HasStarted = true; + } + } +} + diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 2c192e70f361..e654d3486939 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -2,7 +2,6 @@ Microsoft.AspNetCore.Components.ISessionValueMapper Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? Microsoft.AspNetCore.Components.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void -Microsoft.AspNetCore.Components.ISessionValueMapper.SetValue(string! sessionKey, object? value) -> void Microsoft.AspNetCore.Components.Web.EnvironmentBoundary Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.set -> void diff --git a/src/Components/Web/src/Session/ISessionValueMapper.cs b/src/Components/Web/src/Session/ISessionValueMapper.cs index c4db9a2973dd..3ceade341174 100644 --- a/src/Components/Web/src/Session/ISessionValueMapper.cs +++ b/src/Components/Web/src/Session/ISessionValueMapper.cs @@ -16,13 +16,6 @@ public interface ISessionValueMapper /// The deserialized value, or null if not found. object? GetValue(string sessionKey, Type targetType); - /// - /// Stores the value in the session with the specified key. - /// - /// The session key. - /// The value to store. - void SetValue(string sessionKey, object? value); - /// /// Registers a callback to retrieve the current value of a session property. /// The callback will be invoked when the response starts to persist the value. diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs index 4a893a43dfc9..4242f459fb95 100644 --- a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -28,7 +28,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) } var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = attribute.Name ?? parameterInfo.PropertyName; + var sessionKey = (attribute.Name ?? parameterInfo.PropertyName).ToLowerInvariant(); return _sessionValueMapper.GetValue(sessionKey, parameterInfo.PropertyType); } @@ -39,19 +39,21 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = attribute.Name ?? parameterInfo.PropertyName; - - // Capture these in the closure - var component = subscriber.Component; + var sessionKey = (attribute.Name ?? parameterInfo.PropertyName).ToLowerInvariant(); var propertyName = parameterInfo.PropertyName; - var componentType = component.GetType(); - var propertyInfo = componentType.GetProperty(propertyName); + var propertyInfo = subscriber.Component.GetType().GetProperty(propertyName); if (propertyInfo is null) { return; } - _sessionValueMapper.RegisterValueCallback(sessionKey, () => propertyInfo.GetValue(component)); + + var component = subscriber.Component; + _sessionValueMapper.RegisterValueCallback(sessionKey, () => + { + var value = propertyInfo.GetValue(component); + return value; + }); } public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) diff --git a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs index 8e7b7ba64ed1..d7fa5e740c9b 100644 --- a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs +++ b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs @@ -20,6 +20,12 @@ public SupplyParameterFromSessionAttributeTest(BrowserFixture browserFixture, Ba { } + protected override void InitializeAsyncCore() + { + _serverFixture.AdditionalArguments.Add("--UseSession=true"); + base.InitializeAsyncCore(); + } + [Fact] public void SupplyParameterCanReadFromSession() { @@ -58,4 +64,25 @@ public void SupplyParameterWorksWithRedirect() Browser.Exists(By.Id("set-email-redirect")).Click(); Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); } + + [Fact] + public void SupplyParameterWorksWithComplexTypes() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-testclass-email")).SendKeys("email"); + Browser.Exists(By.Id("input-testclass-age")).SendKeys("30"); + Browser.Exists(By.Id("set-testclass-object")).Click(); + Browser.Equal("email", () => Browser.Exists(By.Id("text-testclass-email")).Text); + Browser.Equal("30", () => Browser.Exists(By.Id("text-testclass-age")).Text); + } + + [Fact] + public void SupplyParameterPersistThroughNavigationAndBack() + { + Navigate($"{ServerPathBase}/supply-parameter-from-session"); + Browser.Exists(By.Id("input-number")).SendKeys("12345"); + Browser.Exists(By.Id("set-number")).Click(); + Browser.Exists(By.Id("redirect")).Click(); + Browser.Equal("12345", () => Browser.Exists(By.Id("text-number")).Text); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index 841fc524748d..84df9c7cb20b 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -35,11 +35,15 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpContextAccessor(); services.AddCascadingAuthenticationState(); services.AddDistributedMemoryCache(); - services.AddSession(options => + + if (Configuration.GetValue("UseSession")) { - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - }); + services.AddSession(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + }); + } } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -95,7 +99,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) }); }); - app.UseSession(); + if (Configuration.GetValue("UseSession")) + { + app.UseSession(); + } ConfigureSubdirPipeline(app, env); }); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor index db46c864c88f..69dc4a85c1a7 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor @@ -8,6 +8,10 @@

@Email

@AnotherEmail

@NoElement

+

@Number

+ +

@TestClassObject?.Email

+

@TestClassObject?.Age

@@ -21,35 +25,69 @@ +
+ + + + +
+
+ + + + + + @code { [SupplyParameterFromSession] public string? Email { get; set; } - [SupplyParameterFromSession(Name = "Email")] + [SupplyParameterFromSession(Name = "email")] public string? AnotherEmail { get; set; } [SupplyParameterFromSession] public string? NoElement { get; set; } + [SupplyParameterFromSession] + public int? Number { get; set; } + [SupplyParameterFromForm] public string? EmailInput { get; set; } + [SupplyParameterFromForm] + public int? NumberInput { get; set; } + + [SupplyParameterFromForm] + public int? AgeInput { get; set; } + + [SupplyParameterFromSession] + public TestClass? TestClassObject { get; set; } + void SetEmail() { Email = EmailInput; EmailInput = string.Empty; + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session", forceLoad: true); } void SetAnotherEmail() { AnotherEmail = EmailInput; EmailInput = string.Empty; + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session", forceLoad: true); + } + + void SetNumber() + { + Number = NumberInput; + NumberInput = null; + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session/navigation", forceLoad: true); } void SetEmailWithRedirect() @@ -58,4 +96,23 @@ EmailInput = string.Empty; NavigationManager.NavigateTo("/subdir/supply-parameter-from-session/navigation", forceLoad: true); } + + void SetTestClassObject() + { + if (TestClassObject == null) + { + TestClassObject = new TestClass(); + } + TestClassObject.Email = EmailInput; + TestClassObject.Age = AgeInput; + EmailInput = string.Empty; + AgeInput = null; + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session", forceLoad: true); + } + + public class TestClass + { + public string? Email { get; set; } + public int? Age { get; set; } + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor index 4918d4c3901c..4bdd6697b8be 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor @@ -1,10 +1,23 @@ @page "/supply-parameter-from-session/navigation" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager

SupplyParameterFromSessionNavigationComponent

@Email

+
+ + + + @code { [SupplyParameterFromSession] public string? Email { get; set; } + + + void Redirect() + { + NavigationManager.NavigateTo("/subdir/supply-parameter-from-session"); + } } From 3ee272da2ac746b20421bb1536554e9960126903 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 9 Feb 2026 17:57:49 +0100 Subject: [PATCH 06/11] Small fixes --- .../Microsoft.AspNetCore.Components.csproj | 1 + .../Endpoints/src/PublicAPI.Unshipped.txt | 6 ++++++ .../src/Rendering/EndpointComponentState.cs | 2 +- .../EndpointHtmlRenderer.Prerendering.cs | 4 ++-- .../src/Rendering/EndpointHtmlRenderer.cs | 2 +- .../src/Session/ISessionValueMapper.cs | 12 +++++------ .../src/{ => Session}/SessionValueMapper.cs | 5 +++++ ...rFromSessionServiceCollectionExtensions.cs | 6 ++---- ...SupplyParameterFromSessionValueProvider.cs | 20 ++++++++++--------- .../Web/src/PublicAPI.Unshipped.txt | 5 ----- 10 files changed, 35 insertions(+), 28 deletions(-) rename src/Components/{Web => Endpoints}/src/Session/ISessionValueMapper.cs (65%) rename src/Components/Endpoints/src/{ => Session}/SessionValueMapper.cs (96%) rename src/Components/{Web => Endpoints}/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs (88%) rename src/Components/{Web => Endpoints}/src/Session/SupplyParameterFromSessionValueProvider.cs (77%) diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index ba3ae6420645..58010a3bef6c 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -68,6 +68,7 @@ + diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 2ebef8b69595..d6fa4f7da8b5 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,10 +1,15 @@ #nullable enable Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void +Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper +Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.DeleteValueCallback(string! sessionKey) -> void +Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? +Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.get -> Microsoft.AspNetCore.Http.CookieBuilder! Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.set -> void Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataProviderType.get -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataProviderType.set -> void +Microsoft.AspNetCore.Components.Endpoints.SupplyParameterFromSessionServiceCollectionExtensions Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType.Cookie = 0 -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType.SessionStorage = 1 -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType @@ -13,3 +18,4 @@ Microsoft.AspNetCore.Components.ITempData.Get(string! key) -> object? Microsoft.AspNetCore.Components.ITempData.Keep() -> void Microsoft.AspNetCore.Components.ITempData.Keep(string! key) -> void Microsoft.AspNetCore.Components.ITempData.Peek(string! key) -> object? +static Microsoft.AspNetCore.Components.Endpoints.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index 6c03d0585f2f..d85f67c4400d 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -47,7 +47,7 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com public bool StreamRendering { get; } - protected override object? GetComponentKey() + protected internal override object? GetComponentKey() { if (ParentComponentState != null && ParentComponentState.Component is SSRRenderModeBoundary boundary) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 0276cdfd1515..1fc08b49d889 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -18,7 +18,7 @@ internal partial class EndpointHtmlRenderer { private static readonly object ComponentSequenceKey = new object(); - protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) + protected internal override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { if (_isHandlingErrors) { @@ -42,7 +42,7 @@ protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessed } } - protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) + protected internal override IComponentRenderMode? GetComponentRenderMode(IComponent component) { var componentState = GetComponentState(component); var ssrRenderBoundary = GetClosestRenderModeBoundary(componentState); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index b8fc15aacf16..8a6eb4dc5b26 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -166,7 +166,7 @@ protected override ComponentState CreateComponentState(int componentId, ICompone => new EndpointComponentState(this, componentId, component, parentComponentState); /// - protected override ResourceAssetCollection Assets => + protected internal override ResourceAssetCollection Assets => _resourceCollection ??= GetResourceCollection(_httpContext) ?? base.Assets; private static ResourceAssetCollection? GetResourceCollection(HttpContext httpContext) => httpContext.GetEndpoint()?.Metadata.GetMetadata(); diff --git a/src/Components/Web/src/Session/ISessionValueMapper.cs b/src/Components/Endpoints/src/Session/ISessionValueMapper.cs similarity index 65% rename from src/Components/Web/src/Session/ISessionValueMapper.cs rename to src/Components/Endpoints/src/Session/ISessionValueMapper.cs index 3ceade341174..956ecbc40b5e 100644 --- a/src/Components/Web/src/Session/ISessionValueMapper.cs +++ b/src/Components/Endpoints/src/Session/ISessionValueMapper.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components.Endpoints; /// /// Maps session data values to a model. @@ -11,16 +11,16 @@ public interface ISessionValueMapper /// /// Returns the session value with the specified name, deserialized to the specified type. /// - /// The session key. - /// The type to deserialize to. - /// The deserialized value, or null if not found. object? GetValue(string sessionKey, Type targetType); /// /// Registers a callback to retrieve the current value of a session property. /// The callback will be invoked when the response starts to persist the value. /// - /// The session key. - /// A function that returns the current value. void RegisterValueCallback(string sessionKey, Func valueGetter); + + /// + /// Unregisters a previously registered callback for the specified session key. + /// + void DeleteValueCallback(string sessionKey); } diff --git a/src/Components/Endpoints/src/SessionValueMapper.cs b/src/Components/Endpoints/src/Session/SessionValueMapper.cs similarity index 96% rename from src/Components/Endpoints/src/SessionValueMapper.cs rename to src/Components/Endpoints/src/Session/SessionValueMapper.cs index ff80d8556793..4dbeaabb2362 100644 --- a/src/Components/Endpoints/src/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/Session/SessionValueMapper.cs @@ -59,6 +59,11 @@ public void RegisterValueCallback(string sessionKey, Func valueGetter) callbacks.Add(valueGetter); } + public void DeleteValueCallback(string sessionKey) + { + _valueCallbacks.Remove(sessionKey); + } + private Task PersistAllValues() { var session = _httpContext?.Features.Get()?.Session; diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs similarity index 88% rename from src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs rename to src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs index a6dd64a9794b..10fb4f99fb20 100644 --- a/src/Components/Web/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs @@ -1,11 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Components.Endpoints; /// /// Enables component parameters to be supplied from the session with . diff --git a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs similarity index 77% rename from src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs rename to src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs index 4242f459fb95..4004331fd944 100644 --- a/src/Components/Web/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -2,9 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; -namespace Microsoft.AspNetCore.Components.Web; +namespace Microsoft.AspNetCore.Components.Endpoints; internal class SupplyParameterFromSessionValueProvider : ICascadingValueSupplier { @@ -28,7 +29,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) } var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = (attribute.Name ?? parameterInfo.PropertyName).ToLowerInvariant(); + var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; return _sessionValueMapper.GetValue(sessionKey, parameterInfo.PropertyType); } @@ -39,9 +40,10 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = (attribute.Name ?? parameterInfo.PropertyName).ToLowerInvariant(); + var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; var propertyName = parameterInfo.PropertyName; - var propertyInfo = subscriber.Component.GetType().GetProperty(propertyName); + var componentType = subscriber.Component.GetType(); + var propertyInfo = componentType.GetProperty(propertyName); if (propertyInfo is null) { @@ -49,14 +51,14 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param } var component = subscriber.Component; - _sessionValueMapper.RegisterValueCallback(sessionKey, () => - { - var value = propertyInfo.GetValue(component); - return value; - }); + var getter = new PropertyGetter(componentType, propertyInfo); + _sessionValueMapper.RegisterValueCallback(sessionKey, () => getter.GetValue(component)); } public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { + var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; + var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; + _sessionValueMapper.DeleteValueCallback(sessionKey); } } diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 1f1c48a41bca..b4c6d1f43c3a 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,7 +1,4 @@ #nullable enable -Microsoft.AspNetCore.Components.ISessionValueMapper -Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? -Microsoft.AspNetCore.Components.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void Microsoft.AspNetCore.Components.Web.EnvironmentBoundary Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.set -> void @@ -12,8 +9,6 @@ Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.get -> string? Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.set -> void Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.get -> bool Microsoft.AspNetCore.Components.Routing.NavLink.RelativeToCurrentUri.set -> void -Microsoft.Extensions.DependencyInjection.SupplyParameterFromSessionServiceCollectionExtensions -static Microsoft.Extensions.DependencyInjection.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.get -> int *REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.set -> void From 5f9ca6007c4a3b6217eaaa6b6896300b18847bbd Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 11 Feb 2026 20:25:18 +0100 Subject: [PATCH 07/11] Fixes --- .../SupplyParameterFromSessionAttribute.cs | 2 +- .../src/Session/SessionValueMapper.cs | 36 +++++++------------ ...SupplyParameterFromSessionValueProvider.cs | 5 --- ...SupplyParameterFromSessionAttributeTest.cs | 11 ------ .../Tests/TempDataSessionStorageTest.cs | 1 + ...omponentEndpointsNoInteractivityStartup.cs | 4 +-- .../SupplyParameterFromSessionComponent.razor | 18 ---------- ...ameterFromSessionNavigationComponent.razor | 2 +- 8 files changed, 18 insertions(+), 61 deletions(-) diff --git a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs index 8e1f7820c3fb..50faa0b4dd4d 100644 --- a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs +++ b/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components; public sealed class SupplyParameterFromSessionAttribute : CascadingParameterAttributeBase { /// - /// Gets or sets the name of the session attribute. If not specified, the property name will be used. + /// Gets or sets the name of the session key. If not specified, the property name will be used. /// public string? Name { get; set; } diff --git a/src/Components/Endpoints/src/Session/SessionValueMapper.cs b/src/Components/Endpoints/src/Session/SessionValueMapper.cs index 4dbeaabb2362..368ae831328a 100644 --- a/src/Components/Endpoints/src/Session/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/Session/SessionValueMapper.cs @@ -12,7 +12,7 @@ internal partial class SessionValueMapper : ISessionValueMapper { private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); private HttpContext? _httpContext; - private readonly Dictionary>> _valueCallbacks = new(); + private readonly Dictionary> _valueCallbacks = new(StringComparer.OrdinalIgnoreCase); private readonly ILogger _logger; public SessionValueMapper(ILogger logger) @@ -35,7 +35,7 @@ internal void SetRequestContext(HttpContext httpContext) } try { - var json = session.GetString(sessionKey); + var json = session.GetString(sessionKey.ToLowerInvariant()); if (string.IsNullOrEmpty(json)) { return null; @@ -51,12 +51,10 @@ internal void SetRequestContext(HttpContext httpContext) public void RegisterValueCallback(string sessionKey, Func valueGetter) { - if (!_valueCallbacks.TryGetValue(sessionKey, out var callbacks)) + if (!_valueCallbacks.TryAdd(sessionKey, valueGetter)) { - callbacks = new List>(); - _valueCallbacks[sessionKey] = callbacks; + throw new InvalidOperationException($"A callback is already registered for the session key '{sessionKey}'. Multiple components cannot use the same session key."); } - callbacks.Add(valueGetter); } public void DeleteValueCallback(string sessionKey) @@ -72,33 +70,25 @@ private Task PersistAllValues() return Task.CompletedTask; } - foreach (var (key, callbacks) in _valueCallbacks) + foreach (var (key, valueGetter) in _valueCallbacks) { object? value = null; - foreach (var valueGetter in callbacks) + try { - try - { - var candidateValue = valueGetter(); - if (candidateValue is not null) - { - value = candidateValue; - break; - } - } - catch (Exception ex) - { - Log.SessionPersistFail(_logger, ex); - } + value = valueGetter(); + } + catch (Exception ex) + { + Log.SessionPersistFail(_logger, ex); } if (value is not null) { var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); - session.SetString(key, json); + session.SetString(key.ToLowerInvariant(), json); } else { - session.Remove(key); + session.Remove(key.ToLowerInvariant()); } } return Task.CompletedTask; diff --git a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs index 4004331fd944..180d25dc58c4 100644 --- a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs @@ -23,11 +23,6 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { - if (_sessionValueMapper is null) - { - return null; - } - var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; return _sessionValueMapper.GetValue(sessionKey, parameterInfo.PropertyType); diff --git a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs index d7fa5e740c9b..f8537fd2ba23 100644 --- a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs +++ b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs @@ -45,17 +45,6 @@ public void SupplyParameterNullWhenNoKeyInSession() Browser.Equal("", () => Browser.Exists(By.Id("text-null")).Text); } - [Fact] - public void SupplyParameterWorksWhenDifferentName() - { - Navigate($"{ServerPathBase}/supply-parameter-from-session"); - Browser.Exists(By.Id("input-another-email")).SendKeys("email"); - Browser.Exists(By.Id("set-another-email")).Click(); - Browser.Equal("email", () => Browser.Exists(By.Id("text-another-email")).Text); - Browser.Equal("email", () => Browser.Exists(By.Id("text-email")).Text); - Browser.Equal("", () => Browser.Exists(By.Id("text-null")).Text); - } - [Fact] public void SupplyParameterWorksWithRedirect() { diff --git a/src/Components/test/E2ETest/Tests/TempDataSessionStorageTest.cs b/src/Components/test/E2ETest/Tests/TempDataSessionStorageTest.cs index f1836c8db8c4..42437ff68208 100644 --- a/src/Components/test/E2ETest/Tests/TempDataSessionStorageTest.cs +++ b/src/Components/test/E2ETest/Tests/TempDataSessionStorageTest.cs @@ -27,6 +27,7 @@ public TempDataSessionStorageTest( protected override void InitializeAsyncCore() { _serverFixture.AdditionalArguments.Add("--UseSessionStorageTempDataProvider=true"); + _serverFixture.AdditionalArguments.Add("--UseSession=true"); Browser.Manage().Cookies.DeleteCookieNamed(SessionCookieName); base.InitializeAsyncCore(); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs index ba0d5ac06bc9..de43c510a42a 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsNoInteractivityStartup.cs @@ -36,7 +36,7 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpContextAccessor(); services.AddCascadingAuthenticationState(); - if (Configuration.GetValue("UseSession") || Configuration.GetValue("UseSessionStorageTempDataProvider")) + if (Configuration.GetValue("UseSession")) { services.AddDistributedMemoryCache(); services.AddSession(options => @@ -44,7 +44,7 @@ public void ConfigureServices(IServiceCollection services) options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; }); - + if (Configuration.GetValue("UseSessionStorageTempDataProvider")) { services.Configure(options => diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor index 69dc4a85c1a7..688553aa6e47 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionComponent.razor @@ -1,12 +1,10 @@ @page "/supply-parameter-from-session" @using Microsoft.AspNetCore.Components.Forms -@inject IHttpContextAccessor HttpContextAccessor @inject NavigationManager NavigationManager

SupplyParameterFromSessionComponent

@Email

-

@AnotherEmail

@NoElement

@Number

@@ -19,12 +17,6 @@ -
- - - - -
@@ -48,9 +40,6 @@ [SupplyParameterFromSession] public string? Email { get; set; } - [SupplyParameterFromSession(Name = "email")] - public string? AnotherEmail { get; set; } - [SupplyParameterFromSession] public string? NoElement { get; set; } @@ -76,13 +65,6 @@ NavigationManager.NavigateTo("/subdir/supply-parameter-from-session", forceLoad: true); } - void SetAnotherEmail() - { - AnotherEmail = EmailInput; - EmailInput = string.Empty; - NavigationManager.NavigateTo("/subdir/supply-parameter-from-session", forceLoad: true); - } - void SetNumber() { Number = NumberInput; diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor index 4bdd6697b8be..dfd2323010e8 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/SupplyParameterFromSessionNavigationComponent.razor @@ -8,7 +8,7 @@ - + @code { From 725040ad9b468f1a24448febb5c6cd6597b55343 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Wed, 11 Feb 2026 20:40:53 +0100 Subject: [PATCH 08/11] Fix --- .../Endpoints/test/SessionValueMapperTest.cs | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/Components/Endpoints/test/SessionValueMapperTest.cs b/src/Components/Endpoints/test/SessionValueMapperTest.cs index 68f6e7f36f99..94a6c2d091e9 100644 --- a/src/Components/Endpoints/test/SessionValueMapperTest.cs +++ b/src/Components/Endpoints/test/SessionValueMapperTest.cs @@ -79,37 +79,27 @@ public void RegisterValueCallback_AddsCallbackToList() } [Fact] - public async Task RegisterValueCallback_AllowsMultipleCallbacksForSameKey() + public void RegisterValueCallback_ThrowsForDuplicateKey() { // Arrange var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - var callOrder = new List(); - mapper.RegisterValueCallback("key", () => { callOrder.Add(1); return null; }); - mapper.RegisterValueCallback("key", () => { callOrder.Add(2); return "value2"; }); - mapper.RegisterValueCallback("key", () => { callOrder.Add(3); return "value3"; }); + mapper.RegisterValueCallback("key", () => "value1"); - // Act - await responseFeature.FireOnStartingAsync(); - - // Assert - Assert.Equal(new[] { 1, 2 }, callOrder); - Assert.Equal("\"value2\"", httpContext.Session.GetString("key")); + // Act & Assert + var ex = Assert.Throws(() => + mapper.RegisterValueCallback("key", () => "value2")); + Assert.Contains("key", ex.Message); } [Fact] - public async Task PersistAllValues_PersistsFirstNonNullValue() + public async Task PersistAllValues_PersistsValue() { // Arrange var mapper = GetSessionValueMapper(); var httpContext = CreateHttpContextWithSession(out var responseFeature); mapper.SetRequestContext(httpContext); - mapper.RegisterValueCallback("email", () => null); mapper.RegisterValueCallback("email", () => "test@example.com"); - mapper.RegisterValueCallback("email", () => "other@example.com"); // Act await responseFeature.FireOnStartingAsync(); @@ -119,7 +109,7 @@ public async Task PersistAllValues_PersistsFirstNonNullValue() } [Fact] - public async Task PersistAllValues_RemovesKey_WhenAllCallbacksReturnNull() + public async Task PersistAllValues_RemovesKey_WhenCallbackReturnsNull() { // Arrange var mapper = GetSessionValueMapper(); @@ -127,7 +117,6 @@ public async Task PersistAllValues_RemovesKey_WhenAllCallbacksReturnNull() httpContext.Session.SetString("email", "\"existing@example.com\""); mapper.SetRequestContext(httpContext); - mapper.RegisterValueCallback("email", () => null); mapper.RegisterValueCallback("email", () => null); // Act From ed0b04d3c5864bcbf11aa0c579461d4c91909da0 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 16 Feb 2026 09:17:12 +0100 Subject: [PATCH 09/11] Fix namespaces --- .../src/Session => Components/src}/ISessionValueMapper.cs | 2 +- .../Components/src/Microsoft.AspNetCore.Components.csproj | 1 - src/Components/Components/src/PublicAPI.Unshipped.txt | 6 ++++++ ...SupplyParameterFromSessionServiceCollectionExtensions.cs | 2 +- .../src}/SupplyParameterFromSessionValueProvider.cs | 2 +- src/Components/Endpoints/src/PublicAPI.Unshipped.txt | 6 ------ .../Endpoints/src/Rendering/EndpointComponentState.cs | 2 +- .../src/Rendering/EndpointHtmlRenderer.Prerendering.cs | 4 ++-- .../Endpoints/src/Rendering/EndpointHtmlRenderer.cs | 2 +- src/Components/Endpoints/src/Session/SessionValueMapper.cs | 4 ++-- 10 files changed, 15 insertions(+), 16 deletions(-) rename src/Components/{Endpoints/src/Session => Components/src}/ISessionValueMapper.cs (94%) rename src/Components/{Endpoints/src/Session => Components/src}/SupplyParameterFromSessionServiceCollectionExtensions.cs (95%) rename src/Components/{Endpoints/src/Session => Components/src}/SupplyParameterFromSessionValueProvider.cs (97%) diff --git a/src/Components/Endpoints/src/Session/ISessionValueMapper.cs b/src/Components/Components/src/ISessionValueMapper.cs similarity index 94% rename from src/Components/Endpoints/src/Session/ISessionValueMapper.cs rename to src/Components/Components/src/ISessionValueMapper.cs index 956ecbc40b5e..4d8f2e2c1a57 100644 --- a/src/Components/Endpoints/src/Session/ISessionValueMapper.cs +++ b/src/Components/Components/src/ISessionValueMapper.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.AspNetCore.Components.Endpoints; +namespace Microsoft.AspNetCore.Components; /// /// Maps session data values to a model. diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 58010a3bef6c..ba3ae6420645 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -68,7 +68,6 @@ - diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 489644de389a..ead81dfb5570 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -7,7 +7,13 @@ Microsoft.AspNetCore.Components.IComponentPropertyActivator.GetActivator(System. *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void +Microsoft.AspNetCore.Components.ISessionValueMapper +Microsoft.AspNetCore.Components.ISessionValueMapper.DeleteValueCallback(string! sessionKey) -> void +Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? +Microsoft.AspNetCore.Components.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.get -> string? Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.set -> void Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void +Microsoft.AspNetCore.Components.SupplyParameterFromSessionServiceCollectionExtensions +static Microsoft.AspNetCore.Components.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs similarity index 95% rename from src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs rename to src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs index 10fb4f99fb20..bc90655e4275 100644 --- a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Microsoft.AspNetCore.Components.Endpoints; +namespace Microsoft.AspNetCore.Components; /// /// Enables component parameters to be supplied from the session with . diff --git a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs b/src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs similarity index 97% rename from src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs rename to src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs index 180d25dc58c4..2b033b8d1fd6 100644 --- a/src/Components/Endpoints/src/Session/SupplyParameterFromSessionValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; -namespace Microsoft.AspNetCore.Components.Endpoints; +namespace Microsoft.AspNetCore.Components; internal class SupplyParameterFromSessionValueProvider : ICascadingValueSupplier { diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index d6fa4f7da8b5..2ebef8b69595 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,15 +1,10 @@ #nullable enable Microsoft.AspNetCore.Components.Endpoints.BasePath Microsoft.AspNetCore.Components.Endpoints.BasePath.BasePath() -> void -Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper -Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.DeleteValueCallback(string! sessionKey) -> void -Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? -Microsoft.AspNetCore.Components.Endpoints.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.get -> Microsoft.AspNetCore.Http.CookieBuilder! Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataCookie.set -> void Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataProviderType.get -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TempDataProviderType.set -> void -Microsoft.AspNetCore.Components.Endpoints.SupplyParameterFromSessionServiceCollectionExtensions Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType.Cookie = 0 -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType.SessionStorage = 1 -> Microsoft.AspNetCore.Components.Endpoints.TempDataProviderType @@ -18,4 +13,3 @@ Microsoft.AspNetCore.Components.ITempData.Get(string! key) -> object? Microsoft.AspNetCore.Components.ITempData.Keep() -> void Microsoft.AspNetCore.Components.ITempData.Keep(string! key) -> void Microsoft.AspNetCore.Components.ITempData.Peek(string! key) -> object? -static Microsoft.AspNetCore.Components.Endpoints.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs index d85f67c4400d..6c03d0585f2f 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointComponentState.cs @@ -47,7 +47,7 @@ public EndpointComponentState(Renderer renderer, int componentId, IComponent com public bool StreamRendering { get; } - protected internal override object? GetComponentKey() + protected override object? GetComponentKey() { if (ParentComponentState != null && ParentComponentState.Component is SSRRenderModeBoundary boundary) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 1fc08b49d889..0276cdfd1515 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -18,7 +18,7 @@ internal partial class EndpointHtmlRenderer { private static readonly object ComponentSequenceKey = new object(); - protected internal override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) + protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { if (_isHandlingErrors) { @@ -42,7 +42,7 @@ protected internal override IComponent ResolveComponentForRenderMode([Dynamicall } } - protected internal override IComponentRenderMode? GetComponentRenderMode(IComponent component) + protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) { var componentState = GetComponentState(component); var ssrRenderBoundary = GetClosestRenderModeBoundary(componentState); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 8a6eb4dc5b26..b8fc15aacf16 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -166,7 +166,7 @@ protected override ComponentState CreateComponentState(int componentId, ICompone => new EndpointComponentState(this, componentId, component, parentComponentState); /// - protected internal override ResourceAssetCollection Assets => + protected override ResourceAssetCollection Assets => _resourceCollection ??= GetResourceCollection(_httpContext) ?? base.Assets; private static ResourceAssetCollection? GetResourceCollection(HttpContext httpContext) => httpContext.GetEndpoint()?.Metadata.GetMetadata(); diff --git a/src/Components/Endpoints/src/Session/SessionValueMapper.cs b/src/Components/Endpoints/src/Session/SessionValueMapper.cs index 368ae831328a..8a5c740870f5 100644 --- a/src/Components/Endpoints/src/Session/SessionValueMapper.cs +++ b/src/Components/Endpoints/src/Session/SessionValueMapper.cs @@ -26,7 +26,7 @@ internal void SetRequestContext(HttpContext httpContext) _httpContext.Response.OnStarting(PersistAllValues); } - public object? GetValue(string sessionKey, Type type) + public object? GetValue(string sessionKey, Type targetType) { var session = _httpContext?.Features.Get()?.Session; if (session is null) @@ -40,7 +40,7 @@ internal void SetRequestContext(HttpContext httpContext) { return null; } - return JsonSerializer.Deserialize(json, type, _jsonOptions); + return JsonSerializer.Deserialize(json, targetType, _jsonOptions); } catch (JsonException ex) { From 420c545079034645be3eecefb0bfd5432c0968f4 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 20 Apr 2026 15:05:05 +0200 Subject: [PATCH 10/11] Refactor SupplyParameterFromSession to use CascadingParameterSubscription pattern --- .../Components/src/ISessionValueMapper.cs | 26 -- .../Components/src/PublicAPI.Unshipped.txt | 10 - ...rFromSessionServiceCollectionExtensions.cs | 23 -- ...SupplyParameterFromSessionValueProvider.cs | 59 ---- ...orComponentsServiceCollectionExtensions.cs | 5 +- .../src/Rendering/EndpointHtmlRenderer.cs | 10 +- .../Session/SessionCascadingValueSupplier.cs | 192 ++++++++++++ .../src/Session/SessionValueMapper.cs | 105 ------- .../SessionCascadingValueSupplierTest.cs | 250 ++++++++++++++++ .../test/Session/SessionSubscriptionTest.cs | 190 ++++++++++++ .../Endpoints/test/SessionValueMapperTest.cs | 282 ------------------ .../Web/src/PublicAPI.Unshipped.txt | 4 + .../SupplyParameterFromSessionAttribute.cs | 2 +- 13 files changed, 645 insertions(+), 513 deletions(-) delete mode 100644 src/Components/Components/src/ISessionValueMapper.cs delete mode 100644 src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs delete mode 100644 src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs create mode 100644 src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs delete mode 100644 src/Components/Endpoints/src/Session/SessionValueMapper.cs create mode 100644 src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs create mode 100644 src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs delete mode 100644 src/Components/Endpoints/test/SessionValueMapperTest.cs rename src/Components/{Components => Web}/src/SupplyParameterFromSessionAttribute.cs (93%) diff --git a/src/Components/Components/src/ISessionValueMapper.cs b/src/Components/Components/src/ISessionValueMapper.cs deleted file mode 100644 index 4d8f2e2c1a57..000000000000 --- a/src/Components/Components/src/ISessionValueMapper.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Components; - -/// -/// Maps session data values to a model. -/// -public interface ISessionValueMapper -{ - /// - /// Returns the session value with the specified name, deserialized to the specified type. - /// - object? GetValue(string sessionKey, Type targetType); - - /// - /// Registers a callback to retrieve the current value of a session property. - /// The callback will be invoked when the response starts to persist the value. - /// - void RegisterValueCallback(string sessionKey, Func valueGetter); - - /// - /// Unregisters a previously registered callback for the specified session key. - /// - void DeleteValueCallback(string sessionKey); -} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index aa36b78a9d9e..c243d71cf252 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -11,16 +11,6 @@ Microsoft.AspNetCore.Components.IComponentPropertyActivator.GetActivator(System. *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool *REMOVED*Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void -Microsoft.AspNetCore.Components.ISessionValueMapper -Microsoft.AspNetCore.Components.ISessionValueMapper.DeleteValueCallback(string! sessionKey) -> void -Microsoft.AspNetCore.Components.ISessionValueMapper.GetValue(string! sessionKey, System.Type! targetType) -> object? -Microsoft.AspNetCore.Components.ISessionValueMapper.RegisterValueCallback(string! sessionKey, System.Func! valueGetter) -> void -Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute -Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.get -> string? -Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.Name.set -> void -Microsoft.AspNetCore.Components.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void -Microsoft.AspNetCore.Components.SupplyParameterFromSessionServiceCollectionExtensions -static Microsoft.AspNetCore.Components.SupplyParameterFromSessionServiceCollectionExtensions.AddSupplyValueFromSessionProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.CascadingValueServiceCollectionExtensions.TryAddCascadingValueSupplier(this Microsoft.Extensions.DependencyInjection.IServiceCollection! serviceCollection, System.Func!>! subscribeFactory) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Microsoft.AspNetCore.Components.BrowserConfiguration Microsoft.AspNetCore.Components.BrowserConfiguration.BrowserConfiguration() -> void diff --git a/src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs deleted file mode 100644 index bc90655e4275..000000000000 --- a/src/Components/Components/src/SupplyParameterFromSessionServiceCollectionExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Microsoft.AspNetCore.Components; - -/// -/// Enables component parameters to be supplied from the session with . -/// -public static class SupplyParameterFromSessionServiceCollectionExtensions -{ - /// - /// Enables component parameters to be supplied from the session with . - /// - /// The . - /// The . - public static IServiceCollection AddSupplyValueFromSessionProvider(this IServiceCollection services) - { - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - return services; - } -} diff --git a/src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs b/src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs deleted file mode 100644 index 2b033b8d1fd6..000000000000 --- a/src/Components/Components/src/SupplyParameterFromSessionValueProvider.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components.Reflection; -using Microsoft.AspNetCore.Components.Rendering; - -namespace Microsoft.AspNetCore.Components; - -internal class SupplyParameterFromSessionValueProvider : ICascadingValueSupplier -{ - private readonly ISessionValueMapper _sessionValueMapper; - - public SupplyParameterFromSessionValueProvider(ISessionValueMapper sessionValueMapper) - { - _sessionValueMapper = sessionValueMapper; - } - - public bool IsFixed => false; - - public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) - => parameterInfo.Attribute is SupplyParameterFromSessionAttribute; - - public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) - { - var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; - return _sessionValueMapper.GetValue(sessionKey, parameterInfo.PropertyType); - } - - [UnconditionalSuppressMessage( - "Trimming", - "IL2075:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'", - Justification = "Component properties are preserved through other means.")] - public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - { - var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; - var propertyName = parameterInfo.PropertyName; - var componentType = subscriber.Component.GetType(); - var propertyInfo = componentType.GetProperty(propertyName); - - if (propertyInfo is null) - { - return; - } - - var component = subscriber.Component; - var getter = new PropertyGetter(componentType, propertyInfo); - _sessionValueMapper.RegisterValueCallback(sessionKey, () => getter.GetValue(component)); - } - - public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - { - var attribute = (SupplyParameterFromSessionAttribute)parameterInfo.Attribute; - var sessionKey = (attribute.Name ?? parameterInfo.PropertyName) ?? ""; - _sessionValueMapper.DeleteValueCallback(sessionKey); - } -} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index a1f7c408cc76..298801a82a8a 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -69,9 +69,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(sp => sp.GetRequiredService()); services.TryAddScoped(); - services.TryAddScoped(); services.AddSupplyValueFromQueryProvider(); - services.AddSupplyValueFromSessionProvider(); services.AddSupplyValueFromPersistentComponentStateProvider(); services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); @@ -79,6 +77,9 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddCascadingValueSupplier( sp => sp.GetRequiredService().CreateSubscription); + services.TryAddScoped(); + services.TryAddCascadingValueSupplier( + sp => sp.GetRequiredService().CreateSubscription); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 3f1bc4b9270b..62797ee22b23 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -122,14 +122,14 @@ internal async Task InitializeStandardComponentServicesAsync( antiforgery.SetRequestContext(httpContext); } - if (httpContext.RequestServices.GetService() is SessionValueMapper sessionValueMapper) + if (httpContext.RequestServices.GetService() is { } tempDataSupplier) { - sessionValueMapper.SetRequestContext(httpContext); + tempDataSupplier.SetRequestContext(httpContext); } - - if (httpContext.RequestServices.GetService() is {} tempDataSupplier) + + if (httpContext.RequestServices.GetService() is { } sessionSupplier) { - tempDataSupplier.SetRequestContext(httpContext); + sessionSupplier.SetRequestContext(httpContext); } // It's important that this is initialized since a component might try to restore state during prerendering diff --git a/src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs b/src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs new file mode 100644 index 000000000000..877d65c45773 --- /dev/null +++ b/src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Components.Reflection; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal partial class SessionCascadingValueSupplier +{ + private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private HttpContext? _httpContext; + private readonly Dictionary> _valueCallbacks = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public SessionCascadingValueSupplier(ILogger logger) + { + _logger = logger; + } + + internal void SetRequestContext(HttpContext httpContext) + { + if (ReferenceEquals(_httpContext, httpContext)) + { + return; + } + + if (_httpContext is not null) + { + throw new InvalidOperationException($"{nameof(SessionCascadingValueSupplier)} is already associated with a different {nameof(HttpContext)}."); + } + + _httpContext = httpContext; + _httpContext.Response.OnStarting(PersistAllValues); + } + + internal CascadingParameterSubscription CreateSubscription( + ComponentState componentState, + SupplyParameterFromSessionAttribute attribute, + CascadingParameterInfo parameterInfo) + { + var sessionKey = attribute.Name ?? parameterInfo.PropertyName; + var componentType = componentState.Component.GetType(); + var getter = _propertyGetterCache.GetOrAdd((componentType, parameterInfo.PropertyName), PropertyGetterFactory); + Func valueGetter = () => getter.GetValue(componentState.Component); + RegisterValueCallback(sessionKey, valueGetter); + return new SessionSubscription(this, sessionKey, parameterInfo.PropertyType, valueGetter); + } + + private static PropertyGetter PropertyGetterFactory((Type type, string propertyName) key) + { + var (type, propertyName) = key; + var propertyInfo = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (propertyInfo is null) + { + throw new InvalidOperationException($"A property '{propertyName}' on component type '{type.FullName}' wasn't found."); + } + return new PropertyGetter(type, propertyInfo); + } + + internal ISession? GetSession() => _httpContext?.Features.Get()?.Session; + + internal void RegisterValueCallback(string sessionKey, Func valueGetter) + { + if (!_valueCallbacks.TryAdd(sessionKey, valueGetter)) + { + throw new InvalidOperationException($"A callback is already registered for the session key '{sessionKey}'. Multiple components cannot use the same session key for multiple [SupplyParameterFromSession] attributes."); + } + } + + internal Task PersistAllValues() + { + if (_valueCallbacks.Count == 0) + { + return Task.CompletedTask; + } + + var session = GetSession(); + if (session is null) + { + return Task.CompletedTask; + } + + foreach (var (key, valueGetter) in _valueCallbacks) + { + object? value = null; + try + { + value = valueGetter(); + } + catch (Exception ex) + { + Log.SessionPersistFail(_logger, ex); + continue; + } + + var sessionKey = key.ToLowerInvariant(); + if (value is not null) + { + var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); + session.SetString(sessionKey, json); + } + else + { + session.Remove(sessionKey); + } + } + return Task.CompletedTask; + } + + internal void DeleteValueCallback(string sessionKey) + { + _valueCallbacks.Remove(sessionKey); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Warning, "Persisting of the session element failed.", EventName = "SessionPersistFail")] + public static partial void SessionPersistFail(ILogger logger, Exception exception); + + [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from session failed.", EventName = "SessionDeserializeFail")] + public static partial void SessionDeserializeFail(ILogger logger, Exception exception); + } + + internal partial class SessionSubscription : CascadingParameterSubscription + { + private readonly SessionCascadingValueSupplier _owner; + private readonly string _sessionKey; + private readonly Type _propertyType; + private readonly Func _currentValueGetter; + private bool _delivered; + + public SessionSubscription( + SessionCascadingValueSupplier owner, + string sessionKey, + Type propertyType, + Func currentValueGetter) + { + _owner = owner; + _sessionKey = sessionKey; + _propertyType = propertyType; + _currentValueGetter = currentValueGetter; + } + + public override object? GetCurrentValue() + { + if (_delivered) + { + // After the first delivery, return the component's current property value + // to avoid overriding modifications the component made during rendering. + return _currentValueGetter(); + } + + _delivered = true; + + var session = _owner.GetSession(); + if (session is null) + { + return null; + } + + try + { + var json = session.GetString(_sessionKey.ToLowerInvariant()); + if (string.IsNullOrEmpty(json)) + { + return null; + } + + return JsonSerializer.Deserialize(json, _propertyType, _jsonOptions); + } + catch (Exception ex) + { + Log.SessionDeserializeFail(_owner._logger, ex); + return null; + } + } + + public override void Dispose() + { + _owner.DeleteValueCallback(_sessionKey); + } + } +} diff --git a/src/Components/Endpoints/src/Session/SessionValueMapper.cs b/src/Components/Endpoints/src/Session/SessionValueMapper.cs deleted file mode 100644 index 8a5c740870f5..000000000000 --- a/src/Components/Endpoints/src/Session/SessionValueMapper.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Logging; - -namespace Microsoft.AspNetCore.Components.Endpoints; - -internal partial class SessionValueMapper : ISessionValueMapper -{ - private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); - private HttpContext? _httpContext; - private readonly Dictionary> _valueCallbacks = new(StringComparer.OrdinalIgnoreCase); - private readonly ILogger _logger; - - public SessionValueMapper(ILogger logger) - { - _logger = logger; - } - - internal void SetRequestContext(HttpContext httpContext) - { - _httpContext = httpContext; - _httpContext.Response.OnStarting(PersistAllValues); - } - - public object? GetValue(string sessionKey, Type targetType) - { - var session = _httpContext?.Features.Get()?.Session; - if (session is null) - { - return null; - } - try - { - var json = session.GetString(sessionKey.ToLowerInvariant()); - if (string.IsNullOrEmpty(json)) - { - return null; - } - return JsonSerializer.Deserialize(json, targetType, _jsonOptions); - } - catch (JsonException ex) - { - Log.SessionDeserializeFail(_logger, ex); - return null; - } - } - - public void RegisterValueCallback(string sessionKey, Func valueGetter) - { - if (!_valueCallbacks.TryAdd(sessionKey, valueGetter)) - { - throw new InvalidOperationException($"A callback is already registered for the session key '{sessionKey}'. Multiple components cannot use the same session key."); - } - } - - public void DeleteValueCallback(string sessionKey) - { - _valueCallbacks.Remove(sessionKey); - } - - private Task PersistAllValues() - { - var session = _httpContext?.Features.Get()?.Session; - if (session is null) - { - return Task.CompletedTask; - } - - foreach (var (key, valueGetter) in _valueCallbacks) - { - object? value = null; - try - { - value = valueGetter(); - } - catch (Exception ex) - { - Log.SessionPersistFail(_logger, ex); - } - if (value is not null) - { - var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); - session.SetString(key.ToLowerInvariant(), json); - } - else - { - session.Remove(key.ToLowerInvariant()); - } - } - return Task.CompletedTask; - } - - private static partial class Log - { - [LoggerMessage(1, LogLevel.Warning, "Persisting of the element failed.", EventName = "SessionPersistFail")] - public static partial void SessionPersistFail(ILogger logger, Exception exception); - - [LoggerMessage(2, LogLevel.Warning, "Deserialization of the element from Session failed.", EventName = "SessionDeserializeFail")] - public static partial void SessionDeserializeFail(ILogger logger, JsonException exception); - } -} diff --git a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs new file mode 100644 index 000000000000..cbe73efb0581 --- /dev/null +++ b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs @@ -0,0 +1,250 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class SessionCascadingValueSupplierTest +{ + private readonly SessionCascadingValueSupplier _supplier; + + public SessionCascadingValueSupplierTest() + { + _supplier = new SessionCascadingValueSupplier(NullLogger.Instance); + } + + [Fact] + public async Task RegisterValueCallback_AddsCallback() + { + var callbackInvoked = false; + _supplier.RegisterValueCallback("key", () => + { + callbackInvoked = true; + return "value"; + }); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.True(callbackInvoked); + } + + [Fact] + public void RegisterValueCallback_ThrowsForDuplicateKey() + { + _supplier.RegisterValueCallback("key", () => "value1"); + + var ex = Assert.Throws(() => + _supplier.RegisterValueCallback("key", () => "value2")); + + Assert.Contains("key", ex.Message); + } + + [Fact] + public async Task PersistAllValues_SetsValueInSession() + { + _supplier.RegisterValueCallback("key", () => "persisted value"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Equal("\"persisted value\"", httpContext.Session.GetString("key")); + } + + [Fact] + public async Task PersistAllValues_RemovesKey_WhenCallbackReturnsNull() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("key", "\"existing\""); + + _supplier.RegisterValueCallback("key", () => null); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Null(httpContext.Session.GetString("key")); + } + + [Fact] + public async Task PersistAllValues_HandlesMultipleKeys() + { + _supplier.RegisterValueCallback("key1", () => "value1"); + _supplier.RegisterValueCallback("key2", () => "value2"); + _supplier.RegisterValueCallback("key3", () => "value3"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Equal("\"value1\"", httpContext.Session.GetString("key1")); + Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); + Assert.Equal("\"value3\"", httpContext.Session.GetString("key3")); + } + + [Fact] + public async Task PersistAllValues_ContinuesOnCallbackException() + { + _supplier.RegisterValueCallback("key1", () => throw new InvalidOperationException("Test exception")); + _supplier.RegisterValueCallback("key2", () => "value2"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Null(httpContext.Session.GetString("key1")); + Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); + } + + [Fact] + public async Task PersistAllValues_LowercasesSessionKey() + { + _supplier.RegisterValueCallback("MyKey", () => "value"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Equal("\"value\"", httpContext.Session.GetString("mykey")); + } + + [Fact] + public async Task PersistAllValues_NoOp_WhenSessionUnavailable() + { + _supplier.RegisterValueCallback("key", () => "value"); + + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new TestHttpResponseFeature()); + _supplier.SetRequestContext(httpContext); + + await _supplier.PersistAllValues(); + } + + [Fact] + public async Task DeleteCallbacks_RemovesCallbacksForKey() + { + var callbackInvoked = false; + _supplier.RegisterValueCallback("key", () => + { + callbackInvoked = true; + return "value"; + }); + + _supplier.DeleteValueCallback("key"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.False(callbackInvoked); + Assert.Null(httpContext.Session.GetString("key")); + } + + [Fact] + public async Task SetRequestContext_RegistersOnStartingCallback() + { + _supplier.RegisterValueCallback("key", () => "value"); + + var httpContext = CreateHttpContextWithSession(out var responseFeature); + _supplier.SetRequestContext(httpContext); + + await responseFeature.FireOnStartingAsync(); + + Assert.Equal("\"value\"", httpContext.Session.GetString("key")); + } + + [Fact] + public void SetRequestContext_IsIdempotent_ForSameHttpContext() + { + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + _supplier.SetRequestContext(httpContext); + } + + [Fact] + public void SetRequestContext_Throws_ForDifferentHttpContext() + { + var first = CreateHttpContextWithSession(); + var second = CreateHttpContextWithSession(); + _supplier.SetRequestContext(first); + + Assert.Throws(() => _supplier.SetRequestContext(second)); + } + + internal static DefaultHttpContext CreateHttpContextWithSession() + { + return CreateHttpContextWithSession(out _); + } + + internal static DefaultHttpContext CreateHttpContextWithSession(out TestHttpResponseFeature responseFeature) + { + var httpContext = new DefaultHttpContext(); + var session = new TestSession(); + httpContext.Features.Set(new TestSessionFeature(session)); + + responseFeature = new TestHttpResponseFeature(); + httpContext.Features.Set(responseFeature); + + return httpContext; + } + + internal class TestSession : ISession + { + private readonly Dictionary _store = new(); + + public bool IsAvailable => true; + public string Id => "test-session"; + public IEnumerable Keys => _store.Keys; + + public void Clear() => _store.Clear(); + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Remove(string key) => _store.Remove(key); + public void Set(string key, byte[] value) => _store[key] = value; + public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out byte[]? value) => _store.TryGetValue(key, out value); + } + + internal class TestSessionFeature : ISessionFeature + { + public TestSessionFeature(ISession session) + { + Session = session; + } + + public ISession Session { get; set; } + } + + internal class TestHttpResponseFeature : IHttpResponseFeature + { + private readonly Stack<(Func Callback, object State)> _onStarting = new(); + + public int StatusCode { get; set; } = 200; + public string? ReasonPhrase { get; set; } + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = new MemoryStream(); + public bool HasStarted { get; private set; } + + public void OnCompleted(Func callback, object state) + { + } + + public void OnStarting(Func callback, object state) + { + _onStarting.Push((callback, state)); + } + + public async Task FireOnStartingAsync() + { + foreach (var (callback, state) in _onStarting) + { + await callback(state); + } + HasStarted = true; + } + } +} diff --git a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs new file mode 100644 index 000000000000..e2b5e56a28e5 --- /dev/null +++ b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs @@ -0,0 +1,190 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using static Microsoft.AspNetCore.Components.Endpoints.SessionCascadingValueSupplierTest; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class SessionSubscriptionTest +{ + private readonly SessionCascadingValueSupplier _supplier; + private readonly TestComponent _component; + + public SessionSubscriptionTest() + { + _supplier = new SessionCascadingValueSupplier(NullLogger.Instance); + _component = new TestComponent(); + } + + private SessionCascadingValueSupplier.SessionSubscription CreateSubscription(string key, Type propertyType) + { + return new SessionCascadingValueSupplier.SessionSubscription( + _supplier, + key, + propertyType, + () => _component.Value); + } + + [Fact] + public void GetValue_ReturnsNull_WhenHttpContextNotSet() + { + var subscription = CreateSubscription("key", typeof(string)); + + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + } + + [Fact] + public void GetValue_ReturnsNull_WhenKeyNotFound() + { + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("nonexistent", typeof(string)); + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + } + + [Fact] + public void GetValue_ReturnsValue_WhenKeyExists() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("mykey", "\"myvalue\""); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("mykey", typeof(string)); + var result = subscription.GetCurrentValue(); + + Assert.Equal("myvalue", result); + } + + [Fact] + public void GetValue_LowercasesSessionKey() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("mykey", "\"myvalue\""); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("MyKey", typeof(string)); + var result = subscription.GetCurrentValue(); + + Assert.Equal("myvalue", result); + } + + [Fact] + public void GetValue_DeserializesEnum() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("status", "1"); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("status", typeof(TestEnum)); + var result = subscription.GetCurrentValue(); + + Assert.IsType(result); + Assert.Equal(TestEnum.Active, result); + } + + [Fact] + public void GetValue_DeserializesNullableEnum() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("status", "2"); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("status", typeof(TestEnum?)); + var result = subscription.GetCurrentValue(); + + Assert.IsType(result); + Assert.Equal(TestEnum.Inactive, result); + } + + [Fact] + public void GetValue_ReturnsNull_WhenKeyNotFound_ForEnumType() + { + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("missing", typeof(TestEnum)); + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + } + + [Fact] + public void GetValue_ReturnsNull_WhenDeserializationFails() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("key", "not-valid-json-for-int"); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("key", typeof(int)); + var result = subscription.GetCurrentValue(); + + Assert.Null(result); + } + + [Fact] + public void GetCurrentValue_ReturnsComponentValue_OnSubsequentCalls() + { + var httpContext = CreateHttpContextWithSession(); + httpContext.Session.SetString("key", "\"original\""); + _supplier.SetRequestContext(httpContext); + + var subscription = CreateSubscription("key", typeof(string)); + var firstResult = subscription.GetCurrentValue(); + + _component.Value = "modified"; + var secondResult = subscription.GetCurrentValue(); + + Assert.Equal("original", firstResult); + Assert.Equal("modified", secondResult); + } + + [Fact] + public async Task CreateSubscription_RegistersValueCallbackAndReturnsSubscription() + { + var httpContext = CreateHttpContextWithSession(out var responseFeature); + httpContext.Session.SetString(nameof(TestComponent.Value).ToLowerInvariant(), "\"from-session\""); + _supplier.SetRequestContext(httpContext); + + var renderer = new TestRenderer(); + var componentState = new ComponentState(renderer, 0, _component, null); + var attribute = new SupplyParameterFromSessionAttribute(); + var parameterInfo = new CascadingParameterInfo(attribute, nameof(TestComponent.Value), typeof(string)); + + var subscription = _supplier.CreateSubscription(componentState, attribute, parameterInfo); + + Assert.NotNull(subscription); + Assert.Equal("from-session", subscription.GetCurrentValue()); + + _component.Value = "updated"; + await responseFeature.FireOnStartingAsync(); + Assert.Equal("\"updated\"", httpContext.Session.GetString(nameof(TestComponent.Value).ToLowerInvariant())); + } + + private class TestComponent : IComponent + { + public object? Value { get; set; } + + public void Attach(RenderHandle renderHandle) { } + + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + public enum TestEnum + { + None = 0, + Active = 1, + Inactive = 2, + } +} diff --git a/src/Components/Endpoints/test/SessionValueMapperTest.cs b/src/Components/Endpoints/test/SessionValueMapperTest.cs deleted file mode 100644 index 94a6c2d091e9..000000000000 --- a/src/Components/Endpoints/test/SessionValueMapperTest.cs +++ /dev/null @@ -1,282 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable enable - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -namespace Microsoft.AspNetCore.Components.Endpoints; - -public class SessionValueMapperTest -{ - private SessionValueMapper GetSessionValueMapper() - { - return new SessionValueMapper(new Microsoft.Extensions.Logging.Abstractions.NullLogger()); - } - - [Fact] - public void GetValue_ReturnsNull_WhenKeyNotFound() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(); - mapper.SetRequestContext(httpContext); - - // Act - var result = mapper.GetValue("nonexistent", typeof(string)); - - // Assert - Assert.Null(result); - } - - [Fact] - public void GetValue_ReturnsDeserializedValue_WhenKeyExists() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(); - httpContext.Session.SetString("email", "\"test@example.com\""); - mapper.SetRequestContext(httpContext); - - // Act - var result = mapper.GetValue("email", typeof(string)); - - // Assert - Assert.Equal("test@example.com", result); - } - - [Fact] - public void GetValue_DeserializesComplexType() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(); - httpContext.Session.SetString("user", "{\"name\":\"John\",\"age\":30}"); - mapper.SetRequestContext(httpContext); - - // Act - var result = mapper.GetValue("user", typeof(TestUser)); - - // Assert - var user = Assert.IsType(result); - Assert.Equal("John", user.Name); - Assert.Equal(30, user.Age); - } - - [Fact] - public void RegisterValueCallback_AddsCallbackToList() - { - // Arrange - var mapper = GetSessionValueMapper(); - var callCount = 0; - - // Act - mapper.RegisterValueCallback("key", () => { callCount++; return "value"; }); - - // Assert - Assert.Equal(0, callCount); - } - - [Fact] - public void RegisterValueCallback_ThrowsForDuplicateKey() - { - // Arrange - var mapper = GetSessionValueMapper(); - mapper.RegisterValueCallback("key", () => "value1"); - - // Act & Assert - var ex = Assert.Throws(() => - mapper.RegisterValueCallback("key", () => "value2")); - Assert.Contains("key", ex.Message); - } - - [Fact] - public async Task PersistAllValues_PersistsValue() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("email", () => "test@example.com"); - - // Act - await responseFeature.FireOnStartingAsync(); - - // Assert - Assert.Equal("\"test@example.com\"", httpContext.Session.GetString("email")); - } - - [Fact] - public async Task PersistAllValues_RemovesKey_WhenCallbackReturnsNull() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - httpContext.Session.SetString("email", "\"existing@example.com\""); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("email", () => null); - - // Act - await responseFeature.FireOnStartingAsync(); - - // Assert - Assert.Null(httpContext.Session.GetString("email")); - } - - [Fact] - public async Task PersistAllValues_HandlesMultipleKeys() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("key1", () => "value1"); - mapper.RegisterValueCallback("key2", () => "value2"); - - // Act - await responseFeature.FireOnStartingAsync(); - - // Assert - Assert.Equal("\"value1\"", httpContext.Session.GetString("key1")); - Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); - } - - [Fact] - public async Task PersistAllValues_SerializesComplexTypes() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("user", () => new TestUser { Name = "Jane", Age = 25 }); - - // Act - await responseFeature.FireOnStartingAsync(); - - // Assert - var json = httpContext.Session.GetString("user"); - Assert.NotNull(json); - Assert.Contains("\"name\":\"Jane\"", json); - Assert.Contains("\"age\":25", json); - } - - [Fact] - public async Task HandlesIncorrectValuesInSession() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("number", () => "not-a-number"); - - // Act - await responseFeature.FireOnStartingAsync(); - var result = mapper.GetValue("number", typeof(int)); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task HandlesCallbackThrowing() - { - // Arrange - var mapper = GetSessionValueMapper(); - var httpContext = CreateHttpContextWithSession(out var responseFeature); - mapper.SetRequestContext(httpContext); - - mapper.RegisterValueCallback("number", () => throw new Exception("Callback exception")); - - // Act - await responseFeature.FireOnStartingAsync(); - var result = mapper.GetValue("number", typeof(int)); - - // Assert - Assert.Null(result); - } - - private static DefaultHttpContext CreateHttpContextWithSession() - { - return CreateHttpContextWithSession(out _); - } - - private static DefaultHttpContext CreateHttpContextWithSession(out TestHttpResponseFeature responseFeature) - { - var httpContext = new DefaultHttpContext(); - var session = new TestSession(); - httpContext.Features.Set(new TestSessionFeature(session)); - - responseFeature = new TestHttpResponseFeature(); - httpContext.Features.Set(responseFeature); - - return httpContext; - } - - private class TestUser - { - public string? Name { get; set; } - public int Age { get; set; } - } - - private class TestSession : ISession - { - private readonly Dictionary _store = new(); - - public bool IsAvailable => true; - public string Id => "test-session"; - public IEnumerable Keys => _store.Keys; - - public void Clear() => _store.Clear(); - public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public void Remove(string key) => _store.Remove(key); - public void Set(string key, byte[] value) => _store[key] = value; - public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out byte[]? value) => _store.TryGetValue(key, out value); - } - - private class TestSessionFeature : ISessionFeature - { - public TestSessionFeature(ISession session) - { - Session = session; - } - - public ISession Session { get; set; } - } - - private class TestHttpResponseFeature : IHttpResponseFeature - { - private readonly Stack<(Func Callback, object State)> _onStarting = new(); - - public int StatusCode { get; set; } = 200; - public string? ReasonPhrase { get; set; } - public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); - public Stream Body { get; set; } = new MemoryStream(); - public bool HasStarted { get; private set; } - - public void OnCompleted(Func callback, object state) - { - } - - public void OnStarting(Func callback, object state) - { - _onStarting.Push((callback, state)); - } - - public async Task FireOnStartingAsync() - { - foreach (var (callback, state) in _onStarting) - { - await callback(state); - } - HasStarted = true; - } - } -} - diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 3e6daf94f47d..2478b8813484 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -80,6 +80,10 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(byte[]! data, Microsoft.AspNetCore.Components.Web.Media.MediaSource.MediaSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string! Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream! +Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute +Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.Name.get -> string? +Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.Name.set -> void +Microsoft.AspNetCore.Components.Web.SupplyParameterFromSessionAttribute.SupplyParameterFromSessionAttribute() -> void Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute.Name.get -> string? Microsoft.AspNetCore.Components.Web.SupplyParameterFromTempDataAttribute.Name.set -> void diff --git a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs b/src/Components/Web/src/SupplyParameterFromSessionAttribute.cs similarity index 93% rename from src/Components/Components/src/SupplyParameterFromSessionAttribute.cs rename to src/Components/Web/src/SupplyParameterFromSessionAttribute.cs index 50faa0b4dd4d..945615560414 100644 --- a/src/Components/Components/src/SupplyParameterFromSessionAttribute.cs +++ b/src/Components/Web/src/SupplyParameterFromSessionAttribute.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components.Web; /// /// Indicates that the value of the associated property should be supplied from From e5692c65e442c8cc6fb9d1b06edd58ec8318ad29 Mon Sep 17 00:00:00 2001 From: Daria Tiurina Date: Mon, 20 Apr 2026 16:31:28 +0200 Subject: [PATCH 11/11] Clean-up --- .../SessionCascadingValueSupplier.cs | 39 ++++++------------- .../SessionCascadingValueSupplierTest.cs | 32 +++++++-------- .../test/Session/SessionSubscriptionTest.cs | 26 ------------- ...SupplyParameterFromSessionAttributeTest.cs | 2 +- 4 files changed, 26 insertions(+), 73 deletions(-) rename src/Components/Endpoints/src/{Session => }/SessionCascadingValueSupplier.cs (86%) diff --git a/src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs similarity index 86% rename from src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs rename to src/Components/Endpoints/src/SessionCascadingValueSupplier.cs index 877d65c45773..b9e364c148d8 100644 --- a/src/Components/Endpoints/src/Session/SessionCascadingValueSupplier.cs +++ b/src/Components/Endpoints/src/SessionCascadingValueSupplier.cs @@ -28,16 +28,6 @@ public SessionCascadingValueSupplier(ILogger logg internal void SetRequestContext(HttpContext httpContext) { - if (ReferenceEquals(_httpContext, httpContext)) - { - return; - } - - if (_httpContext is not null) - { - throw new InvalidOperationException($"{nameof(SessionCascadingValueSupplier)} is already associated with a different {nameof(HttpContext)}."); - } - _httpContext = httpContext; _httpContext.Response.OnStarting(PersistAllValues); } @@ -91,26 +81,23 @@ internal Task PersistAllValues() foreach (var (key, valueGetter) in _valueCallbacks) { - object? value = null; + var sessionKey = key.ToLowerInvariant(); try { - value = valueGetter(); + var value = valueGetter(); + if (value is not null) + { + var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); + session.SetString(sessionKey, json); + } + else + { + session.Remove(sessionKey); + } } catch (Exception ex) { Log.SessionPersistFail(_logger, ex); - continue; - } - - var sessionKey = key.ToLowerInvariant(); - if (value is not null) - { - var json = JsonSerializer.Serialize(value, value.GetType(), _jsonOptions); - session.SetString(sessionKey, json); - } - else - { - session.Remove(sessionKey); } } return Task.CompletedTask; @@ -154,13 +141,10 @@ public SessionSubscription( { if (_delivered) { - // After the first delivery, return the component's current property value - // to avoid overriding modifications the component made during rendering. return _currentValueGetter(); } _delivered = true; - var session = _owner.GetSession(); if (session is null) { @@ -174,7 +158,6 @@ public SessionSubscription( { return null; } - return JsonSerializer.Deserialize(json, _propertyType, _jsonOptions); } catch (Exception ex) diff --git a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs index cbe73efb0581..15b7a597dedb 100644 --- a/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs +++ b/src/Components/Endpoints/test/Session/SessionCascadingValueSupplierTest.cs @@ -101,6 +101,20 @@ public async Task PersistAllValues_ContinuesOnCallbackException() Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); } + [Fact] + public async Task PersistAllValues_ContinuesOnSerializationException() + { + _supplier.RegisterValueCallback("key1", () => new IntPtr(42)); + _supplier.RegisterValueCallback("key2", () => "value2"); + + var httpContext = CreateHttpContextWithSession(); + _supplier.SetRequestContext(httpContext); + await _supplier.PersistAllValues(); + + Assert.Null(httpContext.Session.GetString("key1")); + Assert.Equal("\"value2\"", httpContext.Session.GetString("key2")); + } + [Fact] public async Task PersistAllValues_LowercasesSessionKey() { @@ -158,24 +172,6 @@ public async Task SetRequestContext_RegistersOnStartingCallback() Assert.Equal("\"value\"", httpContext.Session.GetString("key")); } - [Fact] - public void SetRequestContext_IsIdempotent_ForSameHttpContext() - { - var httpContext = CreateHttpContextWithSession(); - _supplier.SetRequestContext(httpContext); - _supplier.SetRequestContext(httpContext); - } - - [Fact] - public void SetRequestContext_Throws_ForDifferentHttpContext() - { - var first = CreateHttpContextWithSession(); - var second = CreateHttpContextWithSession(); - _supplier.SetRequestContext(first); - - Assert.Throws(() => _supplier.SetRequestContext(second)); - } - internal static DefaultHttpContext CreateHttpContextWithSession() { return CreateHttpContextWithSession(out _); diff --git a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs index e2b5e56a28e5..443b0bb3cf8d 100644 --- a/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs +++ b/src/Components/Endpoints/test/Session/SessionSubscriptionTest.cs @@ -82,20 +82,6 @@ public void GetValue_LowercasesSessionKey() [Fact] public void GetValue_DeserializesEnum() - { - var httpContext = CreateHttpContextWithSession(); - httpContext.Session.SetString("status", "1"); - _supplier.SetRequestContext(httpContext); - - var subscription = CreateSubscription("status", typeof(TestEnum)); - var result = subscription.GetCurrentValue(); - - Assert.IsType(result); - Assert.Equal(TestEnum.Active, result); - } - - [Fact] - public void GetValue_DeserializesNullableEnum() { var httpContext = CreateHttpContextWithSession(); httpContext.Session.SetString("status", "2"); @@ -108,18 +94,6 @@ public void GetValue_DeserializesNullableEnum() Assert.Equal(TestEnum.Inactive, result); } - [Fact] - public void GetValue_ReturnsNull_WhenKeyNotFound_ForEnumType() - { - var httpContext = CreateHttpContextWithSession(); - _supplier.SetRequestContext(httpContext); - - var subscription = CreateSubscription("missing", typeof(TestEnum)); - var result = subscription.GetCurrentValue(); - - Assert.Null(result); - } - [Fact] public void GetValue_ReturnsNull_WhenDeserializationFails() { diff --git a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs index f8537fd2ba23..b86f1de1576f 100644 --- a/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs +++ b/src/Components/test/E2ETest/Tests/SupplyParameterFromSessionAttributeTest.cs @@ -12,7 +12,7 @@ using TestServer; using Xunit.Abstractions; -namespace Microsoft.AspNetCore.Components.E2ETests.Tests; +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; public class SupplyParameterFromSessionAttributeTest : ServerTestBase>> {