diff --git a/src/Components/Components/src/ITempData.cs b/src/Components/Components/src/ITempData.cs new file mode 100644 index 000000000000..12ec4d4fc950 --- /dev/null +++ b/src/Components/Components/src/ITempData.cs @@ -0,0 +1,37 @@ +// 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; + +/// +/// Provides a dictionary for storing data that is needed for subsequent requests. +/// Data stored in TempData is automatically removed after it is read unless +/// or is called, or it is accessed via . +/// +public interface ITempData +{ + /// + /// Gets or sets the value associated with the specified key. + /// + object? this[string key] { get; set; } + + /// + /// Gets the value associated with the specified key and then schedules it for deletion. + /// + object? Get(string key); + + /// + /// Gets the value associated with the specified key without scheduling it for deletion. + /// + object? Peek(string key); + + /// + /// Makes all of the keys currently in TempData persist for another request. + /// + void Keep(); + + /// + /// Makes the element with the persist for another request. + /// + void Keep(string key); +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 9e7c166fbaaa..ff40a15ce028 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +1,21 @@ #nullable enable +Microsoft.AspNetCore.Components.ITempData +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.this[string! key].get -> object? +Microsoft.AspNetCore.Components.ITempData.this[string! key].set -> void +Microsoft.AspNetCore.Components.ITempData.Peek(string! key) -> object? +Microsoft.AspNetCore.Components.TempData +Microsoft.AspNetCore.Components.TempData.Get(string! key) -> object? +Microsoft.AspNetCore.Components.TempData.GetDataToSave() -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.Components.TempData.Keep() -> void +Microsoft.AspNetCore.Components.TempData.Keep(string! key) -> void +Microsoft.AspNetCore.Components.TempData.LoadDataFromCookie(System.Collections.Generic.IDictionary! data) -> void +Microsoft.AspNetCore.Components.TempData.Peek(string! key) -> object? +Microsoft.AspNetCore.Components.TempData.TempData() -> void +Microsoft.AspNetCore.Components.TempData.this[string! key].get -> object? +Microsoft.AspNetCore.Components.TempData.this[string! key].set -> void *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 diff --git a/src/Components/Components/src/TempData.cs b/src/Components/Components/src/TempData.cs new file mode 100644 index 000000000000..93ebe73b7e24 --- /dev/null +++ b/src/Components/Components/src/TempData.cs @@ -0,0 +1,76 @@ +// 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; + +/// +public class TempData : ITempData +{ + private readonly Dictionary _data = new(); + private readonly HashSet _retainedKeys = new(); + + /// + public object? this[string key] + { + get => Get(key); + set + { + _data[key] = value; + _retainedKeys.Add(key); + } + } + + /// + public object? Get(string key) + { + _retainedKeys.Remove(key); + return _data.GetValueOrDefault(key); + } + + /// + public object? Peek(string key) + { + return _data.GetValueOrDefault(key); + } + + /// + public void Keep() + { + foreach (var key in _data.Keys) + { + _retainedKeys.Add(key); + } + } + + /// + public void Keep(string key) + { + if (_data.ContainsKey(key)) + { + _retainedKeys.Add(key); + } + } + + /// + public IDictionary GetDataToSave() + { + var dataToSave = new Dictionary(); + foreach (var key in _retainedKeys) + { + dataToSave[key] = _data[key]; + } + return dataToSave; + } + + /// + public void LoadDataFromCookie(IDictionary data) + { + _data.Clear(); + _retainedKeys.Clear(); + foreach (var kvp in data) + { + _data[kvp.Key] = kvp.Value; + _retainedKeys.Add(kvp.Key); + } + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index dc365194fcbe..4e3a0bd2a49f 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -74,6 +74,26 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddCascadingValue(sp => + { + var httpContext = sp.GetRequiredService().HttpContext; + if (httpContext is null) + { + return null!; + } + var key = typeof(ITempData); + if (!httpContext.Items.TryGetValue(key, out var tempData)) + { + var tempDataInstance = TempDataService.Load(httpContext); + httpContext.Items[key] = tempDataInstance; + httpContext.Response.OnStarting(() => + { + TempDataService.Save(httpContext, tempDataInstance); + return Task.CompletedTask; + }); + } + return (ITempData)httpContext.Items[key]!; + }); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs new file mode 100644 index 000000000000..1a3ffcd4ca89 --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/TempDataService.cs @@ -0,0 +1,154 @@ +// 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 sealed class TempDataService +{ + private const string CookieName = ".AspNetCore.Components.TempData"; + + public TempDataService() + { + // TO-DO: Add encoding later if needed + } + + public static TempData Load(HttpContext httpContext) + { + var returnTempData = new TempData(); + var serializedDataFromCookie = httpContext.Request.Cookies[CookieName]; + if (serializedDataFromCookie is null) + { + return returnTempData; + } + + var dataFromCookie = JsonSerializer.Deserialize>(serializedDataFromCookie); + if (dataFromCookie is null) + { + return returnTempData; + } + + var convertedData = new Dictionary(); + foreach (var kvp in dataFromCookie) + { + convertedData[kvp.Key] = ConvertJsonElement(kvp.Value); + } + + returnTempData.LoadDataFromCookie(convertedData); + return returnTempData; + } + + private static object? ConvertJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + if (element.TryGetGuid(out var guid)) + { + return guid; + } + if (element.TryGetDateTime(out var dateTime)) + { + return dateTime; + } + return element.GetString(); + case JsonValueKind.Number: + return element.GetInt32(); + case JsonValueKind.True: + case JsonValueKind.False: + return element.GetBoolean(); + case JsonValueKind.Null: + return null; + case JsonValueKind.Array: + return DeserializeArray(element); + case JsonValueKind.Object: + return DeserializeDictionaryEntry(element); + default: + throw new InvalidOperationException($"TempData cannot deserialize value of type '{element.ValueKind}'."); + } + } + + private static object? DeserializeArray(JsonElement arrayElement) + { + var arrayLength = arrayElement.GetArrayLength(); + if (arrayLength == 0) + { + return null; + } + if (arrayElement[0].ValueKind == JsonValueKind.String) + { + var array = new List(arrayLength); + foreach (var item in arrayElement.EnumerateArray()) + { + array.Add(item.GetString()); + } + return array.ToArray(); + } + else if (arrayElement[0].ValueKind == JsonValueKind.Number) + { + var array = new List(arrayLength); + foreach (var item in arrayElement.EnumerateArray()) + { + array.Add(item.GetInt32()); + } + return array.ToArray(); + } + throw new InvalidOperationException($"TempData cannot deserialize array of type '{arrayElement[0].ValueKind}'."); + } + + private static Dictionary DeserializeDictionaryEntry(JsonElement objectElement) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + foreach (var item in objectElement.EnumerateObject()) + { + dictionary[item.Name] = item.Value.GetString(); + } + return dictionary; + } + + public static void Save(HttpContext httpContext, TempData tempData) + { + var dataToSave = tempData.GetDataToSave(); + foreach (var kvp in dataToSave) + { + if (!CanSerializeType(kvp.Value?.GetType() ?? typeof(object))) + { + throw new InvalidOperationException($"TempData cannot store values of type '{kvp.Value?.GetType()}'."); + } + } + + if (dataToSave.Count == 0) + { + httpContext.Response.Cookies.Delete(CookieName, new CookieOptions + { + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + return; + } + httpContext.Response.Cookies.Append(CookieName, JsonSerializer.Serialize(dataToSave), new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = httpContext.Request.IsHttps, + Path = httpContext.Request.PathBase.HasValue ? httpContext.Request.PathBase.Value : "/", + }); + } + + private static bool CanSerializeType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return + type.IsEnum || + type == typeof(int) || + type == typeof(string) || + type == typeof(bool) || + type == typeof(DateTime) || + type == typeof(Guid) || + typeof(ICollection).IsAssignableFrom(type) || + typeof(ICollection).IsAssignableFrom(type) || + typeof(IDictionary).IsAssignableFrom(type); + } +} diff --git a/src/Components/test/E2ETest/Tests/TempDataTest.cs b/src/Components/test/E2ETest/Tests/TempDataTest.cs new file mode 100644 index 000000000000..4b8d12377dcf --- /dev/null +++ b/src/Components/test/E2ETest/Tests/TempDataTest.cs @@ -0,0 +1,86 @@ +// 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.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.E2ETesting; +using Xunit.Abstractions; +using OpenQA.Selenium; +using TestServer; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class TempDataTest : ServerTestBase>> +{ + public TempDataTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + public override Task InitializeAsync() => InitializeAsync(BrowserFixture.StreamingContext); + + [Fact] + public void TempDataCanPersistThroughNavigation() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataCanPersistThroughDifferentPages() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button-diff-page")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataPeekDoesntDelete() + { + Navigate($"{ServerPathBase}/tempdata"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("Peeked value", () => Browser.FindElement(By.Id("peeked-value")).Text); + } + + [Fact] + public void TempDataKeepAllElements() + { + Navigate($"{ServerPathBase}/tempdata?ValueToKeep=all"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + } + + [Fact] + public void TempDataKeepOneElement() + { + Navigate($"{ServerPathBase}/tempdata?ValueToKeep=KeptValue"); + + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("set-values-button")).Click(); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + Browser.Equal("Message", () => Browser.FindElement(By.Id("message")).Text); + Browser.FindElement(By.Id("redirect-button")).Click(); + Browser.Equal("No message", () => Browser.FindElement(By.Id("message")).Text); + Browser.Equal("Kept value", () => Browser.FindElement(By.Id("kept-value")).Text); + } +} 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/RazorComponents/Pages/TempData/TempDataComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataComponent.razor new file mode 100644 index 000000000000..8a89af87b654 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataComponent.razor @@ -0,0 +1,81 @@ +@page "/tempdata" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

TempData Basic Test

+ +
+ + + + +
+ + + + +
+ + + + +
+ +

@_message

+

@_peekedValue

+

@_keptValue

+ + +@code { + [SupplyParameterFromForm(Name = "_handler")] + public string? Handler { get; set; } + + [CascadingParameter] + public ITempData? TempData { get; set; } + + [SupplyParameterFromQuery] + public string ValueToKeep { get; set; } = string.Empty; + + private string? _message; + private string? _peekedValue; + private string? _keptValue; + + protected override void OnInitialized() + { + if (Handler is not null) + { + return; + } + _message = TempData!.Get("Message") as string ?? "No message"; + _peekedValue = TempData!.Peek("PeekedValue") as string ?? "No peeked value"; + _keptValue = TempData!.Get("KeptValue") as string ?? "No kept value"; + + Console.WriteLine("ValueToKeep = " + ValueToKeep); + + if (ValueToKeep == "all") + { + TempData!.Keep(); + } + else if (!string.IsNullOrEmpty(ValueToKeep)) + { + TempData!.Keep(ValueToKeep); + } + } + + private void SetValues(bool differentPage = false) + { + TempData!["Message"] = "Message"; + TempData!["PeekedValue"] = "Peeked value"; + TempData!["KeptValue"] = "Kept value"; + if (differentPage) + { + NavigateToDifferentPage(); + return; + } + NavigateToSamePageKeep(ValueToKeep); + } + + private void NavigateToSamePage() => NavigationManager.NavigateTo("/subdir/tempdata", forceLoad: true); + private void NavigateToSamePageKeep(string valueToKeep) => NavigationManager.NavigateTo($"/subdir/tempdata?ValueToKeep={valueToKeep}", forceLoad: true); + private void NavigateToDifferentPage() => NavigationManager.NavigateTo("/subdir/tempdata/read", forceLoad: true); +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor new file mode 100644 index 000000000000..950e58e1c24b --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/TempData/TempDataReadComponent.razor @@ -0,0 +1,33 @@ +@page "/tempdata/read" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

TempData Read Test

+ +

@_message

+

@_peekedValue

+

@_keptValue

+ + +@code { + [CascadingParameter] + public ITempData? TempData { get; set; } + + private string? _message; + private string? _peekedValue; + private string? _keptValue; + + protected override void OnInitialized() + { + if (TempData is null) + { + return; + } + _message = TempData.Get("Message") as string; + _message ??= "No message"; + _peekedValue = TempData.Get("PeekedValue") as string; + _peekedValue ??= "No peeked value"; + _keptValue = TempData.Get("KeptValue") as string; + _keptValue ??= "No kept value"; + } +}