From 5d415ec2b683eccd4ba38cd1a4ad9ac3d99b1828 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:38:22 +0000 Subject: [PATCH 01/15] Initial plan From bd834c28641708394d6f3af21e42f0fab425c64b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:44:36 +0000 Subject: [PATCH 02/15] Initial commit - start implementing scenario-based persistent component state Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- activate.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 activate.sh diff --git a/activate.sh b/activate.sh old mode 100644 new mode 100755 From 2524d258d1baf095a52ed78227181f1d6433f494 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:50:50 +0000 Subject: [PATCH 03/15] Implement core scenario-based persistent component state interfaces and attributes Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/IPersistentComponentStateScenario.cs | 16 ++++ .../Components/src/IPersistentStateFilter.cs | 17 ++++ .../src/PersistentComponentState.cs | 82 +++++++++++++++++++ .../ComponentStatePersistenceManager.cs | 26 +++++- .../Components/src/PublicAPI.Unshipped.txt | 10 +++ .../RestoringComponentStateSubscription.cs | 44 ++++++++++ .../Web/src/PublicAPI.Unshipped.txt | 23 +++++- .../RestoreStateOnPrerenderingAttribute.cs | 23 ++++++ .../RestoreStateOnReconnectionAttribute.cs | 23 ++++++ ...pdateStateOnEnhancedNavigationAttribute.cs | 23 ++++++ .../Web/src/WebPersistenceContext.cs | 49 +++++++++++ .../Web/src/WebPersistenceReason.cs | 26 ++++++ 12 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/Components/Components/src/IPersistentComponentStateScenario.cs create mode 100644 src/Components/Components/src/IPersistentStateFilter.cs create mode 100644 src/Components/Components/src/RestoringComponentStateSubscription.cs create mode 100644 src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs create mode 100644 src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs create mode 100644 src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs create mode 100644 src/Components/Web/src/WebPersistenceContext.cs create mode 100644 src/Components/Web/src/WebPersistenceReason.cs diff --git a/src/Components/Components/src/IPersistentComponentStateScenario.cs b/src/Components/Components/src/IPersistentComponentStateScenario.cs new file mode 100644 index 000000000000..2d67c2a86a81 --- /dev/null +++ b/src/Components/Components/src/IPersistentComponentStateScenario.cs @@ -0,0 +1,16 @@ +// 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; + +/// +/// Represents a scenario for persistent component state restoration. +/// +public interface IPersistentComponentStateScenario +{ + /// + /// Gets a value indicating whether callbacks for this scenario can be invoked multiple times. + /// If false, callbacks are automatically unregistered after first invocation. + /// + bool IsRecurring { get; } +} \ No newline at end of file diff --git a/src/Components/Components/src/IPersistentStateFilter.cs b/src/Components/Components/src/IPersistentStateFilter.cs new file mode 100644 index 000000000000..44c33faaad29 --- /dev/null +++ b/src/Components/Components/src/IPersistentStateFilter.cs @@ -0,0 +1,17 @@ +// 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; + +/// +/// Defines filtering logic for persistent component state restoration. +/// +public interface IPersistentStateFilter +{ + /// + /// Determines whether state should be restored for the given scenario. + /// + /// The restoration scenario. + /// True if state should be restored; otherwise false. + bool ShouldRestore(IPersistentComponentStateScenario scenario); +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a3dd2fdddc81..675bae24b528 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -16,6 +16,7 @@ public class PersistentComponentState private readonly IDictionary _currentState; private readonly List _registeredCallbacks; + private readonly List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)> _restoringCallbacks = new(); internal PersistentComponentState( IDictionary currentState, @@ -27,6 +28,11 @@ internal PersistentComponentState( internal bool PersistingState { get; set; } + /// + /// Gets the current restoration scenario, if any. + /// + public IPersistentComponentStateScenario? CurrentScenario { get; internal set; } + internal void InitializeExistingState(IDictionary existingState) { if (_existingState != null) @@ -155,6 +161,82 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial } } + /// + /// Registers a callback to be invoked when state is restored for a specific scenario. + /// If state for the scenario is already available, the callback is invoked immediately. + /// + /// The scenario for which to register the callback. + /// The callback to invoke during restoration. + /// A subscription that can be disposed to unregister the callback. + public RestoringComponentStateSubscription RegisterOnRestoring( + IPersistentComponentStateScenario scenario, + Action callback) + { + ArgumentNullException.ThrowIfNull(scenario); + ArgumentNullException.ThrowIfNull(callback); + + var isRecurring = scenario.IsRecurring; + _restoringCallbacks.Add((scenario, callback, isRecurring)); + + // If we already have a current scenario and it matches, invoke immediately + if (CurrentScenario != null && ShouldInvokeCallback(scenario, CurrentScenario)) + { + callback(); + } + + return new RestoringComponentStateSubscription(_restoringCallbacks, scenario, callback, isRecurring); + } + + /// + /// Updates the existing state with new state for subsequent restoration calls. + /// Only allowed when existing state is empty (fully consumed). + /// + /// New state dictionary to replace existing state. + /// The restoration scenario context. + internal void UpdateExistingState(IDictionary newState, IPersistentComponentStateScenario scenario) + { + ArgumentNullException.ThrowIfNull(newState); + ArgumentNullException.ThrowIfNull(scenario); + + if (_existingState != null && _existingState.Count > 0) + { + throw new InvalidOperationException("Cannot update existing state when state dictionary is not empty. State must be fully consumed before updating."); + } + + _existingState = newState; + CurrentScenario = scenario; + + // Invoke matching restoration callbacks + InvokeRestoringCallbacks(scenario); + } + + private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario) + { + for (int i = _restoringCallbacks.Count - 1; i >= 0; i--) + { + var (callbackScenario, callback, isRecurring) = _restoringCallbacks[i]; + + if (ShouldInvokeCallback(callbackScenario, scenario)) + { + callback(); + + // Remove non-recurring callbacks after invocation + if (!isRecurring) + { + _restoringCallbacks.RemoveAt(i); + } + } + } + } + + private static bool ShouldInvokeCallback(IPersistentComponentStateScenario callbackScenario, IPersistentComponentStateScenario currentScenario) + { + // For now, match scenarios by type and properties + // This can be enhanced with more sophisticated matching logic + return callbackScenario.GetType() == currentScenario.GetType() && + callbackScenario.Equals(currentScenario); + } + private bool TryTake(string key, out byte[]? value) { ArgumentNullException.ThrowIfNull(key); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 72c1ca666411..cddce3f42230 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -55,9 +55,33 @@ public ComponentStatePersistenceManager(ILoggerThe to restore the application state from. /// A that will complete when the state has been restored. public async Task RestoreStateAsync(IPersistentComponentStateStore store) + { + await RestoreStateAsync(store, scenario: null); + } + + /// + /// Restores component state from the given store with scenario context. + /// + /// The store to restore state from. + /// The restoration scenario context. + /// A task that completes when state restoration is finished. + public async Task RestoreStateAsync( + IPersistentComponentStateStore store, + IPersistentComponentStateScenario? scenario) { var data = await store.GetPersistedStateAsync(); - State.InitializeExistingState(data); + + if (scenario == null) + { + // First-time initialization + State.InitializeExistingState(data); + } + else + { + // Scenario-based update + State.UpdateExistingState(data, scenario); + } + _servicesRegistry?.Restore(State); } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 33bf7c236923..4d9dee6a59d2 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -21,3 +21,13 @@ static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCo static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object? +Microsoft.AspNetCore.Components.IPersistentComponentStateScenario +Microsoft.AspNetCore.Components.IPersistentComponentStateScenario.IsRecurring.get -> bool +Microsoft.AspNetCore.Components.IPersistentStateFilter +Microsoft.AspNetCore.Components.IPersistentStateFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool +Microsoft.AspNetCore.Components.PersistentComponentState.CurrentScenario.get -> Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.RestoringComponentStateSubscription() -> void +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.Dispose() -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.RestoreStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? scenario) -> System.Threading.Tasks.Task! diff --git a/src/Components/Components/src/RestoringComponentStateSubscription.cs b/src/Components/Components/src/RestoringComponentStateSubscription.cs new file mode 100644 index 000000000000..30c6d936f452 --- /dev/null +++ b/src/Components/Components/src/RestoringComponentStateSubscription.cs @@ -0,0 +1,44 @@ +// 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; + +/// +/// Represents a subscription to state restoration notifications. +/// +public readonly struct RestoringComponentStateSubscription : IDisposable +{ + private readonly List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)>? _callbacks; + private readonly IPersistentComponentStateScenario? _scenario; + private readonly Action? _callback; + private readonly bool _isRecurring; + + internal RestoringComponentStateSubscription( + List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)> callbacks, + IPersistentComponentStateScenario scenario, + Action callback, + bool isRecurring) + { + _callbacks = callbacks; + _scenario = scenario; + _callback = callback; + _isRecurring = isRecurring; + } + + /// + public void Dispose() + { + if (_callbacks != null && _scenario != null && _callback != null) + { + for (int i = _callbacks.Count - 1; i >= 0; i--) + { + var (scenario, callback, isRecurring) = _callbacks[i]; + if (ReferenceEquals(scenario, _scenario) && ReferenceEquals(callback, _callback) && isRecurring == _isRecurring) + { + _callbacks.RemoveAt(i); + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 99365e10804e..3ad30d0392d5 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,3 +1,24 @@ #nullable enable Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! -virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file +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.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool +Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute +Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.RestoreStateOnReconnectionAttribute() -> 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.WebPersistenceContext +Microsoft.AspNetCore.Components.Web.WebPersistenceContext.WebPersistenceContext(Microsoft.AspNetCore.Components.Web.WebPersistenceReason reason, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode = null) -> void +Microsoft.AspNetCore.Components.Web.WebPersistenceContext.IsRecurring.get -> bool +Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reason.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason +Microsoft.AspNetCore.Components.Web.WebPersistenceContext.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode? +static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.EnhancedNavigation.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! +static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Prerendering.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! +static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reconnection.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! +Microsoft.AspNetCore.Components.Web.WebPersistenceReason +Microsoft.AspNetCore.Components.Web.WebPersistenceReason.EnhancedNavigation = 2 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason +Microsoft.AspNetCore.Components.Web.WebPersistenceReason.Prerendering = 1 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason +Microsoft.AspNetCore.Components.Web.WebPersistenceReason.Reconnection = 4 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs new file mode 100644 index 000000000000..17c72613ee53 --- /dev/null +++ b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs @@ -0,0 +1,23 @@ +// 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.Web; + +/// +/// Indicates that state should be restored during prerendering. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class RestoreStateOnPrerenderingAttribute : Attribute, IPersistentStateFilter +{ + /// + public bool ShouldRestore(IPersistentComponentStateScenario scenario) + { + if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.Prerendering } context) + { + return false; + } + + // Prerendering state restoration only applies to interactive modes + return context.RenderMode is InteractiveServerRenderMode or InteractiveWebAssemblyRenderMode; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs new file mode 100644 index 000000000000..b543bbbd1f0a --- /dev/null +++ b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs @@ -0,0 +1,23 @@ +// 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.Web; + +/// +/// Indicates that state should be restored after server reconnection. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class RestoreStateOnReconnectionAttribute : Attribute, IPersistentStateFilter +{ + /// + public bool ShouldRestore(IPersistentComponentStateScenario scenario) + { + if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.Reconnection } context) + { + return false; + } + + // Reconnection only applies to server-side interactive mode + return context.RenderMode is InteractiveServerRenderMode; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs new file mode 100644 index 000000000000..2e6ccb0e919f --- /dev/null +++ b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs @@ -0,0 +1,23 @@ +// 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.Web; + +/// +/// Indicates that state should be restored during enhanced navigation. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class UpdateStateOnEnhancedNavigationAttribute : Attribute, IPersistentStateFilter +{ + /// + public bool ShouldRestore(IPersistentComponentStateScenario scenario) + { + if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.EnhancedNavigation } context) + { + return false; + } + + // Enhanced navigation only applies to interactive modes (Server or WebAssembly) + return context.RenderMode is InteractiveServerRenderMode or InteractiveWebAssemblyRenderMode; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceContext.cs b/src/Components/Web/src/WebPersistenceContext.cs new file mode 100644 index 000000000000..f7375ac2bde1 --- /dev/null +++ b/src/Components/Web/src/WebPersistenceContext.cs @@ -0,0 +1,49 @@ +// 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.Web; + +/// +/// Provides context for web-based persistent component state restoration. +/// +public sealed class WebPersistenceContext : IPersistentComponentStateScenario +{ + /// + /// Initializes a new instance of . + /// + /// The reason for this restoration. + /// The render mode context for this restoration. + public WebPersistenceContext(WebPersistenceReason reason, IComponentRenderMode? renderMode = null) + { + Reason = reason; + RenderMode = renderMode; + } + + /// + /// Gets the reason for this restoration. + /// + public WebPersistenceReason Reason { get; } + + /// + /// Gets the render mode context for this restoration. + /// + public IComponentRenderMode? RenderMode { get; } + + /// + public bool IsRecurring => Reason == WebPersistenceReason.EnhancedNavigation; + + /// + /// Gets a context for enhanced navigation restoration. + /// + public static WebPersistenceContext EnhancedNavigation => new(WebPersistenceReason.EnhancedNavigation); + + /// + /// Gets a context for prerendering restoration. + /// + public static WebPersistenceContext Prerendering => new(WebPersistenceReason.Prerendering); + + /// + /// Gets a context for reconnection restoration. + /// + public static WebPersistenceContext Reconnection => new(WebPersistenceReason.Reconnection); +} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceReason.cs b/src/Components/Web/src/WebPersistenceReason.cs new file mode 100644 index 000000000000..e3268bb3ded9 --- /dev/null +++ b/src/Components/Web/src/WebPersistenceReason.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. + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Specifies the reason for persistent component state restoration in web scenarios. +/// +[Flags] +public enum WebPersistenceReason +{ + /// + /// State restoration during prerendering. + /// + Prerendering = 1, + + /// + /// State restoration during enhanced navigation. + /// + EnhancedNavigation = 2, + + /// + /// State restoration after server reconnection. + /// + Reconnection = 4 +} \ No newline at end of file From 82659034a5ddfc1d7f439b43dd51016d3217de29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:59:02 +0000 Subject: [PATCH 04/15] Complete scenario-based persistent component state implementation with tests Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- ...omPersistentComponentStateValueProvider.cs | 46 +++++++++ ...crosoft.AspNetCore.Components.Tests.csproj | 1 + ...enarioBasedPersistentComponentStateTest.cs | 96 +++++++++++++++++++ .../Web/src/PublicAPI.Unshipped.txt | 2 + .../Web/src/WebPersistenceContext.cs | 14 +++ 5 files changed, 159 insertions(+) create mode 100644 src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index d157dfbd3bb4..3c2fe8c21dd2 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -42,6 +42,12 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) var componentState = (ComponentState)key!; var storageKey = ComputeKey(componentState, parameterInfo.PropertyName); + // Check if there are scenario filters on the property + if (ShouldFilterByScenario(componentState, parameterInfo)) + { + return null; // Don't provide value if scenario filtering rejects it + } + return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } @@ -281,4 +287,44 @@ private static bool IsSerializableKey(object key) return result; } + + [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateValueProvider.GetPropertyForScenarioFiltering(Type, String)'. The return value of method 'System.Object.GetType()' does not have matching annotations.", Justification = "Properties of rendered components are preserved through other means and won't get trimmed.")] + private bool ShouldFilterByScenario(ComponentState componentState, in CascadingParameterInfo parameterInfo) + { + // If there's no current scenario, don't filter + if (state.CurrentScenario == null) + { + return false; + } + + // Get the property info to check for filter attributes + var componentType = componentState.Component.GetType(); + var propertyInfo = GetPropertyForScenarioFiltering(componentType, parameterInfo.PropertyName); + if (propertyInfo == null) + { + return false; + } + + // Check for IPersistentStateFilter attributes + var filterAttributes = propertyInfo.GetCustomAttributes(typeof(IPersistentStateFilter), inherit: true); + if (filterAttributes.Length == 0) + { + return false; // No filters, allow state + } + + // Check if any filter allows the current scenario + foreach (IPersistentStateFilter filter in filterAttributes) + { + if (filter.ShouldRestore(state.CurrentScenario)) + { + return false; // At least one filter allows it + } + } + + return true; // No filter allows it, so filter it out + } + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The return value of method 'System.Object.GetType()' does not have matching annotations.", Justification = "Properties of rendered components are preserved through other means and won't get trimmed.")] + private static PropertyInfo? GetPropertyForScenarioFiltering([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string propertyName) + => type.GetProperty(propertyName); } diff --git a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj index 732ebbb65892..be5f0fed3908 100644 --- a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj +++ b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs b/src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs new file mode 100644 index 000000000000..ccd35164be49 --- /dev/null +++ b/src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs @@ -0,0 +1,96 @@ +// 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.Web; +using Xunit; + +namespace Microsoft.AspNetCore.Components; + +public class ScenarioBasedPersistentComponentStateTest +{ + [Fact] + public void WebPersistenceContext_Properties_SetCorrectly() + { + // Arrange & Act + var enhancedNavContext = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); + var prerenderingContext = new WebPersistenceContext(WebPersistenceReason.Prerendering); + var reconnectionContext = new WebPersistenceContext(WebPersistenceReason.Reconnection); + + // Assert + Assert.Equal(WebPersistenceReason.EnhancedNavigation, enhancedNavContext.Reason); + Assert.True(enhancedNavContext.IsRecurring); + + Assert.Equal(WebPersistenceReason.Prerendering, prerenderingContext.Reason); + Assert.False(prerenderingContext.IsRecurring); + + Assert.Equal(WebPersistenceReason.Reconnection, reconnectionContext.Reason); + Assert.False(reconnectionContext.IsRecurring); + } + + [Fact] + public void WebPersistenceContext_StaticProperties_ReturnCorrectInstances() + { + // Act + var enhancedNav = WebPersistenceContext.EnhancedNavigation; + var prerendering = WebPersistenceContext.Prerendering; + var reconnection = WebPersistenceContext.Reconnection; + + // Assert + Assert.Equal(WebPersistenceReason.EnhancedNavigation, enhancedNav.Reason); + Assert.Equal(WebPersistenceReason.Prerendering, prerendering.Reason); + Assert.Equal(WebPersistenceReason.Reconnection, reconnection.Reason); + } + + [Fact] + public void WebPersistenceContext_Equals_WorksCorrectly() + { + // Arrange + var context1 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); + var context2 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); + var context3 = new WebPersistenceContext(WebPersistenceReason.Prerendering); + + // Act & Assert + Assert.True(context1.Equals(context2)); + Assert.False(context1.Equals(context3)); + Assert.False(context1.Equals(null)); + } + + [Fact] + public void WebPersistenceContext_GetHashCode_WorksCorrectly() + { + // Arrange + var context1 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); + var context2 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); + var context3 = new WebPersistenceContext(WebPersistenceReason.Prerendering); + + // Act & Assert + Assert.Equal(context1.GetHashCode(), context2.GetHashCode()); + Assert.NotEqual(context1.GetHashCode(), context3.GetHashCode()); + } + + [Fact] + public void FilterAttributes_ShouldRestore_WorksCorrectly() + { + // Arrange + var enhancedNavContext = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation, new InteractiveServerRenderMode()); + var prerenderingContext = new WebPersistenceContext(WebPersistenceReason.Prerendering, new InteractiveServerRenderMode()); + var reconnectionContext = new WebPersistenceContext(WebPersistenceReason.Reconnection, new InteractiveServerRenderMode()); + + var enhancedNavFilter = new UpdateStateOnEnhancedNavigationAttribute(); + var prerenderingFilter = new RestoreStateOnPrerenderingAttribute(); + var reconnectionFilter = new RestoreStateOnReconnectionAttribute(); + + // Act & Assert + Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavContext)); + Assert.False(enhancedNavFilter.ShouldRestore(prerenderingContext)); + Assert.False(enhancedNavFilter.ShouldRestore(reconnectionContext)); + + Assert.False(prerenderingFilter.ShouldRestore(enhancedNavContext)); + Assert.True(prerenderingFilter.ShouldRestore(prerenderingContext)); + Assert.False(prerenderingFilter.ShouldRestore(reconnectionContext)); + + Assert.False(reconnectionFilter.ShouldRestore(enhancedNavContext)); + Assert.False(reconnectionFilter.ShouldRestore(prerenderingContext)); + Assert.True(reconnectionFilter.ShouldRestore(reconnectionContext)); + } +} \ No newline at end of file diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 3ad30d0392d5..2079b8eec166 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -15,6 +15,8 @@ Microsoft.AspNetCore.Components.Web.WebPersistenceContext.WebPersistenceContext( Microsoft.AspNetCore.Components.Web.WebPersistenceContext.IsRecurring.get -> bool Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reason.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason Microsoft.AspNetCore.Components.Web.WebPersistenceContext.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode? +override Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Components.Web.WebPersistenceContext.GetHashCode() -> int static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.EnhancedNavigation.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Prerendering.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reconnection.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! diff --git a/src/Components/Web/src/WebPersistenceContext.cs b/src/Components/Web/src/WebPersistenceContext.cs index f7375ac2bde1..59593ed4f779 100644 --- a/src/Components/Web/src/WebPersistenceContext.cs +++ b/src/Components/Web/src/WebPersistenceContext.cs @@ -46,4 +46,18 @@ public WebPersistenceContext(WebPersistenceReason reason, IComponentRenderMode? /// Gets a context for reconnection restoration. /// public static WebPersistenceContext Reconnection => new(WebPersistenceReason.Reconnection); + + /// + public override bool Equals(object? obj) + { + return obj is WebPersistenceContext other && + Reason == other.Reason && + RenderMode?.GetType() == other.RenderMode?.GetType(); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Reason, RenderMode?.GetType()); + } } \ No newline at end of file From 85759ef832b3d75c542307ef928850ecdfe4846a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:38:02 +0000 Subject: [PATCH 05/15] Move scenario-based persistent component state tests to Components.Web.Tests project Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/test/Microsoft.AspNetCore.Components.Tests.csproj | 1 - .../test/ScenarioBasedPersistentComponentStateTest.cs | 0 2 files changed, 1 deletion(-) rename src/Components/{Components => Web}/test/ScenarioBasedPersistentComponentStateTest.cs (100%) diff --git a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj index be5f0fed3908..732ebbb65892 100644 --- a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj +++ b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj @@ -7,7 +7,6 @@ - diff --git a/src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs similarity index 100% rename from src/Components/Components/test/ScenarioBasedPersistentComponentStateTest.cs rename to src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs From 3a1d69ca5f240f40503d16231f5828dd5cc52c86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:57:50 +0000 Subject: [PATCH 06/15] Address feedback: Refactor scenario-based component state filtering - Replace tuple with RestoreComponentStateRegistration struct - Remove redundant IsRecurring storage (use scenario.IsRecurring) - Add RegisterOnRestoring overload that takes IPersistentStateFilter - Modify PropertyGetter to expose PropertyInfo to avoid redundant reflection - Update SupplyParameterFromPersistentComponentStateValueProvider to use RegisterOnRestoring callbacks during Subscribe - Track restore call count instead of checking scenario presence in ComponentStatePersistenceManager - Add proper API definitions and trimming attributes Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/PersistentComponentState.cs | 70 ++++++++++++++++--- .../ComponentStatePersistenceManager.cs | 18 ++++- .../Components/src/PublicAPI.Unshipped.txt | 1 + .../src/Reflection/PropertyGetter.cs | 4 ++ .../src/RestoreComponentStateRegistration.cs | 19 +++++ .../RestoringComponentStateSubscription.cs | 13 ++-- ...omPersistentComponentStateValueProvider.cs | 57 ++++++--------- 7 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 src/Components/Components/src/RestoreComponentStateRegistration.cs diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index 675bae24b528..d26d00068a86 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -16,7 +16,7 @@ public class PersistentComponentState private readonly IDictionary _currentState; private readonly List _registeredCallbacks; - private readonly List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)> _restoringCallbacks = new(); + private readonly List _restoringCallbacks = new(); internal PersistentComponentState( IDictionary currentState, @@ -175,8 +175,8 @@ public RestoringComponentStateSubscription RegisterOnRestoring( ArgumentNullException.ThrowIfNull(scenario); ArgumentNullException.ThrowIfNull(callback); - var isRecurring = scenario.IsRecurring; - _restoringCallbacks.Add((scenario, callback, isRecurring)); + var registration = new RestoreComponentStateRegistration(scenario, callback); + _restoringCallbacks.Add(registration); // If we already have a current scenario and it matches, invoke immediately if (CurrentScenario != null && ShouldInvokeCallback(scenario, CurrentScenario)) @@ -184,7 +184,52 @@ public RestoringComponentStateSubscription RegisterOnRestoring( callback(); } - return new RestoringComponentStateSubscription(_restoringCallbacks, scenario, callback, isRecurring); + return new RestoringComponentStateSubscription(_restoringCallbacks, scenario, callback); + } + + /// + /// Registers a callback to be invoked when state is restored and the filter allows the current scenario. + /// + /// The filter to determine if the callback should be invoked for a scenario. + /// The callback to invoke during restoration. + /// A subscription that can be disposed to unregister the callback. + public RestoringComponentStateSubscription RegisterOnRestoring( + IPersistentStateFilter filter, + Action callback) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(callback); + + // Create a wrapper scenario that uses the filter + var filterScenario = new FilterWrapperScenario(filter); + return RegisterOnRestoring(filterScenario, callback); + } + + /// + /// A scenario wrapper that uses a filter to determine if it should match the current scenario. + /// + private sealed class FilterWrapperScenario : IPersistentComponentStateScenario + { + private readonly IPersistentStateFilter _filter; + + public FilterWrapperScenario(IPersistentStateFilter filter) + { + _filter = filter; + } + + public bool IsRecurring => true; // Filter-based scenarios can be recurring + + public bool ShouldMatchScenario(IPersistentComponentStateScenario currentScenario) + { + return _filter.ShouldRestore(currentScenario); + } + + public override bool Equals(object? obj) + { + return obj is FilterWrapperScenario other && ReferenceEquals(_filter, other._filter); + } + + public override int GetHashCode() => _filter.GetHashCode(); } /// @@ -214,14 +259,14 @@ private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario { for (int i = _restoringCallbacks.Count - 1; i >= 0; i--) { - var (callbackScenario, callback, isRecurring) = _restoringCallbacks[i]; + var registration = _restoringCallbacks[i]; - if (ShouldInvokeCallback(callbackScenario, scenario)) + if (ShouldInvokeCallback(registration.Scenario, scenario)) { - callback(); + registration.Callback(); // Remove non-recurring callbacks after invocation - if (!isRecurring) + if (!registration.Scenario.IsRecurring) { _restoringCallbacks.RemoveAt(i); } @@ -231,8 +276,13 @@ private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario private static bool ShouldInvokeCallback(IPersistentComponentStateScenario callbackScenario, IPersistentComponentStateScenario currentScenario) { - // For now, match scenarios by type and properties - // This can be enhanced with more sophisticated matching logic + // Special handling for filter wrapper scenarios + if (callbackScenario is FilterWrapperScenario filterWrapper) + { + return filterWrapper.ShouldMatchScenario(currentScenario); + } + + // For regular scenarios, match by type and properties return callbackScenario.GetType() == currentScenario.GetType() && callbackScenario.Equals(currentScenario); } diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index cddce3f42230..238234cc4dc2 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -17,6 +17,7 @@ public class ComponentStatePersistenceManager private bool _stateIsPersisted; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); + private int _restoreCallCount; /// /// Initializes a new instance of . @@ -71,15 +72,26 @@ public async Task RestoreStateAsync( { var data = await store.GetPersistedStateAsync(); - if (scenario == null) + _restoreCallCount++; + + if (_restoreCallCount == 1) { // First-time initialization State.InitializeExistingState(data); } else { - // Scenario-based update - State.UpdateExistingState(data, scenario); + // Scenario-based update - only if we have a scenario + if (scenario != null) + { + State.UpdateExistingState(data, scenario); + } + else + { + // This is a second call without a scenario, which should fail + // (maintaining the original behavior for backward compatibility) + throw new InvalidOperationException("PersistentComponentState already initialized."); + } } _servicesRegistry?.Restore(State); diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 4d9dee6a59d2..f72f5433b17e 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -27,6 +27,7 @@ Microsoft.AspNetCore.Components.IPersistentStateFilter Microsoft.AspNetCore.Components.IPersistentStateFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool Microsoft.AspNetCore.Components.PersistentComponentState.CurrentScenario.get -> Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentStateFilter! filter, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription Microsoft.AspNetCore.Components.RestoringComponentStateSubscription Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.RestoringComponentStateSubscription() -> void Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.Dispose() -> void diff --git a/src/Components/Components/src/Reflection/PropertyGetter.cs b/src/Components/Components/src/Reflection/PropertyGetter.cs index 03fa596cbc5c..e4f2289f0dd4 100644 --- a/src/Components/Components/src/Reflection/PropertyGetter.cs +++ b/src/Components/Components/src/Reflection/PropertyGetter.cs @@ -14,12 +14,16 @@ internal sealed class PropertyGetter private readonly Func _GetterDelegate; + public PropertyInfo PropertyInfo { get; } + [UnconditionalSuppressMessage( "ReflectionAnalysis", "IL2060:MakeGenericMethod", Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")] public PropertyGetter(Type targetType, PropertyInfo property) { + PropertyInfo = property; + if (property.GetMethod == null) { throw new InvalidOperationException("Cannot provide a value for property " + diff --git a/src/Components/Components/src/RestoreComponentStateRegistration.cs b/src/Components/Components/src/RestoreComponentStateRegistration.cs new file mode 100644 index 000000000000..a394c749de4e --- /dev/null +++ b/src/Components/Components/src/RestoreComponentStateRegistration.cs @@ -0,0 +1,19 @@ +// 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; + +/// +/// Represents a registration for state restoration callbacks. +/// +internal readonly struct RestoreComponentStateRegistration +{ + public RestoreComponentStateRegistration(IPersistentComponentStateScenario scenario, Action callback) + { + Scenario = scenario; + Callback = callback; + } + + public IPersistentComponentStateScenario Scenario { get; } + public Action Callback { get; } +} \ No newline at end of file diff --git a/src/Components/Components/src/RestoringComponentStateSubscription.cs b/src/Components/Components/src/RestoringComponentStateSubscription.cs index 30c6d936f452..ab14ec76926b 100644 --- a/src/Components/Components/src/RestoringComponentStateSubscription.cs +++ b/src/Components/Components/src/RestoringComponentStateSubscription.cs @@ -8,21 +8,18 @@ namespace Microsoft.AspNetCore.Components; /// public readonly struct RestoringComponentStateSubscription : IDisposable { - private readonly List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)>? _callbacks; + private readonly List? _callbacks; private readonly IPersistentComponentStateScenario? _scenario; private readonly Action? _callback; - private readonly bool _isRecurring; internal RestoringComponentStateSubscription( - List<(IPersistentComponentStateScenario Scenario, Action Callback, bool IsRecurring)> callbacks, + List callbacks, IPersistentComponentStateScenario scenario, - Action callback, - bool isRecurring) + Action callback) { _callbacks = callbacks; _scenario = scenario; _callback = callback; - _isRecurring = isRecurring; } /// @@ -32,8 +29,8 @@ public void Dispose() { for (int i = _callbacks.Count - 1; i >= 0; i--) { - var (scenario, callback, isRecurring) = _callbacks[i]; - if (ReferenceEquals(scenario, _scenario) && ReferenceEquals(callback, _callback) && isRecurring == _isRecurring) + var registration = _callbacks[i]; + if (ReferenceEquals(registration.Scenario, _scenario) && ReferenceEquals(registration.Callback, _callback)) { _callbacks.RemoveAt(i); break; diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 3c2fe8c21dd2..cb4431a07430 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -42,12 +42,6 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) var componentState = (ComponentState)key!; var storageKey = ComputeKey(componentState, parameterInfo.PropertyName); - // Check if there are scenario filters on the property - if (ShouldFilterByScenario(componentState, parameterInfo)) - { - return null; // Don't provide value if scenario filtering rejects it - } - return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } @@ -58,10 +52,12 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param { var propertyName = parameterInfo.PropertyName; var propertyType = parameterInfo.PropertyType; + var propertyGetter = ResolvePropertyGetter(subscriber.Component.GetType(), propertyName); + + // Register persistence callback _subscriptions[subscriber] = state.RegisterOnPersisting(() => { var storageKey = ComputeKey(subscriber, propertyName); - var propertyGetter = ResolvePropertyGetter(subscriber.Component.GetType(), propertyName); var property = propertyGetter.GetValue(subscriber.Component); if (property == null) { @@ -70,6 +66,9 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param state.PersistAsJson(storageKey, property, propertyType); return Task.CompletedTask; }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); + + // Register scenario-based restoration callback using PropertyGetter's PropertyInfo + RegisterScenarioRestorationCallback(subscriber, parameterInfo, propertyGetter.PropertyInfo); } private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) @@ -288,43 +287,33 @@ private static bool IsSerializableKey(object key) return result; } - [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateValueProvider.GetPropertyForScenarioFiltering(Type, String)'. The return value of method 'System.Object.GetType()' does not have matching annotations.", Justification = "Properties of rendered components are preserved through other means and won't get trimmed.")] - private bool ShouldFilterByScenario(ComponentState componentState, in CascadingParameterInfo parameterInfo) + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicConstructors', 'DynamicallyAccessedMemberTypes.PublicFields', 'DynamicallyAccessedMemberTypes.PublicProperties' in call to target method. The return value of the source method does not have matching annotations.", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + private void RegisterScenarioRestorationCallback(ComponentState subscriber, in CascadingParameterInfo parameterInfo, PropertyInfo propertyInfo) { - // If there's no current scenario, don't filter - if (state.CurrentScenario == null) - { - return false; - } - - // Get the property info to check for filter attributes - var componentType = componentState.Component.GetType(); - var propertyInfo = GetPropertyForScenarioFiltering(componentType, parameterInfo.PropertyName); - if (propertyInfo == null) - { - return false; - } - // Check for IPersistentStateFilter attributes var filterAttributes = propertyInfo.GetCustomAttributes(typeof(IPersistentStateFilter), inherit: true); if (filterAttributes.Length == 0) { - return false; // No filters, allow state + return; // No filters, no scenario-based restoration needed } - // Check if any filter allows the current scenario + var storageKey = ComputeKey(subscriber, parameterInfo.PropertyName); + var propertyType = parameterInfo.PropertyType; + var component = subscriber.Component; + + // Register restoration callbacks for each filter foreach (IPersistentStateFilter filter in filterAttributes) { - if (filter.ShouldRestore(state.CurrentScenario)) + state.RegisterOnRestoring(filter, () => { - return false; // At least one filter allows it - } + if (state.TryTakeFromJson(storageKey, propertyType, out var value)) + { + // Set the property value on the component + propertyInfo.SetValue(component, value); + // The component will re-render naturally when needed + } + }); } - - return true; // No filter allows it, so filter it out } - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The return value of method 'System.Object.GetType()' does not have matching annotations.", Justification = "Properties of rendered components are preserved through other means and won't get trimmed.")] - private static PropertyInfo? GetPropertyForScenarioFiltering([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string propertyName) - => type.GetProperty(propertyName); } From a8f65ed9964fa399181af2a910f9056c864f0caa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:10:27 +0000 Subject: [PATCH 07/15] Implement updated design for scenario-based persistent component state Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Web/src/PublicAPI.Unshipped.txt | 26 ++-- .../RestoreStateOnPrerenderingAttribute.cs | 10 +- .../RestoreStateOnReconnectionAttribute.cs | 10 +- ...pdateStateOnEnhancedNavigationAttribute.cs | 10 +- .../Web/src/WebPersistenceContext.cs | 63 ---------- .../Web/src/WebPersistenceFilter.cs | 38 ++++++ .../Web/src/WebPersistenceReason.cs | 26 ---- .../Web/src/WebPersistenceScenario.cs | 86 +++++++++++++ ...enarioBasedPersistentComponentStateTest.cs | 114 +++++++++++------- 9 files changed, 217 insertions(+), 166 deletions(-) delete mode 100644 src/Components/Web/src/WebPersistenceContext.cs create mode 100644 src/Components/Web/src/WebPersistenceFilter.cs delete mode 100644 src/Components/Web/src/WebPersistenceReason.cs create mode 100644 src/Components/Web/src/WebPersistenceScenario.cs diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 2079b8eec166..ea3a7a6c52f7 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -10,17 +10,15 @@ Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.ShouldRe 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.WebPersistenceContext -Microsoft.AspNetCore.Components.Web.WebPersistenceContext.WebPersistenceContext(Microsoft.AspNetCore.Components.Web.WebPersistenceReason reason, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode = null) -> void -Microsoft.AspNetCore.Components.Web.WebPersistenceContext.IsRecurring.get -> bool -Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reason.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason -Microsoft.AspNetCore.Components.Web.WebPersistenceContext.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode? -override Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Equals(object? obj) -> bool -override Microsoft.AspNetCore.Components.Web.WebPersistenceContext.GetHashCode() -> int -static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.EnhancedNavigation.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! -static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Prerendering.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! -static Microsoft.AspNetCore.Components.Web.WebPersistenceContext.Reconnection.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceContext! -Microsoft.AspNetCore.Components.Web.WebPersistenceReason -Microsoft.AspNetCore.Components.Web.WebPersistenceReason.EnhancedNavigation = 2 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason -Microsoft.AspNetCore.Components.Web.WebPersistenceReason.Prerendering = 1 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason -Microsoft.AspNetCore.Components.Web.WebPersistenceReason.Reconnection = 4 -> Microsoft.AspNetCore.Components.Web.WebPersistenceReason \ No newline at end of file +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 +override Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.GetHashCode() -> int +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.EnhancedNavigation(Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode = null) -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Prerendering() -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Reconnection() -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +Microsoft.AspNetCore.Components.Web.WebPersistenceFilter +Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.EnhancedNavigation.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.Prerendering.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.Reconnection.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs index 17c72613ee53..e5eb1549737c 100644 --- a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs +++ b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs @@ -9,15 +9,11 @@ namespace Microsoft.AspNetCore.Components.Web; [AttributeUsage(AttributeTargets.Property)] public sealed class RestoreStateOnPrerenderingAttribute : Attribute, IPersistentStateFilter { + internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.Prerendering; + /// public bool ShouldRestore(IPersistentComponentStateScenario scenario) { - if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.Prerendering } context) - { - return false; - } - - // Prerendering state restoration only applies to interactive modes - return context.RenderMode is InteractiveServerRenderMode or InteractiveWebAssemblyRenderMode; + return WebPersistenceFilter.ShouldRestore(scenario); } } \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs index b543bbbd1f0a..8ed981245dfe 100644 --- a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs +++ b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs @@ -9,15 +9,11 @@ namespace Microsoft.AspNetCore.Components.Web; [AttributeUsage(AttributeTargets.Property)] public sealed class RestoreStateOnReconnectionAttribute : Attribute, IPersistentStateFilter { + internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.Reconnection; + /// public bool ShouldRestore(IPersistentComponentStateScenario scenario) { - if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.Reconnection } context) - { - return false; - } - - // Reconnection only applies to server-side interactive mode - return context.RenderMode is InteractiveServerRenderMode; + return WebPersistenceFilter.ShouldRestore(scenario); } } \ No newline at end of file diff --git a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs index 2e6ccb0e919f..955642b56843 100644 --- a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs +++ b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs @@ -9,15 +9,11 @@ namespace Microsoft.AspNetCore.Components.Web; [AttributeUsage(AttributeTargets.Property)] public sealed class UpdateStateOnEnhancedNavigationAttribute : Attribute, IPersistentStateFilter { + internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.EnhancedNavigation; + /// public bool ShouldRestore(IPersistentComponentStateScenario scenario) { - if (scenario is not WebPersistenceContext { Reason: WebPersistenceReason.EnhancedNavigation } context) - { - return false; - } - - // Enhanced navigation only applies to interactive modes (Server or WebAssembly) - return context.RenderMode is InteractiveServerRenderMode or InteractiveWebAssemblyRenderMode; + return WebPersistenceFilter.ShouldRestore(scenario); } } \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceContext.cs b/src/Components/Web/src/WebPersistenceContext.cs deleted file mode 100644 index 59593ed4f779..000000000000 --- a/src/Components/Web/src/WebPersistenceContext.cs +++ /dev/null @@ -1,63 +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.Web; - -/// -/// Provides context for web-based persistent component state restoration. -/// -public sealed class WebPersistenceContext : IPersistentComponentStateScenario -{ - /// - /// Initializes a new instance of . - /// - /// The reason for this restoration. - /// The render mode context for this restoration. - public WebPersistenceContext(WebPersistenceReason reason, IComponentRenderMode? renderMode = null) - { - Reason = reason; - RenderMode = renderMode; - } - - /// - /// Gets the reason for this restoration. - /// - public WebPersistenceReason Reason { get; } - - /// - /// Gets the render mode context for this restoration. - /// - public IComponentRenderMode? RenderMode { get; } - - /// - public bool IsRecurring => Reason == WebPersistenceReason.EnhancedNavigation; - - /// - /// Gets a context for enhanced navigation restoration. - /// - public static WebPersistenceContext EnhancedNavigation => new(WebPersistenceReason.EnhancedNavigation); - - /// - /// Gets a context for prerendering restoration. - /// - public static WebPersistenceContext Prerendering => new(WebPersistenceReason.Prerendering); - - /// - /// Gets a context for reconnection restoration. - /// - public static WebPersistenceContext Reconnection => new(WebPersistenceReason.Reconnection); - - /// - public override bool Equals(object? obj) - { - return obj is WebPersistenceContext other && - Reason == other.Reason && - RenderMode?.GetType() == other.RenderMode?.GetType(); - } - - /// - public override int GetHashCode() - { - return HashCode.Combine(Reason, RenderMode?.GetType()); - } -} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceFilter.cs b/src/Components/Web/src/WebPersistenceFilter.cs new file mode 100644 index 000000000000..5fd868321f8f --- /dev/null +++ b/src/Components/Web/src/WebPersistenceFilter.cs @@ -0,0 +1,38 @@ +// 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.Web; + +/// +/// Provides filters for web-based persistent component state restoration scenarios. +/// +public sealed class WebPersistenceFilter : IPersistentStateFilter +{ + /// + /// Gets a filter that matches enhanced navigation scenarios. + /// + public static WebPersistenceFilter EnhancedNavigation { get; } = new(WebPersistenceScenario.ScenarioType.EnhancedNavigation); + + /// + /// Gets a filter that matches prerendering scenarios. + /// + public static WebPersistenceFilter Prerendering { get; } = new(WebPersistenceScenario.ScenarioType.Prerendering); + + /// + /// Gets a filter that matches reconnection scenarios. + /// + public static WebPersistenceFilter Reconnection { get; } = new(WebPersistenceScenario.ScenarioType.Reconnection); + + private readonly WebPersistenceScenario.ScenarioType _targetScenario; + + private WebPersistenceFilter(WebPersistenceScenario.ScenarioType targetScenario) + { + _targetScenario = targetScenario; + } + + /// + public bool ShouldRestore(IPersistentComponentStateScenario scenario) + { + return scenario is WebPersistenceScenario webScenario && webScenario.Type == _targetScenario; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceReason.cs b/src/Components/Web/src/WebPersistenceReason.cs deleted file mode 100644 index e3268bb3ded9..000000000000 --- a/src/Components/Web/src/WebPersistenceReason.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.Web; - -/// -/// Specifies the reason for persistent component state restoration in web scenarios. -/// -[Flags] -public enum WebPersistenceReason -{ - /// - /// State restoration during prerendering. - /// - Prerendering = 1, - - /// - /// State restoration during enhanced navigation. - /// - EnhancedNavigation = 2, - - /// - /// State restoration after server reconnection. - /// - Reconnection = 4 -} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceScenario.cs b/src/Components/Web/src/WebPersistenceScenario.cs new file mode 100644 index 000000000000..366f352ace83 --- /dev/null +++ b/src/Components/Web/src/WebPersistenceScenario.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. + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Provides scenario context for web-based persistent component state restoration. +/// +public sealed class WebPersistenceScenario : IPersistentComponentStateScenario +{ + /// + /// Gets the render mode context for this restoration. + /// + public IComponentRenderMode? RenderMode { get; } + + /// + /// Gets the scenario type. + /// + internal ScenarioType Type { get; } + + /// + bool IPersistentComponentStateScenario.IsRecurring => Type == ScenarioType.EnhancedNavigation; + + private WebPersistenceScenario(ScenarioType type, IComponentRenderMode? renderMode) + { + Type = type; + RenderMode = renderMode; + } + + /// + /// Creates a scenario for enhanced navigation with optional render mode. + /// + /// The render mode context for this restoration. + /// A new enhanced navigation scenario. + public static WebPersistenceScenario EnhancedNavigation(IComponentRenderMode? renderMode = null) + => new(ScenarioType.EnhancedNavigation, renderMode); + + /// + /// Creates a scenario for prerendering. + /// + /// A new prerendering scenario. + public static WebPersistenceScenario Prerendering() + => new(ScenarioType.Prerendering, renderMode: null); + + /// + /// Creates a scenario for server reconnection. + /// + /// A new reconnection scenario. + public static WebPersistenceScenario Reconnection() + => new(ScenarioType.Reconnection, renderMode: null); + + /// + public override bool Equals(object? obj) + { + return obj is WebPersistenceScenario other && + Type == other.Type && + RenderMode?.GetType() == other.RenderMode?.GetType(); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Type, RenderMode?.GetType()); + } + + /// + /// Defines the types of web persistence scenarios. + /// + internal enum ScenarioType + { + /// + /// State restoration during prerendering. + /// + Prerendering, + + /// + /// State restoration during enhanced navigation. + /// + EnhancedNavigation, + + /// + /// State restoration after server reconnection. + /// + Reconnection + } +} \ No newline at end of file diff --git a/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs index ccd35164be49..cb30bf5af933 100644 --- a/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs +++ b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs @@ -9,88 +9,118 @@ namespace Microsoft.AspNetCore.Components; public class ScenarioBasedPersistentComponentStateTest { [Fact] - public void WebPersistenceContext_Properties_SetCorrectly() + public void WebPersistenceScenario_Properties_SetCorrectly() { // Arrange & Act - var enhancedNavContext = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); - var prerenderingContext = new WebPersistenceContext(WebPersistenceReason.Prerendering); - var reconnectionContext = new WebPersistenceContext(WebPersistenceReason.Reconnection); + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); // Assert - Assert.Equal(WebPersistenceReason.EnhancedNavigation, enhancedNavContext.Reason); - Assert.True(enhancedNavContext.IsRecurring); + Assert.Equal(WebPersistenceScenario.ScenarioType.EnhancedNavigation, enhancedNavScenario.Type); + Assert.True(((IPersistentComponentStateScenario)enhancedNavScenario).IsRecurring); - Assert.Equal(WebPersistenceReason.Prerendering, prerenderingContext.Reason); - Assert.False(prerenderingContext.IsRecurring); + Assert.Equal(WebPersistenceScenario.ScenarioType.Prerendering, prerenderingScenario.Type); + Assert.False(((IPersistentComponentStateScenario)prerenderingScenario).IsRecurring); - Assert.Equal(WebPersistenceReason.Reconnection, reconnectionContext.Reason); - Assert.False(reconnectionContext.IsRecurring); + Assert.Equal(WebPersistenceScenario.ScenarioType.Reconnection, reconnectionScenario.Type); + Assert.False(((IPersistentComponentStateScenario)reconnectionScenario).IsRecurring); } [Fact] - public void WebPersistenceContext_StaticProperties_ReturnCorrectInstances() + public void WebPersistenceScenario_EnhancedNavigation_WithRenderMode() { + // Arrange + var serverRenderMode = new InteractiveServerRenderMode(); + var wasmRenderMode = new InteractiveWebAssemblyRenderMode(); + // Act - var enhancedNav = WebPersistenceContext.EnhancedNavigation; - var prerendering = WebPersistenceContext.Prerendering; - var reconnection = WebPersistenceContext.Reconnection; + var serverScenario = WebPersistenceScenario.EnhancedNavigation(serverRenderMode); + var wasmScenario = WebPersistenceScenario.EnhancedNavigation(wasmRenderMode); + var defaultScenario = WebPersistenceScenario.EnhancedNavigation(); // Assert - Assert.Equal(WebPersistenceReason.EnhancedNavigation, enhancedNav.Reason); - Assert.Equal(WebPersistenceReason.Prerendering, prerendering.Reason); - Assert.Equal(WebPersistenceReason.Reconnection, reconnection.Reason); + Assert.Equal(serverRenderMode, serverScenario.RenderMode); + Assert.Equal(wasmRenderMode, wasmScenario.RenderMode); + Assert.Null(defaultScenario.RenderMode); + } + + [Fact] + public void WebPersistenceScenario_Equals_WorksCorrectly() + { + // Arrange + var scenario1 = WebPersistenceScenario.EnhancedNavigation(); + var scenario2 = WebPersistenceScenario.EnhancedNavigation(); + var scenario3 = WebPersistenceScenario.Prerendering(); + + // Act & Assert + Assert.True(scenario1.Equals(scenario2)); + Assert.False(scenario1.Equals(scenario3)); + Assert.False(scenario1.Equals(null)); } [Fact] - public void WebPersistenceContext_Equals_WorksCorrectly() + public void WebPersistenceScenario_GetHashCode_WorksCorrectly() { // Arrange - var context1 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); - var context2 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); - var context3 = new WebPersistenceContext(WebPersistenceReason.Prerendering); + var scenario1 = WebPersistenceScenario.EnhancedNavigation(); + var scenario2 = WebPersistenceScenario.EnhancedNavigation(); + var scenario3 = WebPersistenceScenario.Prerendering(); // Act & Assert - Assert.True(context1.Equals(context2)); - Assert.False(context1.Equals(context3)); - Assert.False(context1.Equals(null)); + Assert.Equal(scenario1.GetHashCode(), scenario2.GetHashCode()); + Assert.NotEqual(scenario1.GetHashCode(), scenario3.GetHashCode()); } [Fact] - public void WebPersistenceContext_GetHashCode_WorksCorrectly() + public void WebPersistenceFilter_ShouldRestore_WorksCorrectly() { // Arrange - var context1 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); - var context2 = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation); - var context3 = new WebPersistenceContext(WebPersistenceReason.Prerendering); + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); + + var enhancedNavFilter = WebPersistenceFilter.EnhancedNavigation; + var prerenderingFilter = WebPersistenceFilter.Prerendering; + var reconnectionFilter = WebPersistenceFilter.Reconnection; // Act & Assert - Assert.Equal(context1.GetHashCode(), context2.GetHashCode()); - Assert.NotEqual(context1.GetHashCode(), context3.GetHashCode()); + Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(prerenderingScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(reconnectionScenario)); + + Assert.False(prerenderingFilter.ShouldRestore(enhancedNavScenario)); + Assert.True(prerenderingFilter.ShouldRestore(prerenderingScenario)); + Assert.False(prerenderingFilter.ShouldRestore(reconnectionScenario)); + + Assert.False(reconnectionFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(reconnectionFilter.ShouldRestore(prerenderingScenario)); + Assert.True(reconnectionFilter.ShouldRestore(reconnectionScenario)); } [Fact] public void FilterAttributes_ShouldRestore_WorksCorrectly() { // Arrange - var enhancedNavContext = new WebPersistenceContext(WebPersistenceReason.EnhancedNavigation, new InteractiveServerRenderMode()); - var prerenderingContext = new WebPersistenceContext(WebPersistenceReason.Prerendering, new InteractiveServerRenderMode()); - var reconnectionContext = new WebPersistenceContext(WebPersistenceReason.Reconnection, new InteractiveServerRenderMode()); + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); var enhancedNavFilter = new UpdateStateOnEnhancedNavigationAttribute(); var prerenderingFilter = new RestoreStateOnPrerenderingAttribute(); var reconnectionFilter = new RestoreStateOnReconnectionAttribute(); // Act & Assert - Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavContext)); - Assert.False(enhancedNavFilter.ShouldRestore(prerenderingContext)); - Assert.False(enhancedNavFilter.ShouldRestore(reconnectionContext)); + Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(prerenderingScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(reconnectionScenario)); - Assert.False(prerenderingFilter.ShouldRestore(enhancedNavContext)); - Assert.True(prerenderingFilter.ShouldRestore(prerenderingContext)); - Assert.False(prerenderingFilter.ShouldRestore(reconnectionContext)); + Assert.False(prerenderingFilter.ShouldRestore(enhancedNavScenario)); + Assert.True(prerenderingFilter.ShouldRestore(prerenderingScenario)); + Assert.False(prerenderingFilter.ShouldRestore(reconnectionScenario)); - Assert.False(reconnectionFilter.ShouldRestore(enhancedNavContext)); - Assert.False(reconnectionFilter.ShouldRestore(prerenderingContext)); - Assert.True(reconnectionFilter.ShouldRestore(reconnectionContext)); + Assert.False(reconnectionFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(reconnectionFilter.ShouldRestore(prerenderingScenario)); + Assert.True(reconnectionFilter.ShouldRestore(reconnectionScenario)); } } \ No newline at end of file From 757ba83dde8f1a90cd9a085f799275fea49fb72c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:42:27 +0000 Subject: [PATCH 08/15] Remove scenario-based RegisterOnRestoring method per review feedback Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/PersistentComponentState.cs | 35 +++++-------------- .../Components/src/PublicAPI.Unshipped.txt | 2 +- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index d26d00068a86..d6a5286c932e 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -162,47 +162,30 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial } /// - /// Registers a callback to be invoked when state is restored for a specific scenario. - /// If state for the scenario is already available, the callback is invoked immediately. + /// Registers a callback to be invoked when state is restored and the filter allows the current scenario. /// - /// The scenario for which to register the callback. + /// The filter to determine if the callback should be invoked for a scenario. /// The callback to invoke during restoration. /// A subscription that can be disposed to unregister the callback. public RestoringComponentStateSubscription RegisterOnRestoring( - IPersistentComponentStateScenario scenario, + IPersistentStateFilter filter, Action callback) { - ArgumentNullException.ThrowIfNull(scenario); + ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(callback); - var registration = new RestoreComponentStateRegistration(scenario, callback); + // Create a wrapper scenario that uses the filter + var filterScenario = new FilterWrapperScenario(filter); + var registration = new RestoreComponentStateRegistration(filterScenario, callback); _restoringCallbacks.Add(registration); // If we already have a current scenario and it matches, invoke immediately - if (CurrentScenario != null && ShouldInvokeCallback(scenario, CurrentScenario)) + if (CurrentScenario != null && ShouldInvokeCallback(filterScenario, CurrentScenario)) { callback(); } - return new RestoringComponentStateSubscription(_restoringCallbacks, scenario, callback); - } - - /// - /// Registers a callback to be invoked when state is restored and the filter allows the current scenario. - /// - /// The filter to determine if the callback should be invoked for a scenario. - /// The callback to invoke during restoration. - /// A subscription that can be disposed to unregister the callback. - public RestoringComponentStateSubscription RegisterOnRestoring( - IPersistentStateFilter filter, - Action callback) - { - ArgumentNullException.ThrowIfNull(filter); - ArgumentNullException.ThrowIfNull(callback); - - // Create a wrapper scenario that uses the filter - var filterScenario = new FilterWrapperScenario(filter); - return RegisterOnRestoring(filterScenario, callback); + return new RestoringComponentStateSubscription(_restoringCallbacks, filterScenario, callback); } /// diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index f72f5433b17e..94c2438ca8a6 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -26,7 +26,7 @@ Microsoft.AspNetCore.Components.IPersistentComponentStateScenario.IsRecurring.ge Microsoft.AspNetCore.Components.IPersistentStateFilter Microsoft.AspNetCore.Components.IPersistentStateFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool Microsoft.AspNetCore.Components.PersistentComponentState.CurrentScenario.get -> Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? -Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +*REMOVED*Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentStateFilter! filter, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription Microsoft.AspNetCore.Components.RestoringComponentStateSubscription Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.RestoringComponentStateSubscription() -> void From 5d2bb55b4e0ee70ebc8a39bbf910deefec76bb01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:53:25 +0000 Subject: [PATCH 09/15] Address review feedback: Use filters directly instead of scenarios in registrations Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/PersistentComponentState.cs | 58 ++----------------- .../ComponentStatePersistenceManager.cs | 7 +-- .../src/RestoreComponentStateRegistration.cs | 6 +- .../RestoringComponentStateSubscription.cs | 10 ++-- ...omPersistentComponentStateValueProvider.cs | 39 ++++++++----- 5 files changed, 40 insertions(+), 80 deletions(-) diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index d6a5286c932e..b3145861027e 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -174,45 +174,16 @@ public RestoringComponentStateSubscription RegisterOnRestoring( ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(callback); - // Create a wrapper scenario that uses the filter - var filterScenario = new FilterWrapperScenario(filter); - var registration = new RestoreComponentStateRegistration(filterScenario, callback); + var registration = new RestoreComponentStateRegistration(filter, callback); _restoringCallbacks.Add(registration); - // If we already have a current scenario and it matches, invoke immediately - if (CurrentScenario != null && ShouldInvokeCallback(filterScenario, CurrentScenario)) + // If we already have a current scenario and the filter matches, invoke immediately + if (CurrentScenario != null && filter.ShouldRestore(CurrentScenario)) { callback(); } - return new RestoringComponentStateSubscription(_restoringCallbacks, filterScenario, callback); - } - - /// - /// A scenario wrapper that uses a filter to determine if it should match the current scenario. - /// - private sealed class FilterWrapperScenario : IPersistentComponentStateScenario - { - private readonly IPersistentStateFilter _filter; - - public FilterWrapperScenario(IPersistentStateFilter filter) - { - _filter = filter; - } - - public bool IsRecurring => true; // Filter-based scenarios can be recurring - - public bool ShouldMatchScenario(IPersistentComponentStateScenario currentScenario) - { - return _filter.ShouldRestore(currentScenario); - } - - public override bool Equals(object? obj) - { - return obj is FilterWrapperScenario other && ReferenceEquals(_filter, other._filter); - } - - public override int GetHashCode() => _filter.GetHashCode(); + return new RestoringComponentStateSubscription(_restoringCallbacks, filter, callback); } /// @@ -244,32 +215,13 @@ private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario { var registration = _restoringCallbacks[i]; - if (ShouldInvokeCallback(registration.Scenario, scenario)) + if (registration.Filter.ShouldRestore(scenario)) { registration.Callback(); - - // Remove non-recurring callbacks after invocation - if (!registration.Scenario.IsRecurring) - { - _restoringCallbacks.RemoveAt(i); - } } } } - private static bool ShouldInvokeCallback(IPersistentComponentStateScenario callbackScenario, IPersistentComponentStateScenario currentScenario) - { - // Special handling for filter wrapper scenarios - if (callbackScenario is FilterWrapperScenario filterWrapper) - { - return filterWrapper.ShouldMatchScenario(currentScenario); - } - - // For regular scenarios, match by type and properties - return callbackScenario.GetType() == currentScenario.GetType() && - callbackScenario.Equals(currentScenario); - } - private bool TryTake(string key, out byte[]? value) { ArgumentNullException.ThrowIfNull(key); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 238234cc4dc2..e9bba77856db 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -17,7 +17,7 @@ public class ComponentStatePersistenceManager private bool _stateIsPersisted; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); - private int _restoreCallCount; + private bool _isFirstRestore = true; /// /// Initializes a new instance of . @@ -72,12 +72,11 @@ public async Task RestoreStateAsync( { var data = await store.GetPersistedStateAsync(); - _restoreCallCount++; - - if (_restoreCallCount == 1) + if (_isFirstRestore) { // First-time initialization State.InitializeExistingState(data); + _isFirstRestore = false; } else { diff --git a/src/Components/Components/src/RestoreComponentStateRegistration.cs b/src/Components/Components/src/RestoreComponentStateRegistration.cs index a394c749de4e..669ad6fec00a 100644 --- a/src/Components/Components/src/RestoreComponentStateRegistration.cs +++ b/src/Components/Components/src/RestoreComponentStateRegistration.cs @@ -8,12 +8,12 @@ namespace Microsoft.AspNetCore.Components; /// internal readonly struct RestoreComponentStateRegistration { - public RestoreComponentStateRegistration(IPersistentComponentStateScenario scenario, Action callback) + public RestoreComponentStateRegistration(IPersistentStateFilter filter, Action callback) { - Scenario = scenario; + Filter = filter; Callback = callback; } - public IPersistentComponentStateScenario Scenario { get; } + public IPersistentStateFilter Filter { get; } public Action Callback { get; } } \ No newline at end of file diff --git a/src/Components/Components/src/RestoringComponentStateSubscription.cs b/src/Components/Components/src/RestoringComponentStateSubscription.cs index ab14ec76926b..eb2bd115ba24 100644 --- a/src/Components/Components/src/RestoringComponentStateSubscription.cs +++ b/src/Components/Components/src/RestoringComponentStateSubscription.cs @@ -9,28 +9,28 @@ namespace Microsoft.AspNetCore.Components; public readonly struct RestoringComponentStateSubscription : IDisposable { private readonly List? _callbacks; - private readonly IPersistentComponentStateScenario? _scenario; + private readonly IPersistentStateFilter? _filter; private readonly Action? _callback; internal RestoringComponentStateSubscription( List callbacks, - IPersistentComponentStateScenario scenario, + IPersistentStateFilter filter, Action callback) { _callbacks = callbacks; - _scenario = scenario; + _filter = filter; _callback = callback; } /// public void Dispose() { - if (_callbacks != null && _scenario != null && _callback != null) + if (_callbacks != null && _filter != null && _callback != null) { for (int i = _callbacks.Count - 1; i >= 0; i--) { var registration = _callbacks[i]; - if (ReferenceEquals(registration.Scenario, _scenario) && ReferenceEquals(registration.Callback, _callback)) + if (ReferenceEquals(registration.Filter, _filter) && ReferenceEquals(registration.Callback, _callback)) { _callbacks.RemoveAt(i); break; diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index cb4431a07430..9977bba00ec2 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -21,6 +21,7 @@ internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(P private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); private readonly Dictionary _subscriptions = []; + private readonly Dictionary<(ComponentState, string), object?> _scenarioRestoredValues = []; public bool IsFixed => false; // For testing purposes only @@ -40,8 +41,15 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { var componentState = (ComponentState)key!; + + // Check if we have a scenario-restored value first + var valueKey = (componentState, parameterInfo.PropertyName); + if (_scenarioRestoredValues.TryGetValue(valueKey, out var scenarioValue)) + { + return scenarioValue; + } + var storageKey = ComputeKey(componentState, parameterInfo.PropertyName); - return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } @@ -293,27 +301,28 @@ private void RegisterScenarioRestorationCallback(ComponentState subscriber, in C { // Check for IPersistentStateFilter attributes var filterAttributes = propertyInfo.GetCustomAttributes(typeof(IPersistentStateFilter), inherit: true); - if (filterAttributes.Length == 0) + + // Register restoration callbacks for each filter + foreach (IPersistentStateFilter filter in filterAttributes) { - return; // No filters, no scenario-based restoration needed + RegisterRestorationCallback(subscriber, parameterInfo, filter); } + } + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicConstructors', 'DynamicallyAccessedMemberTypes.PublicFields', 'DynamicallyAccessedMemberTypes.PublicProperties' in call to target method. The return value of the source method does not have matching annotations.", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + private void RegisterRestorationCallback(ComponentState subscriber, in CascadingParameterInfo parameterInfo, IPersistentStateFilter filter) + { var storageKey = ComputeKey(subscriber, parameterInfo.PropertyName); var propertyType = parameterInfo.PropertyType; - var component = subscriber.Component; + var valueKey = (subscriber, parameterInfo.PropertyName); - // Register restoration callbacks for each filter - foreach (IPersistentStateFilter filter in filterAttributes) + state.RegisterOnRestoring(filter, () => { - state.RegisterOnRestoring(filter, () => + if (state.TryTakeFromJson(storageKey, propertyType, out var value)) { - if (state.TryTakeFromJson(storageKey, propertyType, out var value)) - { - // Set the property value on the component - propertyInfo.SetValue(component, value); - // The component will re-render naturally when needed - } - }); - } + _scenarioRestoredValues[valueKey] = value; + } + }); } } From 59e2436af9302d964041414ba6e371375bf42b05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Jul 2025 13:06:58 +0000 Subject: [PATCH 10/15] Add comprehensive tests for scenario-based RestoreStateAsync in ComponentStatePersistenceManager Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../ComponentStatePersistenceManagerTest.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 4e5708c10f4d..b6cba536f0d7 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -78,6 +78,132 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() await Assert.ThrowsAsync(() => persistenceManager.RestoreStateAsync(store)); } + [Fact] + public async Task RestoreStateAsync_WithScenario_FirstCallInitializesState() + { + // Arrange + var data = new byte[] { 0, 1, 2, 3, 4 }; + var state = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var scenario = new TestScenario(true); + + // Act + await persistenceManager.RestoreStateAsync(store, scenario); + + // Assert + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.Equal(data, retrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithoutScenario_FirstCallInitializesState() + { + // Arrange + var data = new byte[] { 0, 1, 2, 3, 4 }; + var state = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // Act + await persistenceManager.RestoreStateAsync(store, scenario: null); + + // Assert + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.Equal(data, retrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithScenario_SecondCallUpdatesExistingState() + { + // Arrange + var initialData = new byte[] { 0, 1, 2, 3, 4 }; + var updatedData = new byte[] { 5, 6, 7, 8, 9 }; + var initialState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(initialData) + }; + var updatedState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(updatedData) + }; + var initialStore = new TestStore(initialState); + var updatedStore = new TestStore(updatedState); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var scenario = new TestScenario(true); + + // Act - First call initializes state + await persistenceManager.RestoreStateAsync(initialStore, scenario); + + // Consume the initial state to verify it was loaded + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var initialRetrieved)); + Assert.Equal(initialData, initialRetrieved); + + // Act - Second call with scenario should update existing state + await persistenceManager.RestoreStateAsync(updatedStore, scenario); + + // Assert - Should be able to retrieve updated data + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var updatedRetrieved)); + Assert.Equal(updatedData, updatedRetrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithoutScenario_SecondCallThrowsInvalidOperationException() + { + // Arrange + var initialData = new byte[] { 0, 1, 2, 3, 4 }; + var initialState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(initialData) + }; + var store = new TestStore(initialState); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // Act - First call initializes state + await persistenceManager.RestoreStateAsync(store, scenario: null); + + // Assert - Second call without scenario should throw + await Assert.ThrowsAsync(() => + persistenceManager.RestoreStateAsync(store, scenario: null)); + } + + [Fact] + public async Task RestoreStateAsync_WithScenario_RestoresServicesRegistry() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddScoped(sp => new TestStore([])) + .AddPersistentService(new TestRenderMode()) + .BuildServiceProvider(); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.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

+ @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