.Instance,
+ serviceProvider);
+
+ var testStore = new TestStore([]);
+ var scenario = new TestScenario(true);
+
+ // Act
+ await persistenceManager.RestoreStateAsync(testStore, scenario);
+
+ // Assert
+ Assert.NotNull(persistenceManager.ServicesRegistry);
+ }
+
private IServiceProvider CreateServiceProvider() =>
new ServiceCollection().BuildServiceProvider();
@@ -422,6 +548,16 @@ private class TestRenderMode : IComponentRenderMode
{
}
+ private class TestScenario : IPersistentComponentStateScenario
+ {
+ public bool IsRecurring { get; }
+
+ public TestScenario(bool isRecurring)
+ {
+ IsRecurring = isRecurring;
+ }
+ }
+
private class PersistentService : IPersistentServiceRegistration
{
public string Assembly { get; set; }
From 60fcdf5a6cb8c5224f398cb2badfa203961ae26d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Jul 2025 13:19:30 +0000
Subject: [PATCH 11/15] Implement E2E tests for scenario-based persistent
component state filtering
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
---
.../ServerExecutionTests/ServerResumeTests.cs | 47 ++++++++
.../ServerRenderingTests/InteractivityTest.cs | 2 +
.../E2ETest/Tests/StatePersistenceTest.cs | 113 ++++++++++++++++++
.../DeclarativePersistStateComponent.razor | 15 ++-
...treamingComponentWithPersistentState.razor | 24 +++-
.../PersistentCounter.razor | 18 +++
6 files changed, 217 insertions(+), 2 deletions(-)
diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs
index f79d5064c8b4..6adf3364980e 100644
--- a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs
+++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs
@@ -188,6 +188,53 @@ private void TriggerClientPauseAndInteract(IJavaScriptExecutor javascript)
Browser.Exists(By.Id("increment-persistent-counter-count")).Click();
}
+
+ [Fact]
+ public void NonPersistedStateIsNotRestoredAfterDisconnection()
+ {
+ // Verify initial state during/after SSR - NonPersistedCounter should be 5
+ Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Wait for interactivity - the value should still be 5
+ Browser.Exists(By.Id("render-mode-interactive"));
+ Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Increment the non-persisted counter to 6 to show it works during interactive session
+ Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
+ Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Also increment the persistent counter to show the contrast
+ Browser.Exists(By.Id("increment-persistent-counter-count")).Click();
+ Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // Force disconnection and reconnection
+ var javascript = (IJavaScriptExecutor)Browser;
+ javascript.ExecuteScript("window.replaceReconnectCallback()");
+ TriggerReconnectAndInteract(javascript);
+
+ // After reconnection:
+ // - Persistent counter should be 2 (was 1, incremented by TriggerReconnectAndInteract)
+ Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // - Non-persisted counter should be 0 (default value) because RestoreStateOnPrerendering
+ // prevented it from being restored after disconnection
+ Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Verify the non-persisted counter can still be incremented in the new session
+ Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
+ Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+
+ // Test repeatability - trigger another disconnection cycle
+ javascript.ExecuteScript("resetReconnect()");
+ TriggerReconnectAndInteract(javascript);
+
+ // After second reconnection:
+ // - Persistent counter should be 3
+ Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
+
+ // - Non-persisted counter should be 0 again (reset to default)
+ Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
+ }
}
public class CustomUIServerResumeTests : ServerResumeTests
diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs
index 130b518aa210..b06013b46a6d 100644
--- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs
+++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs
@@ -1060,6 +1060,8 @@ public void CanPersistPrerenderedStateDeclaratively_Server()
Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text);
+ Browser.Equal("restored-prerendering-enabled", () => Browser.FindElement(By.Id("prerendering-enabled-server")).Text);
+ Browser.Equal("restored-prerendering-disabled", () => Browser.FindElement(By.Id("prerendering-disabled-server")).Text);
}
[Fact]
diff --git a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
index d49e4fbc5704..0c8601119ea2 100644
--- a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
+++ b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
@@ -236,4 +236,117 @@ private void AssertPageState(
Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text);
}
}
+
+ [Theory]
+ [InlineData(typeof(InteractiveServerRenderMode), (string)null)]
+ [InlineData(typeof(InteractiveServerRenderMode), "ServerStreaming")]
+ [InlineData(typeof(InteractiveWebAssemblyRenderMode), (string)null)]
+ [InlineData(typeof(InteractiveWebAssemblyRenderMode), "WebAssemblyStreaming")]
+ [InlineData(typeof(InteractiveAutoRenderMode), (string)null)]
+ [InlineData(typeof(InteractiveAutoRenderMode), "AutoStreaming")]
+ public void ComponentWithUpdateStateOnEnhancedNavigationReceivesStateUpdates(Type renderMode, string streaming)
+ {
+ var mode = renderMode switch
+ {
+ var t when t == typeof(InteractiveServerRenderMode) => "server",
+ var t when t == typeof(InteractiveWebAssemblyRenderMode) => "wasm",
+ var t when t == typeof(InteractiveAutoRenderMode) => "auto",
+ _ => throw new ArgumentException($"Unknown render mode: {renderMode.Name}")
+ };
+
+ // Step 1: Navigate to page without components first to establish initial state
+ if (streaming == null)
+ {
+ Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart");
+ }
+ else
+ {
+ Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&streaming-id={streaming}&suppress-autostart");
+ }
+
+ if (mode == "auto")
+ {
+ BlockWebAssemblyResourceLoad();
+ }
+
+ Browser.Click(By.Id("call-blazor-start"));
+ Browser.Click(By.Id("page-with-components-link"));
+
+ // Step 2: Validate initial state - no enhanced nav state should be found
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: streaming == null,
+ enhancedNavStateFound: false,
+ enhancedNavStateValue: "no-enhanced-nav-state",
+ streamingId: streaming,
+ streamingCompleted: false);
+
+ if (streaming != null)
+ {
+ Browser.Click(By.Id("end-streaming"));
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: true,
+ enhancedNavStateFound: false,
+ enhancedNavStateValue: "no-enhanced-nav-state",
+ streamingId: streaming,
+ streamingCompleted: true);
+ }
+
+ // Step 3: Navigate back to page without components (this persists state)
+ Browser.Click(By.Id("page-no-components-link"));
+
+ // Step 4: Navigate back to page with components via enhanced navigation
+ // This should trigger [UpdateStateOnEnhancedNavigation] and update the state
+ Browser.Click(By.Id("page-with-components-link"));
+
+ // Step 5: Validate that enhanced navigation state was updated
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: streaming == null,
+ enhancedNavStateFound: true,
+ enhancedNavStateValue: "enhanced-nav-updated",
+ streamingId: streaming,
+ streamingCompleted: streaming == null);
+
+ if (streaming != null)
+ {
+ Browser.Click(By.Id("end-streaming"));
+ ValidateEnhancedNavState(
+ mode: mode,
+ renderMode: renderMode.Name,
+ interactive: true,
+ enhancedNavStateFound: true,
+ enhancedNavStateValue: "enhanced-nav-updated",
+ streamingId: streaming,
+ streamingCompleted: true);
+ }
+ }
+
+ private void ValidateEnhancedNavState(
+ string mode,
+ string renderMode,
+ bool interactive,
+ bool enhancedNavStateFound,
+ string enhancedNavStateValue,
+ string streamingId = null,
+ bool streamingCompleted = false)
+ {
+ Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text);
+ Browser.Equal($"Streaming id:{streamingId}", () => Browser.FindElement(By.Id("streaming-id")).Text);
+ Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text);
+
+ if (streamingId == null || streamingCompleted)
+ {
+ Browser.Equal($"Enhanced nav state found:{enhancedNavStateFound}", () => Browser.FindElement(By.Id("enhanced-nav-state-found")).Text);
+ Browser.Equal($"Enhanced nav state value:{enhancedNavStateValue}", () => Browser.FindElement(By.Id("enhanced-nav-state-value")).Text);
+ }
+ else
+ {
+ Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text);
+ }
+ }
}
diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
index bbdbcc43dccd..95f4048b1667 100644
--- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
+++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
@@ -1,5 +1,8 @@
-Application state is @Value
+@using Microsoft.AspNetCore.Components.Web
+Application state is @Value
Render mode: @_renderMode
+Prerendering enabled state: @PrerenderingEnabledValue
+Prerendering disabled state: @PrerenderingDisabledValue
@code {
[Parameter, EditorRequired]
@@ -11,11 +14,21 @@
[SupplyParameterFromPersistentComponentState]
public string Value { get; set; }
+ [SupplyParameterFromPersistentComponentState]
+ [RestoreStateOnPrerendering]
+ public string PrerenderingEnabledValue { get; set; }
+
+ [SupplyParameterFromPersistentComponentState]
+ [UpdateStateOnEnhancedNavigation]
+ public string PrerenderingDisabledValue { get; set; }
+
private string _renderMode = "SSR";
protected override void OnInitialized()
{
Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored";
+ PrerenderingEnabledValue ??= !RendererInfo.IsInteractive ? $"{InitialValue}-prerendering-enabled" : "not restored";
+ PrerenderingDisabledValue ??= !RendererInfo.IsInteractive ? $"{InitialValue}-prerendering-disabled" : "not restored";
_renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server";
}
}
diff --git a/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor
index 2c8ea740d0a0..8e0ec7b1accf 100644
--- a/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor
+++ b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor
@@ -1,4 +1,5 @@
-Non streaming component with persistent state
+@using Microsoft.AspNetCore.Components.Web
+Non streaming component with persistent state
This component demonstrates state persistence in the absence of streaming rendering. When the component renders it will try to restore the state and if present display that it succeeded in doing so and the restored value. If the state is not present, it will indicate it didn't find it and display a "fresh" value.
@@ -6,18 +7,27 @@
Interactive runtime: @_interactiveRuntime
State found:@_stateFound
State value:@_stateValue
+Enhanced nav state found:@_enhancedNavStateFound
+Enhanced nav state value:@_enhancedNavState
@code {
private bool _stateFound;
private string _stateValue;
private string _interactiveRuntime;
+ private bool _enhancedNavStateFound;
+ private string _enhancedNavState;
[Inject] public PersistentComponentState PersistentComponentState { get; set; }
[CascadingParameter(Name = nameof(RunningOnServer))] public bool RunningOnServer { get; set; }
[Parameter] public string ServerState { get; set; }
+ [Parameter]
+ [SupplyParameterFromPersistentComponentState]
+ [UpdateStateOnEnhancedNavigation]
+ public string EnhancedNavState { get; set; }
+
protected override void OnInitialized()
{
PersistentComponentState.RegisterOnPersisting(PersistState);
@@ -39,11 +49,23 @@
{
_interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server";
}
+
+ // Track enhanced navigation state updates
+ _enhancedNavState = EnhancedNavState ?? "no-enhanced-nav-state";
+ _enhancedNavStateFound = !string.IsNullOrEmpty(EnhancedNavState);
+ }
+
+ protected override void OnParametersSet()
+ {
+ // This will be called during enhanced navigation when [UpdateStateOnEnhancedNavigation] triggers
+ _enhancedNavState = EnhancedNavState ?? "no-enhanced-nav-state";
+ _enhancedNavStateFound = !string.IsNullOrEmpty(EnhancedNavState);
}
Task PersistState()
{
PersistentComponentState.PersistAsJson("NonStreamingComponentWithPersistentState", _stateValue);
+ PersistentComponentState.PersistAsJson("EnhancedNavState", "enhanced-nav-updated");
return Task.CompletedTask;
}
}
diff --git a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
index fef4f1ecaa3f..d608224ce745 100644
--- a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
+++ b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
@@ -1,4 +1,5 @@
@using Microsoft.JSInterop
+@using Microsoft.AspNetCore.Components.Web
@inject IJSRuntime JSRuntime
@@ -9,13 +10,19 @@
Current render GUID: @Guid.NewGuid().ToString()
Current count: @State.Count
+Non-persisted counter: @NonPersistedCounter
Click me
+Increment non-persisted
@code {
[SupplyParameterFromPersistentComponentState] public CounterState State { get; set; }
+ [SupplyParameterFromPersistentComponentState]
+ [RestoreStateOnPrerendering]
+ public int NonPersistedCounter { get; set; }
+
public class CounterState
{
public int Count { get; set; } = 0;
@@ -25,10 +32,21 @@
{
// State is preserved across disconnections
State ??= new CounterState();
+
+ // Initialize non-persisted counter to 5 during SSR (before interactivity)
+ if (!RendererInfo.IsInteractive)
+ {
+ NonPersistedCounter = 5;
+ }
}
private void IncrementCount()
{
State.Count = State.Count + 1;
}
+
+ private void IncrementNonPersistedCount()
+ {
+ NonPersistedCounter = NonPersistedCounter + 1;
+ }
}
From 96c41d9a5a535dff864ee9f6c71585c2a707cdd3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Jul 2025 18:39:02 +0000
Subject: [PATCH 12/15] Implement core scenario-based persistent component
state functionality
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
---
.../src/PersistentComponentState.cs | 6 ++++
.../Web/src/PublicAPI.Unshipped.txt | 4 +--
.../RestoreStateOnPrerenderingAttribute.cs | 13 +++++--
.../RestoreStateOnReconnectionAttribute.cs | 13 +++++--
...DictionaryPersistentComponentStateStore.cs | 34 +++++++++++++++++++
.../src/Rendering/WebAssemblyRenderer.cs | 25 +++++++++++++-
.../Services/DefaultWebAssemblyJSRuntime.cs | 14 ++++++--
7 files changed, 99 insertions(+), 10 deletions(-)
create mode 100644 src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs
index b3145861027e..936f74cb7ec9 100644
--- a/src/Components/Components/src/PersistentComponentState.cs
+++ b/src/Components/Components/src/PersistentComponentState.cs
@@ -218,6 +218,12 @@ private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario
if (registration.Filter.ShouldRestore(scenario))
{
registration.Callback();
+
+ // Remove callback if scenario is not recurring (one-time scenarios)
+ if (!scenario.IsRecurring)
+ {
+ _restoringCallbacks.RemoveAt(i);
+ }
}
}
}
diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt
index ea3a7a6c52f7..41cb99b24661 100644
--- a/src/Components/Web/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Web/src/PublicAPI.Unshipped.txt
@@ -2,10 +2,10 @@
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string!
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool
Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute
-Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.RestoreStateOnPrerenderingAttribute() -> void
+Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.RestoreStateOnPrerenderingAttribute(bool restore = true) -> void
Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool
Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute
-Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.RestoreStateOnReconnectionAttribute() -> void
+Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.RestoreStateOnReconnectionAttribute(bool restore = true) -> void
Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool
Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute
Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute.UpdateStateOnEnhancedNavigationAttribute() -> void
diff --git a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
index e5eb1549737c..6dabb1eb933b 100644
--- a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
+++ b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
@@ -9,11 +9,20 @@ namespace Microsoft.AspNetCore.Components.Web;
[AttributeUsage(AttributeTargets.Property)]
public sealed class RestoreStateOnPrerenderingAttribute : Attribute, IPersistentStateFilter
{
- internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.Prerendering;
+ internal WebPersistenceFilter? WebPersistenceFilter { get; }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// Whether to restore state during prerendering. Default is true.
+ public RestoreStateOnPrerenderingAttribute(bool restore = true)
+ {
+ WebPersistenceFilter = restore ? Components.Web.WebPersistenceFilter.Prerendering : null;
+ }
///
public bool ShouldRestore(IPersistentComponentStateScenario scenario)
{
- return WebPersistenceFilter.ShouldRestore(scenario);
+ return WebPersistenceFilter?.ShouldRestore(scenario) ?? false;
}
}
\ No newline at end of file
diff --git a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
index 8ed981245dfe..54345429b123 100644
--- a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
+++ b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
@@ -9,11 +9,20 @@ namespace Microsoft.AspNetCore.Components.Web;
[AttributeUsage(AttributeTargets.Property)]
public sealed class RestoreStateOnReconnectionAttribute : Attribute, IPersistentStateFilter
{
- internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.Reconnection;
+ internal WebPersistenceFilter? WebPersistenceFilter { get; }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// Whether to restore state after reconnection. Default is true.
+ public RestoreStateOnReconnectionAttribute(bool restore = true)
+ {
+ WebPersistenceFilter = restore ? Components.Web.WebPersistenceFilter.Reconnection : null;
+ }
///
public bool ShouldRestore(IPersistentComponentStateScenario scenario)
{
- return WebPersistenceFilter.ShouldRestore(scenario);
+ return WebPersistenceFilter?.ShouldRestore(scenario) ?? false;
}
}
\ No newline at end of file
diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
new file mode 100644
index 000000000000..b6c7edc22cdf
--- /dev/null
+++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
@@ -0,0 +1,34 @@
+// 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.WebAssembly.Rendering;
+
+///
+/// A simple implementation of that stores state in a dictionary.
+///
+internal sealed class DictionaryPersistentComponentStateStore : IPersistentComponentStateStore
+{
+ private readonly IDictionary _state;
+
+ public DictionaryPersistentComponentStateStore(IDictionary state)
+ {
+ _state = state;
+ }
+
+ public Task> GetPersistedStateAsync()
+ {
+ return Task.FromResult(_state);
+ }
+
+ public Task PersistStateAsync(IReadOnlyDictionary state)
+ {
+ // Not needed for WebAssembly scenarios - state is received from JavaScript
+ throw new NotSupportedException("Persisting state is not supported in WebAssembly scenarios.");
+ }
+
+ public bool SupportsRenderMode(IComponentRenderMode? renderMode)
+ {
+ // Accept all render modes since this is just for state restoration
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
index 08bf6a23a278..d731f10770f6 100644
--- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
+++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
@@ -4,6 +4,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices.JavaScript;
+using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Web;
@@ -26,13 +27,16 @@ internal sealed partial class WebAssemblyRenderer : WebRenderer
private readonly Dispatcher _dispatcher;
private readonly ResourceAssetCollection _resourceCollection;
private readonly IInternalJSImportMethods _jsMethods;
+ private readonly ComponentStatePersistenceManager? _componentStatePersistenceManager;
private static readonly RendererInfo _componentPlatform = new("WebAssembly", isInteractive: true);
+ private bool _isFirstUpdate = true;
public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollection resourceCollection, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop)
: base(serviceProvider, loggerFactory, DefaultWebAssemblyJSRuntime.Instance.ReadJsonSerializerOptions(), jsComponentInterop)
{
_logger = loggerFactory.CreateLogger();
_jsMethods = serviceProvider.GetRequiredService();
+ _componentStatePersistenceManager = serviceProvider.GetService();
// if SynchronizationContext.Current is null, it means we are on the single-threaded runtime
_dispatcher = WebAssemblyDispatcher._mainSynchronizationContext == null
@@ -46,8 +50,27 @@ public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollec
}
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "These are root components which belong to the user and are in assemblies that don't get trimmed.")]
- private void OnUpdateRootComponents(RootComponentOperationBatch batch)
+ private async void OnUpdateRootComponents(RootComponentOperationBatch batch, IDictionary? persistentState)
{
+ // Handle persistent state restoration if available
+ if (_componentStatePersistenceManager != null && persistentState != null)
+ {
+ var store = new DictionaryPersistentComponentStateStore(persistentState);
+ var scenario = _isFirstUpdate
+ ? WebPersistenceScenario.Prerendering()
+ : WebPersistenceScenario.EnhancedNavigation(RenderMode.InteractiveWebAssembly);
+
+ try
+ {
+ await _componentStatePersistenceManager.RestoreStateAsync(store, scenario);
+ _isFirstUpdate = false;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error restoring component state during root component update");
+ }
+ }
+
var webRootComponentManager = GetOrCreateWebRootComponentManager();
for (var i = 0; i < batch.Operations.Length; i++)
{
diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs
index b033a3fd5849..29b9bb20fcf0 100644
--- a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs
+++ b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs
@@ -24,7 +24,7 @@ internal sealed partial class DefaultWebAssemblyJSRuntime : WebAssemblyJSRuntime
public ElementReferenceContext ElementReferenceContext { get; }
- public event Action? OnUpdateRootComponents;
+ public event Action?>? OnUpdateRootComponents;
[DynamicDependency(nameof(InvokeDotNet))]
[DynamicDependency(nameof(EndInvokeJS))]
@@ -94,12 +94,20 @@ public static void BeginInvokeDotNet(string? callId, string assemblyNameOrDotNet
[SupportedOSPlatform("browser")]
[JSExport]
- public static void UpdateRootComponentsCore(string operationsJson)
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Persistent state dictionary is preserved by design.")]
+ public static void UpdateRootComponentsCore(string operationsJson, string? persistentStateJson = null)
{
try
{
var operations = DeserializeOperations(operationsJson);
- Instance.OnUpdateRootComponents?.Invoke(operations);
+ IDictionary? persistentState = null;
+
+ if (!string.IsNullOrEmpty(persistentStateJson))
+ {
+ persistentState = JsonSerializer.Deserialize>(persistentStateJson);
+ }
+
+ Instance.OnUpdateRootComponents?.Invoke(operations, persistentState);
}
catch (Exception ex)
{
From 6a9513ee6dbb88b872ad25d238e8ec5375844985 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Jul 2025 18:43:35 +0000
Subject: [PATCH 13/15] Complete server-side circuit integration for
scenario-based persistent component state
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
---
.../Server/src/Circuits/CircuitHost.cs | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs
index ba684e1984cd..dda895e071fb 100644
--- a/src/Components/Server/src/Circuits/CircuitHost.cs
+++ b/src/Components/Server/src/Circuits/CircuitHost.cs
@@ -783,7 +783,14 @@ internal Task UpdateRootComponents(
// provided during the start up process
var appLifetime = _scope.ServiceProvider.GetRequiredService();
appLifetime.SetPlatformRenderMode(RenderMode.InteractiveServer);
- await appLifetime.RestoreStateAsync(store);
+
+ // For the first update, this could be either prerendering or reconnection
+ // If we have persisted circuit state, it means this is a reconnection scenario
+ var scenario = HasPendingPersistedCircuitState
+ ? WebPersistenceScenario.Reconnection()
+ : WebPersistenceScenario.Prerendering();
+
+ await appLifetime.RestoreStateAsync(store, scenario);
}
// Retrieve the circuit handlers at this point.
@@ -801,6 +808,16 @@ internal Task UpdateRootComponents(
}
}
}
+ else
+ {
+ // This is a subsequent update (enhanced navigation)
+ if (store != null)
+ {
+ var appLifetime = _scope.ServiceProvider.GetRequiredService();
+ var scenario = WebPersistenceScenario.EnhancedNavigation(RenderMode.InteractiveServer);
+ await appLifetime.RestoreStateAsync(store, scenario);
+ }
+ }
await PerformRootComponentOperations(operations, shouldWaitForQuiescence);
From 2bc3d0e2eb323e9d8bb830d48e3c32c4671a1d8f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 2 Jul 2025 20:35:55 +0000
Subject: [PATCH 14/15] Address review feedback: Use
PrerenderComponentApplicationStore, make attribute implementations explicit,
clear state after operations
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
---
.../Web/src/PublicAPI.Unshipped.txt | 3 --
.../RestoreStateOnPrerenderingAttribute.cs | 2 +-
.../RestoreStateOnReconnectionAttribute.cs | 2 +-
...pdateStateOnEnhancedNavigationAttribute.cs | 2 +-
...enarioBasedPersistentComponentStateTest.cs | 18 +++++-----
...DictionaryPersistentComponentStateStore.cs | 34 -------------------
.../src/Rendering/WebAssemblyRenderer.cs | 16 ++++++++-
7 files changed, 27 insertions(+), 50 deletions(-)
delete mode 100644 src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt
index 41cb99b24661..f70d096d0123 100644
--- a/src/Components/Web/src/PublicAPI.Unshipped.txt
+++ b/src/Components/Web/src/PublicAPI.Unshipped.txt
@@ -3,13 +3,10 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.Invo
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool
Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute
Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.RestoreStateOnPrerenderingAttribute(bool restore = true) -> void
-Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool
Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute
Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.RestoreStateOnReconnectionAttribute(bool restore = true) -> void
-Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool
Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute
Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute.UpdateStateOnEnhancedNavigationAttribute() -> void
-Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool
Microsoft.AspNetCore.Components.Web.WebPersistenceScenario
Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode?
override Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Equals(object? obj) -> bool
diff --git a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
index 6dabb1eb933b..9a2671586068 100644
--- a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
+++ b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs
@@ -21,7 +21,7 @@ public RestoreStateOnPrerenderingAttribute(bool restore = true)
}
///
- public bool ShouldRestore(IPersistentComponentStateScenario scenario)
+ bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario)
{
return WebPersistenceFilter?.ShouldRestore(scenario) ?? false;
}
diff --git a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
index 54345429b123..612461fa6332 100644
--- a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
+++ b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs
@@ -21,7 +21,7 @@ public RestoreStateOnReconnectionAttribute(bool restore = true)
}
///
- public bool ShouldRestore(IPersistentComponentStateScenario scenario)
+ bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario)
{
return WebPersistenceFilter?.ShouldRestore(scenario) ?? false;
}
diff --git a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs
index 955642b56843..5ea03fda8bd2 100644
--- a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs
+++ b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs
@@ -12,7 +12,7 @@ public sealed class UpdateStateOnEnhancedNavigationAttribute : Attribute, IPersi
internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.EnhancedNavigation;
///
- public bool ShouldRestore(IPersistentComponentStateScenario scenario)
+ bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario)
{
return WebPersistenceFilter.ShouldRestore(scenario);
}
diff --git a/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs
index cb30bf5af933..8ee2e428036b 100644
--- a/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs
+++ b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs
@@ -111,16 +111,16 @@ public void FilterAttributes_ShouldRestore_WorksCorrectly()
var reconnectionFilter = new RestoreStateOnReconnectionAttribute();
// Act & Assert
- Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavScenario));
- Assert.False(enhancedNavFilter.ShouldRestore(prerenderingScenario));
- Assert.False(enhancedNavFilter.ShouldRestore(reconnectionScenario));
+ Assert.True(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(enhancedNavScenario));
+ Assert.False(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(prerenderingScenario));
+ Assert.False(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(reconnectionScenario));
- Assert.False(prerenderingFilter.ShouldRestore(enhancedNavScenario));
- Assert.True(prerenderingFilter.ShouldRestore(prerenderingScenario));
- Assert.False(prerenderingFilter.ShouldRestore(reconnectionScenario));
+ Assert.False(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(enhancedNavScenario));
+ Assert.True(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(prerenderingScenario));
+ Assert.False(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(reconnectionScenario));
- Assert.False(reconnectionFilter.ShouldRestore(enhancedNavScenario));
- Assert.False(reconnectionFilter.ShouldRestore(prerenderingScenario));
- Assert.True(reconnectionFilter.ShouldRestore(reconnectionScenario));
+ Assert.False(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(enhancedNavScenario));
+ Assert.False(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(prerenderingScenario));
+ Assert.True(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(reconnectionScenario));
}
}
\ No newline at end of file
diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
deleted file mode 100644
index b6c7edc22cdf..000000000000
--- a/src/Components/WebAssembly/WebAssembly/src/Rendering/DictionaryPersistentComponentStateStore.cs
+++ /dev/null
@@ -1,34 +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.WebAssembly.Rendering;
-
-///
-/// A simple implementation of that stores state in a dictionary.
-///
-internal sealed class DictionaryPersistentComponentStateStore : IPersistentComponentStateStore
-{
- private readonly IDictionary _state;
-
- public DictionaryPersistentComponentStateStore(IDictionary state)
- {
- _state = state;
- }
-
- public Task> GetPersistedStateAsync()
- {
- return Task.FromResult(_state);
- }
-
- public Task PersistStateAsync(IReadOnlyDictionary state)
- {
- // Not needed for WebAssembly scenarios - state is received from JavaScript
- throw new NotSupportedException("Persisting state is not supported in WebAssembly scenarios.");
- }
-
- public bool SupportsRenderMode(IComponentRenderMode? renderMode)
- {
- // Accept all render modes since this is just for state restoration
- return true;
- }
-}
\ No newline at end of file
diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
index d731f10770f6..39f3f06735aa 100644
--- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
+++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
@@ -52,10 +52,18 @@ public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollec
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "These are root components which belong to the user and are in assemblies that don't get trimmed.")]
private async void OnUpdateRootComponents(RootComponentOperationBatch batch, IDictionary? persistentState)
{
+ PrerenderComponentApplicationStore? store = null;
+
// Handle persistent state restoration if available
if (_componentStatePersistenceManager != null && persistentState != null)
{
- var store = new DictionaryPersistentComponentStateStore(persistentState);
+ store = new PrerenderComponentApplicationStore();
+ store.ExistingState.Clear();
+ foreach (var kvp in persistentState)
+ {
+ store.ExistingState[kvp.Key] = kvp.Value;
+ }
+
var scenario = _isFirstUpdate
? WebPersistenceScenario.Prerendering()
: WebPersistenceScenario.EnhancedNavigation(RenderMode.InteractiveWebAssembly);
@@ -97,6 +105,12 @@ private async void OnUpdateRootComponents(RootComponentOperationBatch batch, IDi
}
}
+ // Clear state after processing operations when it's not the first update
+ if (!_isFirstUpdate && store != null)
+ {
+ store.ExistingState.Clear();
+ }
+
NotifyEndUpdateRootComponents(batch.BatchId);
}
From e180005c4829cd7d1d517906df01bae9a4dfa7ac Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 3 Jul 2025 20:18:00 +0000
Subject: [PATCH 15/15] Fix E2E test scenarios to match design document
specifications
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
---
.../TestContentPackage/DeclarativePersistStateComponent.razor | 2 +-
.../test/testassets/TestContentPackage/PersistentCounter.razor | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
index 95f4048b1667..2a0e60f051b0 100644
--- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
+++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor
@@ -19,7 +19,7 @@
public string PrerenderingEnabledValue { get; set; }
[SupplyParameterFromPersistentComponentState]
- [UpdateStateOnEnhancedNavigation]
+ [RestoreStateOnPrerendering(false)]
public string PrerenderingDisabledValue { get; set; }
private string _renderMode = "SSR";
diff --git a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
index d608224ce745..4aaa85052ab8 100644
--- a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
+++ b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor
@@ -20,7 +20,7 @@
[SupplyParameterFromPersistentComponentState] public CounterState State { get; set; }
[SupplyParameterFromPersistentComponentState]
- [RestoreStateOnPrerendering]
+ [RestoreStateOnReconnection(false)]
public int NonPersistedCounter { get; set; }
public class CounterState