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";
+ }
+}