From bae34895dee00515d7ff1a7378eae5f260e5673a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:44:03 +0000 Subject: [PATCH 1/7] Initial plan for issue From 2519ee84df2e78e7441aa8001b3bdef830a0180f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:07:46 +0000 Subject: [PATCH 2/7] Implement core persistence reason interfaces and filtering logic Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/src/IPersistenceReason.cs | 15 ++++++ .../src/IPersistenceReasonFilter.cs | 17 ++++++ .../src/PersistComponentStateRegistration.cs | 5 +- .../Components/src/PersistReasonFilter.cs | 34 ++++++++++++ .../Components/src/PersistenceReasons.cs | 31 +++++++++++ .../src/PersistentComponentState.cs | 24 +++++++++ .../ComponentStatePersistenceManager.cs | 53 ++++++++++++++++++- .../Components/src/PublicAPI.Unshipped.txt | 18 +++++++ .../Web/src/PersistenceReasonFilters.cs | 21 ++++++++ 9 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 src/Components/Components/src/IPersistenceReason.cs create mode 100644 src/Components/Components/src/IPersistenceReasonFilter.cs create mode 100644 src/Components/Components/src/PersistReasonFilter.cs create mode 100644 src/Components/Components/src/PersistenceReasons.cs create mode 100644 src/Components/Web/src/PersistenceReasonFilters.cs diff --git a/src/Components/Components/src/IPersistenceReason.cs b/src/Components/Components/src/IPersistenceReason.cs new file mode 100644 index 000000000000..576a18e93fc5 --- /dev/null +++ b/src/Components/Components/src/IPersistenceReason.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Represents a reason for persisting component state. +/// +public interface IPersistenceReason +{ + /// + /// Gets a value indicating whether state should be persisted by default for this reason. + /// + bool PersistByDefault { get; } +} \ No newline at end of file diff --git a/src/Components/Components/src/IPersistenceReasonFilter.cs b/src/Components/Components/src/IPersistenceReasonFilter.cs new file mode 100644 index 000000000000..49b67c5bb524 --- /dev/null +++ b/src/Components/Components/src/IPersistenceReasonFilter.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; + +/// +/// Filters component state persistence based on the reason for persistence. +/// +public interface IPersistenceReasonFilter +{ + /// + /// Determines whether state should be persisted for the given reason. + /// + /// The reason for persistence. + /// true to persist state, false to skip persistence, or null to defer to other filters or default behavior. + bool? ShouldPersist(IPersistenceReason reason); +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistComponentStateRegistration.cs b/src/Components/Components/src/PersistComponentStateRegistration.cs index 0f874970f4e1..25a165cdee6c 100644 --- a/src/Components/Components/src/PersistComponentStateRegistration.cs +++ b/src/Components/Components/src/PersistComponentStateRegistration.cs @@ -5,9 +5,12 @@ namespace Microsoft.AspNetCore.Components; internal readonly struct PersistComponentStateRegistration( Func callback, - IComponentRenderMode? renderMode) + IComponentRenderMode? renderMode, + IReadOnlyList? reasonFilters = null) { public Func Callback { get; } = callback; public IComponentRenderMode? RenderMode { get; } = renderMode; + + public IReadOnlyList? ReasonFilters { get; } = reasonFilters; } diff --git a/src/Components/Components/src/PersistReasonFilter.cs b/src/Components/Components/src/PersistReasonFilter.cs new file mode 100644 index 000000000000..10268df20b67 --- /dev/null +++ b/src/Components/Components/src/PersistReasonFilter.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; + +/// +/// Base class for filtering component state persistence based on specific persistence reasons. +/// +/// The type of persistence reason this filter handles. +public abstract class PersistReasonFilter : Attribute, IPersistenceReasonFilter + where TReason : IPersistenceReason +{ + private readonly bool _persist; + + /// + /// Initializes a new instance of the class. + /// + /// Whether to persist state for the specified reason type. + protected PersistReasonFilter(bool persist) + { + _persist = persist; + } + + /// + public bool? ShouldPersist(IPersistenceReason reason) + { + if (reason is TReason) + { + return _persist; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistenceReasons.cs b/src/Components/Components/src/PersistenceReasons.cs new file mode 100644 index 000000000000..03ab26ad33f6 --- /dev/null +++ b/src/Components/Components/src/PersistenceReasons.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Represents persistence during prerendering. +/// +public class PersistOnPrerendering : IPersistenceReason +{ + /// + public bool PersistByDefault { get; } = true; +} + +/// +/// Represents persistence during enhanced navigation. +/// +public class PersistOnEnhancedNavigation : IPersistenceReason +{ + /// + public bool PersistByDefault { get; } +} + +/// +/// Represents persistence when a circuit is paused. +/// +public class PersistOnCircuitPause : IPersistenceReason +{ + /// + public bool PersistByDefault { get; } = true; +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a3dd2fdddc81..428e2e75e579 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -45,6 +45,30 @@ internal void InitializeExistingState(IDictionary existingState) public PersistingComponentStateSubscription RegisterOnPersisting(Func callback) => RegisterOnPersisting(callback, null); + /// + /// Register a callback to persist the component state when the application is about to be paused. + /// Registered callbacks can use this opportunity to persist their state so that it can be retrieved when the application resumes. + /// + /// The callback to invoke when the application is being paused. + /// + /// Filters to control when the callback should be invoked based on the persistence reason. + /// A subscription that can be used to unregister the callback when disposed. + public PersistingComponentStateSubscription RegisterOnPersisting(Func callback, IComponentRenderMode? renderMode, IReadOnlyList? reasonFilters) + { + ArgumentNullException.ThrowIfNull(callback); + + if (PersistingState) + { + throw new InvalidOperationException("Registering a callback while persisting state is not allowed."); + } + + var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode, reasonFilters); + + _registeredCallbacks.Add(persistenceCallback); + + return new PersistingComponentStateSubscription(_registeredCallbacks, persistenceCallback); + } + /// /// Register a callback to persist the component state when the application is about to be paused. /// Registered callbacks can use this opportunity to persist their state so that it can be retrieved when the application resumes. diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 72c1ca666411..a33eb162e0fd 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -68,6 +68,16 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) /// The that components are being rendered. /// A that will complete when the state has been restored. public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer) + => PersistStateAsync(store, renderer, new PersistOnPrerendering()); + + /// + /// Persists the component application state into the given . + /// + /// The to restore the application state from. + /// The that components are being rendered. + /// The reason for persisting the state. + /// A that will complete when the state has been restored. + public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer, IPersistenceReason persistenceReason) { if (_stateIsPersisted) { @@ -113,7 +123,7 @@ async Task PauseAndPersistState() async Task TryPersistState(IPersistentComponentStateStore store) { - if (!await TryPauseAsync(store)) + if (!await TryPauseAsync(store, persistenceReason)) { _currentState.Clear(); return false; @@ -159,7 +169,7 @@ private void InferRenderModes(Renderer renderer) var componentRenderMode = renderer.GetComponentRenderMode(component); if (componentRenderMode != null) { - _registeredCallbacks[i] = new PersistComponentStateRegistration(registration.Callback, componentRenderMode); + _registeredCallbacks[i] = new PersistComponentStateRegistration(registration.Callback, componentRenderMode, registration.ReasonFilters); } else { @@ -177,6 +187,9 @@ private void InferRenderModes(Renderer renderer) } internal Task TryPauseAsync(IPersistentComponentStateStore store) + => TryPauseAsync(store, new PersistOnPrerendering()); + + internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason persistenceReason) { List>? pendingCallbackTasks = null; @@ -199,6 +212,27 @@ internal Task TryPauseAsync(IPersistentComponentStateStore store) continue; } + // Evaluate reason filters to determine if the callback should be executed for this persistence reason + if (registration.ReasonFilters != null) + { + var shouldPersist = EvaluateReasonFilters(registration.ReasonFilters, persistenceReason); + if (shouldPersist.HasValue && !shouldPersist.Value) + { + // Filters explicitly indicate not to persist for this reason + continue; + } + else if (!shouldPersist.HasValue && !persistenceReason.PersistByDefault) + { + // No filter matched and default is not to persist + continue; + } + } + else if (!persistenceReason.PersistByDefault) + { + // No filters defined and default is not to persist + continue; + } + var result = TryExecuteCallback(registration.Callback, _logger); if (!result.IsCompletedSuccessfully) { @@ -271,4 +305,19 @@ static async Task AnyTaskFailed(List> pendingCallbackTasks) return true; } } + + private static bool? EvaluateReasonFilters(IReadOnlyList reasonFilters, IPersistenceReason persistenceReason) + { + foreach (var reasonFilter in reasonFilters) + { + var shouldPersist = reasonFilter.ShouldPersist(persistenceReason); + if (shouldPersist.HasValue) + { + return shouldPersist.Value; + } + } + + // No filter matched + return null; + } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 07e51aca6bd3..0a7ae6e29c06 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -10,9 +10,27 @@ Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(stri Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void +Microsoft.AspNetCore.Components.IPersistenceReason +Microsoft.AspNetCore.Components.IPersistenceReason.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.IPersistenceReasonFilter +Microsoft.AspNetCore.Components.IPersistenceReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? +Microsoft.AspNetCore.Components.PersistOnCircuitPause +Microsoft.AspNetCore.Components.PersistOnCircuitPause.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.PersistOnCircuitPause.PersistOnCircuitPause() -> void +Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation +Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation.PersistOnEnhancedNavigation() -> void +Microsoft.AspNetCore.Components.PersistOnPrerendering +Microsoft.AspNetCore.Components.PersistOnPrerendering.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.PersistOnPrerendering.PersistOnPrerendering() -> void +Microsoft.AspNetCore.Components.PersistReasonFilter +Microsoft.AspNetCore.Components.PersistReasonFilter.PersistReasonFilter(bool persist) -> void +Microsoft.AspNetCore.Components.PersistReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Web/src/PersistenceReasonFilters.cs b/src/Components/Web/src/PersistenceReasonFilters.cs new file mode 100644 index 000000000000..ebb485bc821b --- /dev/null +++ b/src/Components/Web/src/PersistenceReasonFilters.cs @@ -0,0 +1,21 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Filter that controls whether component state should be persisted during prerendering. +/// +public class PersistOnPrerenderingFilter(bool persist = true) : PersistReasonFilter(persist); + +/// +/// Filter that controls whether component state should be persisted during enhanced navigation. +/// +public class PersistOnEnhancedNavigationFilter(bool persist = true) : PersistReasonFilter(persist); + +/// +/// Filter that controls whether component state should be persisted when a circuit is paused. +/// +public class PersistOnCircuitPauseFilter(bool persist = true) : PersistReasonFilter(persist); \ No newline at end of file From 0fddad7ba9ef97846c695c535bb4e8dd1c128cf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:13:40 +0000 Subject: [PATCH 3/7] Add tests and update calling code with persistence reasons Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/src/PublicAPI.Unshipped.txt | 2 + .../ComponentStatePersistenceManagerTest.cs | 92 ++++++++++++++++++ .../EndpointHtmlRenderer.PrerenderingState.cs | 4 +- .../src/Circuits/CircuitPersistenceManager.cs | 2 +- .../Web/src/PublicAPI.Unshipped.txt | 6 ++ .../Web/test/PersistenceReasonFiltersTest.cs | 94 +++++++++++++++++++ 6 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/Components/Web/test/PersistenceReasonFiltersTest.cs diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 0a7ae6e29c06..3f9625c1ab42 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -11,6 +11,7 @@ Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void @@ -31,6 +32,7 @@ Microsoft.AspNetCore.Components.PersistReasonFilter Microsoft.AspNetCore.Components.PersistReasonFilter.PersistReasonFilter(bool persist) -> void Microsoft.AspNetCore.Components.PersistReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 4e5708c10f4d..abead3e1940b 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -418,6 +418,98 @@ IEnumerator IEnumerable.GetEnumerator() } } + [Fact] + public void PersistenceReasons_HaveCorrectDefaults() + { + // Arrange & Act + var prerenderingReason = new PersistOnPrerendering(); + var enhancedNavReason = new PersistOnEnhancedNavigation(); + var circuitPauseReason = new PersistOnCircuitPause(); + + // Assert + Assert.True(prerenderingReason.PersistByDefault); + Assert.False(enhancedNavReason.PersistByDefault); + Assert.True(circuitPauseReason.PersistByDefault); + } + + [Fact] + public async Task PersistStateAsync_RespectsReasonFilters() + { + // Arrange + var logger = NullLogger.Instance; + var manager = new ComponentStatePersistenceManager(logger); + var renderer = new TestRenderer(); + var store = new TestStore([]); + var callbackExecuted = false; + + // Register callback with filter that blocks enhanced navigation + var filters = new List + { + new TestPersistenceReasonFilter(false) + }; + + manager.State.RegisterOnPersisting(() => + { + callbackExecuted = true; + return Task.CompletedTask; + }, new TestRenderMode(), filters); + + // Act - persist with enhanced navigation reason + await manager.PersistStateAsync(store, renderer, new PersistOnEnhancedNavigation()); + + // Assert - callback should not be executed + Assert.False(callbackExecuted); + } + + [Fact] + public async Task PersistStateAsync_AllowsWhenFilterMatches() + { + // Arrange + var logger = NullLogger.Instance; + var manager = new ComponentStatePersistenceManager(logger); + var renderer = new TestRenderer(); + var store = new TestStore([]); + var callbackExecuted = false; + + // Register callback with filter that allows prerendering + var filters = new List + { + new TestPersistenceReasonFilter(true) + }; + + manager.State.RegisterOnPersisting(() => + { + callbackExecuted = true; + return Task.CompletedTask; + }, new TestRenderMode(), filters); + + // Act - persist with prerendering reason + await manager.PersistStateAsync(store, renderer, new PersistOnPrerendering()); + + // Assert - callback should be executed + Assert.True(callbackExecuted); + } + + private class TestPersistenceReasonFilter : IPersistenceReasonFilter + where TReason : IPersistenceReason + { + private readonly bool _allow; + + public TestPersistenceReasonFilter(bool allow) + { + _allow = allow; + } + + public bool? ShouldPersist(IPersistenceReason reason) + { + if (reason is TReason) + { + return _allow; + } + return null; + } + } + private class TestRenderMode : IComponentRenderMode { } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index 161967076ec5..56acf674a27e 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs @@ -50,7 +50,7 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht if (store != null) { - await manager.PersistStateAsync(store, this); + await manager.PersistStateAsync(store, this, new PersistOnPrerendering()); return store switch { ProtectedPrerenderComponentApplicationStore protectedStore => new ComponentStateHtmlContent(protectedStore, null), @@ -80,7 +80,7 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht var webAssembly = new CopyOnlyStore(); store = new CompositeStore(server, auto, webAssembly); - await manager.PersistStateAsync(store, this); + await manager.PersistStateAsync(store, this, new PersistOnPrerendering()); foreach (var kvp in auto.Saved) { diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs index 285bf5a02eef..b91f4567269e 100644 --- a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -28,7 +28,7 @@ public async Task PauseCircuitAsync(CircuitHost circuit, bool saveStateToClient collector.PersistRootComponents, RenderMode.InteractiveServer); - await persistenceManager.PersistStateAsync(collector, renderer); + await persistenceManager.PersistStateAsync(collector, renderer, new PersistOnCircuitPause()); if (saveStateToClient) { diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 99365e10804e..7ffec55b7ef1 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,3 +1,9 @@ #nullable enable Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! +Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter +Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter.PersistOnCircuitPauseFilter(bool persist = true) -> void +Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter +Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter.PersistOnEnhancedNavigationFilter(bool persist = true) -> void +Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter +Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter.PersistOnPrerenderingFilter(bool persist = true) -> void virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file diff --git a/src/Components/Web/test/PersistenceReasonFiltersTest.cs b/src/Components/Web/test/PersistenceReasonFiltersTest.cs new file mode 100644 index 000000000000..f98f587a5205 --- /dev/null +++ b/src/Components/Web/test/PersistenceReasonFiltersTest.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Web; + +public class PersistenceReasonFiltersTest +{ + [Fact] + public void PersistOnPrerenderingFilter_AllowsByDefault() + { + // Arrange + var filter = new PersistOnPrerenderingFilter(); + var reason = new PersistOnPrerendering(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnPrerenderingFilter_CanBlock() + { + // Arrange + var filter = new PersistOnPrerenderingFilter(persist: false); + var reason = new PersistOnPrerendering(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.False(result); + } + + [Fact] + public void PersistOnEnhancedNavigationFilter_AllowsByDefault() + { + // Arrange + var filter = new PersistOnEnhancedNavigationFilter(); + var reason = new PersistOnEnhancedNavigation(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnEnhancedNavigationFilter_DoesNotMatchDifferentReason() + { + // Arrange + var filter = new PersistOnEnhancedNavigationFilter(); + var reason = new PersistOnPrerendering(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.Null(result); + } + + [Fact] + public void PersistOnCircuitPauseFilter_AllowsByDefault() + { + // Arrange + var filter = new PersistOnCircuitPauseFilter(); + var reason = new PersistOnCircuitPause(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnCircuitPauseFilter_CanBlock() + { + // Arrange + var filter = new PersistOnCircuitPauseFilter(persist: false); + var reason = new PersistOnCircuitPause(); + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.False(result); + } +} \ No newline at end of file From f938ea48b3d95e7ba5d1b85c8548ab01874163d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:42:31 +0000 Subject: [PATCH 4/7] Move persistence reason implementations to Components.Web and update RazorComponentEndpointInvoker to detect enhanced navigation Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/src/DefaultPersistenceReason.cs | 17 +++++++++++++++++ .../ComponentStatePersistenceManager.cs | 4 ++-- .../Components/src/PublicAPI.Unshipped.txt | 13 +------------ ...Microsoft.AspNetCore.Components.Tests.csproj | 1 + .../ComponentStatePersistenceManagerTest.cs | 1 + .../EndpointHtmlRenderer.PrerenderingState.cs | 10 ++++++++-- .../Web/src/PersistenceReasonFilters.cs | 2 -- .../src/PersistenceReasons.cs | 2 +- src/Components/Web/src/PublicAPI.Unshipped.txt | 9 +++++++++ 9 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 src/Components/Components/src/DefaultPersistenceReason.cs rename src/Components/{Components => Web}/src/PersistenceReasons.cs (94%) diff --git a/src/Components/Components/src/DefaultPersistenceReason.cs b/src/Components/Components/src/DefaultPersistenceReason.cs new file mode 100644 index 000000000000..bb7839ab2764 --- /dev/null +++ b/src/Components/Components/src/DefaultPersistenceReason.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; + +/// +/// Default persistence reason used when no specific reason is provided. +/// +internal sealed class DefaultPersistenceReason : IPersistenceReason +{ + public static readonly DefaultPersistenceReason Instance = new(); + + private DefaultPersistenceReason() { } + + /// + public bool PersistByDefault => true; +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index a33eb162e0fd..c4a0ca69b8e1 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -68,7 +68,7 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) /// The that components are being rendered. /// A that will complete when the state has been restored. public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer) - => PersistStateAsync(store, renderer, new PersistOnPrerendering()); + => PersistStateAsync(store, renderer, DefaultPersistenceReason.Instance); /// /// Persists the component application state into the given . @@ -187,7 +187,7 @@ private void InferRenderModes(Renderer renderer) } internal Task TryPauseAsync(IPersistentComponentStateStore store) - => TryPauseAsync(store, new PersistOnPrerendering()); + => TryPauseAsync(store, DefaultPersistenceReason.Instance); internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason persistenceReason) { diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 3f9625c1ab42..b3027248923c 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -10,8 +10,7 @@ Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(stri Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void -Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task -Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void @@ -19,20 +18,10 @@ Microsoft.AspNetCore.Components.IPersistenceReason Microsoft.AspNetCore.Components.IPersistenceReason.PersistByDefault.get -> bool Microsoft.AspNetCore.Components.IPersistenceReasonFilter Microsoft.AspNetCore.Components.IPersistenceReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? -Microsoft.AspNetCore.Components.PersistOnCircuitPause -Microsoft.AspNetCore.Components.PersistOnCircuitPause.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.PersistOnCircuitPause.PersistOnCircuitPause() -> void -Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation -Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.PersistOnEnhancedNavigation.PersistOnEnhancedNavigation() -> void -Microsoft.AspNetCore.Components.PersistOnPrerendering -Microsoft.AspNetCore.Components.PersistOnPrerendering.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.PersistOnPrerendering.PersistOnPrerendering() -> void Microsoft.AspNetCore.Components.PersistReasonFilter Microsoft.AspNetCore.Components.PersistReasonFilter.PersistReasonFilter(bool persist) -> void Microsoft.AspNetCore.Components.PersistReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription -Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! 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/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index abead3e1940b..bc5d64492a8f 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index 56acf674a27e..c3ab9bfaddcb 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs @@ -50,7 +50,10 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht if (store != null) { - await manager.PersistStateAsync(store, this, new PersistOnPrerendering()); + IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) + ? new PersistOnEnhancedNavigation() + : new PersistOnPrerendering(); + await manager.PersistStateAsync(store, this, persistenceReason); return store switch { ProtectedPrerenderComponentApplicationStore protectedStore => new ComponentStateHtmlContent(protectedStore, null), @@ -80,7 +83,10 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht var webAssembly = new CopyOnlyStore(); store = new CompositeStore(server, auto, webAssembly); - await manager.PersistStateAsync(store, this, new PersistOnPrerendering()); + IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) + ? new PersistOnEnhancedNavigation() + : new PersistOnPrerendering(); + await manager.PersistStateAsync(store, this, persistenceReason); foreach (var kvp in auto.Saved) { diff --git a/src/Components/Web/src/PersistenceReasonFilters.cs b/src/Components/Web/src/PersistenceReasonFilters.cs index ebb485bc821b..ce87e6844931 100644 --- a/src/Components/Web/src/PersistenceReasonFilters.cs +++ b/src/Components/Web/src/PersistenceReasonFilters.cs @@ -1,8 +1,6 @@ // 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; - namespace Microsoft.AspNetCore.Components.Web; /// diff --git a/src/Components/Components/src/PersistenceReasons.cs b/src/Components/Web/src/PersistenceReasons.cs similarity index 94% rename from src/Components/Components/src/PersistenceReasons.cs rename to src/Components/Web/src/PersistenceReasons.cs index 03ab26ad33f6..f679e707a8eb 100644 --- a/src/Components/Components/src/PersistenceReasons.cs +++ b/src/Components/Web/src/PersistenceReasons.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components.Web; /// /// Represents persistence during prerendering. diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 7ffec55b7ef1..bb0c5f9109cb 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,9 +1,18 @@ #nullable enable Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! +Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause +Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause.PersistOnCircuitPause() -> void Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter.PersistOnCircuitPauseFilter(bool persist = true) -> void +Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation +Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation.PersistOnEnhancedNavigation() -> void Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter.PersistOnEnhancedNavigationFilter(bool persist = true) -> void +Microsoft.AspNetCore.Components.Web.PersistOnPrerendering +Microsoft.AspNetCore.Components.Web.PersistOnPrerendering.PersistByDefault.get -> bool +Microsoft.AspNetCore.Components.Web.PersistOnPrerendering.PersistOnPrerendering() -> void Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter.PersistOnPrerenderingFilter(bool persist = true) -> void virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file From 4439a8cdd9a9347f5d4eaa965f12a79ea35deae8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:00:13 +0000 Subject: [PATCH 5/7] Remove Components.Web reference from Components.Tests project Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- ...crosoft.AspNetCore.Components.Tests.csproj | 1 - .../ComponentStatePersistenceManagerTest.cs | 31 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) 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/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index bc5d64492a8f..c6e8d9eb3415 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -6,7 +6,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -423,9 +422,9 @@ IEnumerator IEnumerable.GetEnumerator() public void PersistenceReasons_HaveCorrectDefaults() { // Arrange & Act - var prerenderingReason = new PersistOnPrerendering(); - var enhancedNavReason = new PersistOnEnhancedNavigation(); - var circuitPauseReason = new PersistOnCircuitPause(); + var prerenderingReason = new TestPersistOnPrerendering(); + var enhancedNavReason = new TestPersistOnEnhancedNavigation(); + var circuitPauseReason = new TestPersistOnCircuitPause(); // Assert Assert.True(prerenderingReason.PersistByDefault); @@ -446,7 +445,7 @@ public async Task PersistStateAsync_RespectsReasonFilters() // Register callback with filter that blocks enhanced navigation var filters = new List { - new TestPersistenceReasonFilter(false) + new TestPersistenceReasonFilter(false) }; manager.State.RegisterOnPersisting(() => @@ -456,7 +455,7 @@ public async Task PersistStateAsync_RespectsReasonFilters() }, new TestRenderMode(), filters); // Act - persist with enhanced navigation reason - await manager.PersistStateAsync(store, renderer, new PersistOnEnhancedNavigation()); + await manager.PersistStateAsync(store, renderer, new TestPersistOnEnhancedNavigation()); // Assert - callback should not be executed Assert.False(callbackExecuted); @@ -475,7 +474,7 @@ public async Task PersistStateAsync_AllowsWhenFilterMatches() // Register callback with filter that allows prerendering var filters = new List { - new TestPersistenceReasonFilter(true) + new TestPersistenceReasonFilter(true) }; manager.State.RegisterOnPersisting(() => @@ -485,7 +484,7 @@ public async Task PersistStateAsync_AllowsWhenFilterMatches() }, new TestRenderMode(), filters); // Act - persist with prerendering reason - await manager.PersistStateAsync(store, renderer, new PersistOnPrerendering()); + await manager.PersistStateAsync(store, renderer, new TestPersistOnPrerendering()); // Assert - callback should be executed Assert.True(callbackExecuted); @@ -515,6 +514,22 @@ private class TestRenderMode : IComponentRenderMode { } + // Test implementations of persistence reasons + private class TestPersistOnPrerendering : IPersistenceReason + { + public bool PersistByDefault => true; + } + + private class TestPersistOnEnhancedNavigation : IPersistenceReason + { + public bool PersistByDefault => false; + } + + private class TestPersistOnCircuitPause : IPersistenceReason + { + public bool PersistByDefault => true; + } + private class PersistentService : IPersistentServiceRegistration { public string Assembly { get; set; } From a2c9f1c25693e4591d48bb108bc2adcee8f78ec8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:29:36 +0000 Subject: [PATCH 6/7] Address PR review comments - remove overloads, use null instead of default reason, implement singleton pattern Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/DefaultPersistenceReason.cs | 17 ---------- .../src/PersistComponentStateRegistration.cs | 4 +-- .../src/PersistentComponentState.cs | 6 ++-- .../ComponentStatePersistenceManager.cs | 30 +++++++----------- .../Components/src/PublicAPI.Shipped.txt | 2 +- .../Components/src/PublicAPI.Unshipped.txt | 3 +- .../EndpointHtmlRenderer.PrerenderingState.cs | 8 ++--- .../src/Circuits/CircuitPersistenceManager.cs | 2 +- .../Web/src/PersistOnCircuitPause.cs | 20 ++++++++++++ .../Web/src/PersistOnEnhancedNavigation.cs | 20 ++++++++++++ .../Web/src/PersistOnPrerendering.cs | 20 ++++++++++++ src/Components/Web/src/PersistenceReasons.cs | 31 ------------------- .../Web/src/PublicAPI.Unshipped.txt | 6 ++-- .../Web/test/PersistenceReasonFiltersTest.cs | 12 +++---- 14 files changed, 93 insertions(+), 88 deletions(-) delete mode 100644 src/Components/Components/src/DefaultPersistenceReason.cs create mode 100644 src/Components/Web/src/PersistOnCircuitPause.cs create mode 100644 src/Components/Web/src/PersistOnEnhancedNavigation.cs create mode 100644 src/Components/Web/src/PersistOnPrerendering.cs delete mode 100644 src/Components/Web/src/PersistenceReasons.cs diff --git a/src/Components/Components/src/DefaultPersistenceReason.cs b/src/Components/Components/src/DefaultPersistenceReason.cs deleted file mode 100644 index bb7839ab2764..000000000000 --- a/src/Components/Components/src/DefaultPersistenceReason.cs +++ /dev/null @@ -1,17 +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; - -/// -/// Default persistence reason used when no specific reason is provided. -/// -internal sealed class DefaultPersistenceReason : IPersistenceReason -{ - public static readonly DefaultPersistenceReason Instance = new(); - - private DefaultPersistenceReason() { } - - /// - public bool PersistByDefault => true; -} \ No newline at end of file diff --git a/src/Components/Components/src/PersistComponentStateRegistration.cs b/src/Components/Components/src/PersistComponentStateRegistration.cs index 25a165cdee6c..1fa33e6b2412 100644 --- a/src/Components/Components/src/PersistComponentStateRegistration.cs +++ b/src/Components/Components/src/PersistComponentStateRegistration.cs @@ -6,11 +6,11 @@ namespace Microsoft.AspNetCore.Components; internal readonly struct PersistComponentStateRegistration( Func callback, IComponentRenderMode? renderMode, - IReadOnlyList? reasonFilters = null) + IReadOnlyList reasonFilters) { public Func Callback { get; } = callback; public IComponentRenderMode? RenderMode { get; } = renderMode; - public IReadOnlyList? ReasonFilters { get; } = reasonFilters; + public IReadOnlyList ReasonFilters { get; } = reasonFilters ?? Array.Empty(); } diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index 428e2e75e579..4dd16ac986b1 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -43,7 +43,7 @@ internal void InitializeExistingState(IDictionary existingState) /// The callback to invoke when the application is being paused. /// A subscription that can be used to unregister the callback when disposed. public PersistingComponentStateSubscription RegisterOnPersisting(Func callback) - => RegisterOnPersisting(callback, null); + => RegisterOnPersisting(callback, null, Array.Empty()); /// /// Register a callback to persist the component state when the application is about to be paused. @@ -53,7 +53,7 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call /// /// Filters to control when the callback should be invoked based on the persistence reason. /// A subscription that can be used to unregister the callback when disposed. - public PersistingComponentStateSubscription RegisterOnPersisting(Func callback, IComponentRenderMode? renderMode, IReadOnlyList? reasonFilters) + public PersistingComponentStateSubscription RegisterOnPersisting(Func callback, IComponentRenderMode? renderMode, IReadOnlyList reasonFilters) { ArgumentNullException.ThrowIfNull(callback); @@ -85,7 +85,7 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call throw new InvalidOperationException("Registering a callback while persisting state is not allowed."); } - var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode); + var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode, Array.Empty()); _registeredCallbacks.Add(persistenceCallback); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index c4a0ca69b8e1..f8057eff6c01 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -61,15 +61,6 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) _servicesRegistry?.Restore(State); } - /// - /// Persists the component application state into the given . - /// - /// The to restore the application state from. - /// The that components are being rendered. - /// A that will complete when the state has been restored. - public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer) - => PersistStateAsync(store, renderer, DefaultPersistenceReason.Instance); - /// /// Persists the component application state into the given . /// @@ -77,7 +68,7 @@ public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer ren /// The that components are being rendered. /// The reason for persisting the state. /// A that will complete when the state has been restored. - public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer, IPersistenceReason persistenceReason) + public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer, IPersistenceReason? persistenceReason = null) { if (_stateIsPersisted) { @@ -186,10 +177,7 @@ private void InferRenderModes(Renderer renderer) } } - internal Task TryPauseAsync(IPersistentComponentStateStore store) - => TryPauseAsync(store, DefaultPersistenceReason.Instance); - - internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason persistenceReason) + internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason? persistenceReason = null) { List>? pendingCallbackTasks = null; @@ -213,7 +201,7 @@ internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersist } // Evaluate reason filters to determine if the callback should be executed for this persistence reason - if (registration.ReasonFilters != null) + if (registration.ReasonFilters.Count > 0) { var shouldPersist = EvaluateReasonFilters(registration.ReasonFilters, persistenceReason); if (shouldPersist.HasValue && !shouldPersist.Value) @@ -221,13 +209,13 @@ internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersist // Filters explicitly indicate not to persist for this reason continue; } - else if (!shouldPersist.HasValue && !persistenceReason.PersistByDefault) + else if (!shouldPersist.HasValue && !(persistenceReason?.PersistByDefault ?? true)) { // No filter matched and default is not to persist continue; } } - else if (!persistenceReason.PersistByDefault) + else if (!(persistenceReason?.PersistByDefault ?? true)) { // No filters defined and default is not to persist continue; @@ -306,8 +294,14 @@ static async Task AnyTaskFailed(List> pendingCallbackTasks) } } - private static bool? EvaluateReasonFilters(IReadOnlyList reasonFilters, IPersistenceReason persistenceReason) + private static bool? EvaluateReasonFilters(IReadOnlyList reasonFilters, IPersistenceReason? persistenceReason) { + if (persistenceReason is null) + { + // No reason provided, can't evaluate filters + return null; + } + foreach (var reasonFilter in reasonFilters) { var shouldPersist = reasonFilter.ShouldPersist(persistenceReason); diff --git a/src/Components/Components/src/PublicAPI.Shipped.txt b/src/Components/Components/src/PublicAPI.Shipped.txt index c417cab5be3a..881004a410be 100644 --- a/src/Components/Components/src/PublicAPI.Shipped.txt +++ b/src/Components/Components/src/PublicAPI.Shipped.txt @@ -167,7 +167,7 @@ Microsoft.AspNetCore.Components.IHandleEvent Microsoft.AspNetCore.Components.IHandleEvent.HandleEventAsync(Microsoft.AspNetCore.Components.EventCallbackWorkItem item, object? arg) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger) -> void -Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason? persistenceReason = null) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.RestoreStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.State.get -> Microsoft.AspNetCore.Components.PersistentComponentState! Microsoft.AspNetCore.Components.InjectAttribute diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index b3027248923c..6ee13f2cd42f 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -10,7 +10,6 @@ Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(stri Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string! Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void -Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, Microsoft.AspNetCore.Components.IPersistenceReason! persistenceReason) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void @@ -21,7 +20,7 @@ Microsoft.AspNetCore.Components.IPersistenceReasonFilter.ShouldPersist(Microsoft Microsoft.AspNetCore.Components.PersistReasonFilter Microsoft.AspNetCore.Components.PersistReasonFilter.PersistReasonFilter(bool persist) -> void Microsoft.AspNetCore.Components.PersistReasonFilter.ShouldPersist(Microsoft.AspNetCore.Components.IPersistenceReason! reason) -> bool? -Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList? reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback, Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode, System.Collections.Generic.IReadOnlyList! reasonFilters) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index c3ab9bfaddcb..19ad0944481c 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs @@ -51,8 +51,8 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht if (store != null) { IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) - ? new PersistOnEnhancedNavigation() - : new PersistOnPrerendering(); + ? PersistOnEnhancedNavigation.Instance + : PersistOnPrerendering.Instance; await manager.PersistStateAsync(store, this, persistenceReason); return store switch { @@ -84,8 +84,8 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht store = new CompositeStore(server, auto, webAssembly); IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) - ? new PersistOnEnhancedNavigation() - : new PersistOnPrerendering(); + ? PersistOnEnhancedNavigation.Instance + : PersistOnPrerendering.Instance; await manager.PersistStateAsync(store, this, persistenceReason); foreach (var kvp in auto.Saved) diff --git a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs index b91f4567269e..6344a4f5e963 100644 --- a/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs +++ b/src/Components/Server/src/Circuits/CircuitPersistenceManager.cs @@ -28,7 +28,7 @@ public async Task PauseCircuitAsync(CircuitHost circuit, bool saveStateToClient collector.PersistRootComponents, RenderMode.InteractiveServer); - await persistenceManager.PersistStateAsync(collector, renderer, new PersistOnCircuitPause()); + await persistenceManager.PersistStateAsync(collector, renderer, PersistOnCircuitPause.Instance); if (saveStateToClient) { diff --git a/src/Components/Web/src/PersistOnCircuitPause.cs b/src/Components/Web/src/PersistOnCircuitPause.cs new file mode 100644 index 000000000000..11c27e8d4de0 --- /dev/null +++ b/src/Components/Web/src/PersistOnCircuitPause.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Represents persistence when a circuit is paused. +/// +public sealed class PersistOnCircuitPause : IPersistenceReason +{ + /// + /// Gets the singleton instance of . + /// + public static readonly PersistOnCircuitPause Instance = new(); + + private PersistOnCircuitPause() { } + + /// + public bool PersistByDefault => true; +} \ No newline at end of file diff --git a/src/Components/Web/src/PersistOnEnhancedNavigation.cs b/src/Components/Web/src/PersistOnEnhancedNavigation.cs new file mode 100644 index 000000000000..bda5f23a1831 --- /dev/null +++ b/src/Components/Web/src/PersistOnEnhancedNavigation.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Represents persistence during enhanced navigation. +/// +public sealed class PersistOnEnhancedNavigation : IPersistenceReason +{ + /// + /// Gets the singleton instance of . + /// + public static readonly PersistOnEnhancedNavigation Instance = new(); + + private PersistOnEnhancedNavigation() { } + + /// + public bool PersistByDefault => false; +} \ No newline at end of file diff --git a/src/Components/Web/src/PersistOnPrerendering.cs b/src/Components/Web/src/PersistOnPrerendering.cs new file mode 100644 index 000000000000..b14a99bc58f9 --- /dev/null +++ b/src/Components/Web/src/PersistOnPrerendering.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web; + +/// +/// Represents persistence during prerendering. +/// +public sealed class PersistOnPrerendering : IPersistenceReason +{ + /// + /// Gets the singleton instance of . + /// + public static readonly PersistOnPrerendering Instance = new(); + + private PersistOnPrerendering() { } + + /// + public bool PersistByDefault => true; +} \ No newline at end of file diff --git a/src/Components/Web/src/PersistenceReasons.cs b/src/Components/Web/src/PersistenceReasons.cs deleted file mode 100644 index f679e707a8eb..000000000000 --- a/src/Components/Web/src/PersistenceReasons.cs +++ /dev/null @@ -1,31 +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; - -/// -/// Represents persistence during prerendering. -/// -public class PersistOnPrerendering : IPersistenceReason -{ - /// - public bool PersistByDefault { get; } = true; -} - -/// -/// Represents persistence during enhanced navigation. -/// -public class PersistOnEnhancedNavigation : IPersistenceReason -{ - /// - public bool PersistByDefault { get; } -} - -/// -/// Represents persistence when a circuit is paused. -/// -public class PersistOnCircuitPause : IPersistenceReason -{ - /// - public bool PersistByDefault { get; } = true; -} \ 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 bb0c5f9109cb..8ff842d031a8 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -2,17 +2,17 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause.PersistOnCircuitPause() -> void +static readonly Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause.Instance -> Microsoft.AspNetCore.Components.Web.PersistOnCircuitPause! Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter Microsoft.AspNetCore.Components.Web.PersistOnCircuitPauseFilter.PersistOnCircuitPauseFilter(bool persist = true) -> void Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation.PersistOnEnhancedNavigation() -> void +static readonly Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation.Instance -> Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigation! Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter Microsoft.AspNetCore.Components.Web.PersistOnEnhancedNavigationFilter.PersistOnEnhancedNavigationFilter(bool persist = true) -> void Microsoft.AspNetCore.Components.Web.PersistOnPrerendering Microsoft.AspNetCore.Components.Web.PersistOnPrerendering.PersistByDefault.get -> bool -Microsoft.AspNetCore.Components.Web.PersistOnPrerendering.PersistOnPrerendering() -> void +static readonly Microsoft.AspNetCore.Components.Web.PersistOnPrerendering.Instance -> Microsoft.AspNetCore.Components.Web.PersistOnPrerendering! Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter Microsoft.AspNetCore.Components.Web.PersistOnPrerenderingFilter.PersistOnPrerenderingFilter(bool persist = true) -> void virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file diff --git a/src/Components/Web/test/PersistenceReasonFiltersTest.cs b/src/Components/Web/test/PersistenceReasonFiltersTest.cs index f98f587a5205..65baa6677f4f 100644 --- a/src/Components/Web/test/PersistenceReasonFiltersTest.cs +++ b/src/Components/Web/test/PersistenceReasonFiltersTest.cs @@ -13,7 +13,7 @@ public void PersistOnPrerenderingFilter_AllowsByDefault() { // Arrange var filter = new PersistOnPrerenderingFilter(); - var reason = new PersistOnPrerendering(); + var reason = PersistOnPrerendering.Instance; // Act var result = filter.ShouldPersist(reason); @@ -27,7 +27,7 @@ public void PersistOnPrerenderingFilter_CanBlock() { // Arrange var filter = new PersistOnPrerenderingFilter(persist: false); - var reason = new PersistOnPrerendering(); + var reason = PersistOnPrerendering.Instance; // Act var result = filter.ShouldPersist(reason); @@ -41,7 +41,7 @@ public void PersistOnEnhancedNavigationFilter_AllowsByDefault() { // Arrange var filter = new PersistOnEnhancedNavigationFilter(); - var reason = new PersistOnEnhancedNavigation(); + var reason = PersistOnEnhancedNavigation.Instance; // Act var result = filter.ShouldPersist(reason); @@ -55,7 +55,7 @@ public void PersistOnEnhancedNavigationFilter_DoesNotMatchDifferentReason() { // Arrange var filter = new PersistOnEnhancedNavigationFilter(); - var reason = new PersistOnPrerendering(); + var reason = PersistOnPrerendering.Instance; // Act var result = filter.ShouldPersist(reason); @@ -69,7 +69,7 @@ public void PersistOnCircuitPauseFilter_AllowsByDefault() { // Arrange var filter = new PersistOnCircuitPauseFilter(); - var reason = new PersistOnCircuitPause(); + var reason = PersistOnCircuitPause.Instance; // Act var result = filter.ShouldPersist(reason); @@ -83,7 +83,7 @@ public void PersistOnCircuitPauseFilter_CanBlock() { // Arrange var filter = new PersistOnCircuitPauseFilter(persist: false); - var reason = new PersistOnCircuitPause(); + var reason = PersistOnCircuitPause.Instance; // Act var result = filter.ShouldPersist(reason); From 52e84883b4a2d024b5af6ed2777835b51137a99b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:51:55 +0000 Subject: [PATCH 7/7] Add E2E tests for persistent component state filtering functionality Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../E2ETest/Tests/StatePersistenceTest.cs | 280 ++++++++++++++++++ .../PersistentState/FilteringTestPage.razor | 57 ++++ .../PageWithoutComponents.razor | 6 + .../FilteredPersistentStateComponent.razor | 147 +++++++++ 4 files changed, 490 insertions(+) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/FilteringTestPage.razor create mode 100644 src/Components/test/testassets/TestContentPackage/PersistentComponents/FilteredPersistentStateComponent.razor diff --git a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs index d49e4fbc5704..da2449a26270 100644 --- a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs +++ b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs @@ -210,6 +210,286 @@ private void RenderComponentsWithPersistentStateAndValidate( interactiveRuntime: interactiveRuntime); } + [Theory] + [InlineData(true, typeof(InteractiveServerRenderMode), (string)null)] + [InlineData(true, typeof(InteractiveWebAssemblyRenderMode), (string)null)] + [InlineData(true, typeof(InteractiveAutoRenderMode), (string)null)] + [InlineData(false, typeof(InteractiveServerRenderMode), (string)null)] + public void CanFilterPersistentStateCallbacks(bool suppressEnhancedNavigation, 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}") + }; + + if (!suppressEnhancedNavigation) + { + // Navigate to a page without components first to test enhanced navigation filtering + Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart"); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + Browser.Click(By.Id("call-blazor-start")); + Browser.Click(By.Id("filtering-test-link")); + } + else + { + EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, true); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + } + + if (mode != "auto") + { + ValidateFilteringBehavior(suppressEnhancedNavigation, mode, renderMode, streaming); + } + else + { + // For auto mode, validate both server and wasm behavior + ValidateFilteringBehavior(suppressEnhancedNavigation, mode, renderMode, streaming, interactiveRuntime: "server"); + + UnblockWebAssemblyResourceLoad(); + Browser.Navigate().Refresh(); + + ValidateFilteringBehavior(suppressEnhancedNavigation, mode, renderMode, streaming, interactiveRuntime: "wasm"); + } + } + + [Theory] + [InlineData(true, typeof(InteractiveServerRenderMode))] + [InlineData(true, typeof(InteractiveWebAssemblyRenderMode))] + [InlineData(true, typeof(InteractiveAutoRenderMode))] + [InlineData(false, typeof(InteractiveServerRenderMode))] + public void CanFilterPersistentStateForEnhancedNavigation(bool suppressEnhancedNavigation, Type renderMode) + { + 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}") + }; + + if (!suppressEnhancedNavigation) + { + // Navigate to a page without components first to test enhanced navigation filtering + Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart"); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + Browser.Click(By.Id("call-blazor-start")); + // Click link that enables persistence during enhanced navigation + Browser.Click(By.Id("filtering-test-link-with-enhanced-nav")); + } + else + { + EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, true); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + } + + if (mode != "auto") + { + ValidateEnhancedNavigationFiltering(suppressEnhancedNavigation, mode, renderMode); + } + else + { + // For auto mode, validate both server and wasm behavior + ValidateEnhancedNavigationFiltering(suppressEnhancedNavigation, mode, renderMode, interactiveRuntime: "server"); + + UnblockWebAssemblyResourceLoad(); + Browser.Navigate().Refresh(); + + ValidateEnhancedNavigationFiltering(suppressEnhancedNavigation, mode, renderMode, interactiveRuntime: "wasm"); + } + } + + [Theory] + [InlineData(typeof(InteractiveServerRenderMode))] + [InlineData(typeof(InteractiveWebAssemblyRenderMode))] + [InlineData(typeof(InteractiveAutoRenderMode))] + public void CanDisablePersistenceForPrerendering(Type renderMode) + { + 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}") + }; + + // Navigate to a page without components first + Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart"); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + Browser.Click(By.Id("call-blazor-start")); + // Click link that disables persistence during prerendering + Browser.Click(By.Id("filtering-test-link-no-prerendering")); + + if (mode != "auto") + { + ValidatePrerenderingFilteringDisabled(mode, renderMode); + } + else + { + // For auto mode, validate both server and wasm behavior + ValidatePrerenderingFilteringDisabled(mode, renderMode, interactiveRuntime: "server"); + + UnblockWebAssemblyResourceLoad(); + Browser.Navigate().Refresh(); + + ValidatePrerenderingFilteringDisabled(mode, renderMode, interactiveRuntime: "wasm"); + } + } + + private void ValidateFilteringBehavior( + bool suppressEnhancedNavigation, + string mode, + Type renderMode, + string streaming, + string interactiveRuntime = null) + { + if (suppressEnhancedNavigation) + { + Navigate($"subdir/persistent-state/filtering-test?render-mode={mode}&suppress-autostart"); + + // Validate server-side state before Blazor starts + AssertFilteringPageState( + mode: mode, + renderMode: renderMode.Name, + interactive: false, + interactiveRuntime: interactiveRuntime); + + Browser.Click(By.Id("call-blazor-start")); + } + + // Validate state after Blazor is interactive + AssertFilteringPageState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + interactiveRuntime: interactiveRuntime); + } + + private void ValidateEnhancedNavigationFiltering( + bool suppressEnhancedNavigation, + string mode, + Type renderMode, + string interactiveRuntime = null) + { + if (suppressEnhancedNavigation) + { + Navigate($"subdir/persistent-state/filtering-test?render-mode={mode}&persist-enhanced-nav=true&suppress-autostart"); + + // Validate server-side state before Blazor starts + AssertEnhancedNavFilteringPageState( + mode: mode, + renderMode: renderMode.Name, + interactive: false, + interactiveRuntime: interactiveRuntime); + + Browser.Click(By.Id("call-blazor-start")); + } + + // Validate state after Blazor is interactive + AssertEnhancedNavFilteringPageState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + interactiveRuntime: interactiveRuntime); + } + + private void ValidatePrerenderingFilteringDisabled( + string mode, + Type renderMode, + string interactiveRuntime = null) + { + // When prerendering persistence is disabled, components should show fresh state + AssertPrerenderingFilteringDisabledPageState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + interactiveRuntime: interactiveRuntime); + } + + private void AssertFilteringPageState( + string mode, + string renderMode, + bool interactive, + string interactiveRuntime = null) + { + Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text); + + if (interactive) + { + interactiveRuntime = mode == "server" || mode == "wasm" ? mode : (interactiveRuntime ?? throw new InvalidOperationException("Specify interactiveRuntime for auto mode")); + Browser.Equal($"Interactive runtime: {interactiveRuntime}", () => Browser.FindElement(By.Id("interactive-runtime")).Text); + + // Default behavior: persist during prerendering, not during enhanced navigation + Browser.Equal("Prerendering state found:true", () => Browser.FindElement(By.Id("prerendering-state-found")).Text); + Browser.Equal("Enhanced nav state found:false", () => Browser.FindElement(By.Id("enhanced-nav-state-found")).Text); + Browser.Equal("Circuit pause state found:false", () => Browser.FindElement(By.Id("circuit-pause-state-found")).Text); + Browser.Equal("Combined filters state found:true", () => Browser.FindElement(By.Id("combined-filters-state-found")).Text); + } + } + + private void AssertEnhancedNavFilteringPageState( + string mode, + string renderMode, + bool interactive, + string interactiveRuntime = null) + { + Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text); + + if (interactive) + { + interactiveRuntime = mode == "server" || mode == "wasm" ? mode : (interactiveRuntime ?? throw new InvalidOperationException("Specify interactiveRuntime for auto mode")); + Browser.Equal($"Interactive runtime: {interactiveRuntime}", () => Browser.FindElement(By.Id("interactive-runtime")).Text); + + // Enhanced navigation persistence enabled + Browser.Equal("Prerendering state found:true", () => Browser.FindElement(By.Id("prerendering-state-found")).Text); + Browser.Equal("Enhanced nav state found:true", () => Browser.FindElement(By.Id("enhanced-nav-state-found")).Text); + Browser.Equal("Circuit pause state found:false", () => Browser.FindElement(By.Id("circuit-pause-state-found")).Text); + Browser.Equal("Combined filters state found:true", () => Browser.FindElement(By.Id("combined-filters-state-found")).Text); + } + } + + private void AssertPrerenderingFilteringDisabledPageState( + string mode, + string renderMode, + bool interactive, + string interactiveRuntime = null) + { + Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text); + + if (interactive) + { + interactiveRuntime = mode == "server" || mode == "wasm" ? mode : (interactiveRuntime ?? throw new InvalidOperationException("Specify interactiveRuntime for auto mode")); + Browser.Equal($"Interactive runtime: {interactiveRuntime}", () => Browser.FindElement(By.Id("interactive-runtime")).Text); + + // Prerendering persistence disabled - should show fresh values + Browser.Equal("Prerendering state found:false", () => Browser.FindElement(By.Id("prerendering-state-found")).Text); + Browser.Equal("Prerendering state value:fresh-prerendering", () => Browser.FindElement(By.Id("prerendering-state-value")).Text); + Browser.Equal("Enhanced nav state found:false", () => Browser.FindElement(By.Id("enhanced-nav-state-found")).Text); + Browser.Equal("Circuit pause state found:false", () => Browser.FindElement(By.Id("circuit-pause-state-found")).Text); + Browser.Equal("Combined filters state found:false", () => Browser.FindElement(By.Id("combined-filters-state-found")).Text); + } + } + private void AssertPageState( string mode, string renderMode, diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/FilteringTestPage.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/FilteringTestPage.razor new file mode 100644 index 000000000000..e58980227b7c --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/FilteringTestPage.razor @@ -0,0 +1,57 @@ +@page "/persistent-state/filtering-test" +@using TestContentPackage.PersistentComponents + +

Filtered Persistent State Test Page

+ +

+ This page tests selective state persistence based on filtering criteria. + It renders components with different filter configurations to validate that state is persisted or skipped based on the persistence reason. +

+ +

Render mode: @_renderMode?.GetType()?.Name

+

Streaming id:@StreamingId

+ +@if (_renderMode != null) +{ + + + +} + +Go to page with no components + +@code { + private IComponentRenderMode _renderMode; + + [SupplyParameterFromQuery(Name = "render-mode")] public string RenderMode { get; set; } + [SupplyParameterFromQuery(Name = "streaming-id")] public string StreamingId { get; set; } + [SupplyParameterFromQuery(Name = "server-state")] public string ServerState { get; set; } + [SupplyParameterFromQuery(Name = "persist-prerendering")] public bool PersistOnPrerendering { get; set; } = true; + [SupplyParameterFromQuery(Name = "persist-enhanced-nav")] public bool PersistOnEnhancedNav { get; set; } = false; + [SupplyParameterFromQuery(Name = "persist-circuit-pause")] public bool PersistOnCircuitPause { get; set; } = true; + + protected override void OnInitialized() + { + if (!string.IsNullOrEmpty(RenderMode)) + { + switch (RenderMode) + { + case "server": + _renderMode = new InteractiveServerRenderMode(true); + break; + case "wasm": + _renderMode = new InteractiveWebAssemblyRenderMode(true); + break; + case "auto": + _renderMode = new InteractiveAutoRenderMode(true); + break; + default: + throw new ArgumentException($"Invalid render mode: {RenderMode}"); + } + } + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor index fe7693c59523..a818fed166f4 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor @@ -6,6 +6,12 @@ Go to page with components and state +Go to filtering test page + +Go to filtering test page (no prerendering) + +Go to filtering test page (with enhanced nav) + @code { [SupplyParameterFromQuery(Name = "render-mode")] public string RenderMode { get; set; } diff --git a/src/Components/test/testassets/TestContentPackage/PersistentComponents/FilteredPersistentStateComponent.razor b/src/Components/test/testassets/TestContentPackage/PersistentComponents/FilteredPersistentStateComponent.razor new file mode 100644 index 000000000000..40e12ebf22c0 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/PersistentComponents/FilteredPersistentStateComponent.razor @@ -0,0 +1,147 @@ +@using Microsoft.AspNetCore.Components.Web + +

Filtered persistent state component

+ +

This component demonstrates selective state persistence based on filtering criteria. It registers different callbacks with different filter combinations to test the filtering behavior.

+ +

Interactive: @(!RunningOnServer)

+

Interactive runtime: @_interactiveRuntime

+

Prerendering state found:@_prerenderingStateFound

+

Prerendering state value:@_prerenderingStateValue

+

Enhanced nav state found:@_enhancedNavStateFound

+

Enhanced nav state value:@_enhancedNavStateValue

+

Circuit pause state found:@_circuitPauseStateFound

+

Circuit pause state value:@_circuitPauseStateValue

+

Combined filters state found:@_combinedFiltersStateFound

+

Combined filters state value:@_combinedFiltersStateValue

+ +@code { + private bool _prerenderingStateFound; + private string _prerenderingStateValue; + private bool _enhancedNavStateFound; + private string _enhancedNavStateValue; + private bool _circuitPauseStateFound; + private string _circuitPauseStateValue; + private bool _combinedFiltersStateFound; + private string _combinedFiltersStateValue; + private string _interactiveRuntime; + + [Inject] public PersistentComponentState PersistentComponentState { get; set; } + + [CascadingParameter(Name = nameof(RunningOnServer))] public bool RunningOnServer { get; set; } + + [Parameter] public string ServerState { get; set; } + [Parameter] public bool PersistOnPrerendering { get; set; } = true; + [Parameter] public bool PersistOnEnhancedNav { get; set; } = false; + [Parameter] public bool PersistOnCircuitPause { get; set; } = true; + + protected override void OnInitialized() + { + // Register callback that only persists during prerendering + var prerenderingFilters = new List + { + new PersistOnPrerenderingFilter(PersistOnPrerendering), + new PersistOnEnhancedNavigationFilter(false), + new PersistOnCircuitPauseFilter(false) + }; + PersistentComponentState.RegisterOnPersisting(PersistPrerenderingState, null, prerenderingFilters); + + // Register callback that only persists during enhanced navigation + var enhancedNavFilters = new List + { + new PersistOnPrerenderingFilter(false), + new PersistOnEnhancedNavigationFilter(PersistOnEnhancedNav), + new PersistOnCircuitPauseFilter(false) + }; + PersistentComponentState.RegisterOnPersisting(PersistEnhancedNavState, null, enhancedNavFilters); + + // Register callback that only persists on circuit pause + var circuitPauseFilters = new List + { + new PersistOnPrerenderingFilter(false), + new PersistOnEnhancedNavigationFilter(false), + new PersistOnCircuitPauseFilter(PersistOnCircuitPause) + }; + PersistentComponentState.RegisterOnPersisting(PersistCircuitPauseState, null, circuitPauseFilters); + + // Register callback with combined filters + var combinedFilters = new List + { + new PersistOnPrerenderingFilter(PersistOnPrerendering), + new PersistOnEnhancedNavigationFilter(PersistOnEnhancedNav), + new PersistOnCircuitPauseFilter(PersistOnCircuitPause) + }; + PersistentComponentState.RegisterOnPersisting(PersistCombinedFiltersState, null, combinedFilters); + + // Try to restore state + _prerenderingStateFound = PersistentComponentState.TryTakeFromJson("PrerenderingState", out _prerenderingStateValue); + _enhancedNavStateFound = PersistentComponentState.TryTakeFromJson("EnhancedNavState", out _enhancedNavStateValue); + _circuitPauseStateFound = PersistentComponentState.TryTakeFromJson("CircuitPauseState", out _circuitPauseStateValue); + _combinedFiltersStateFound = PersistentComponentState.TryTakeFromJson("CombinedFiltersState", out _combinedFiltersStateValue); + + if (!_prerenderingStateFound) + { + _prerenderingStateValue = "fresh-prerendering"; + } + + if (!_enhancedNavStateFound) + { + _enhancedNavStateValue = "fresh-enhanced-nav"; + } + + if (!_circuitPauseStateFound) + { + _circuitPauseStateValue = "fresh-circuit-pause"; + } + + if (!_combinedFiltersStateFound) + { + _combinedFiltersStateValue = "fresh-combined"; + } + + if (RunningOnServer) + { + _interactiveRuntime = "none"; + // Use server state if provided + if (!string.IsNullOrEmpty(ServerState)) + { + _prerenderingStateFound = true; + _prerenderingStateValue = $"{ServerState}-prerendering"; + _enhancedNavStateFound = true; + _enhancedNavStateValue = $"{ServerState}-enhanced-nav"; + _circuitPauseStateFound = true; + _circuitPauseStateValue = $"{ServerState}-circuit-pause"; + _combinedFiltersStateFound = true; + _combinedFiltersStateValue = $"{ServerState}-combined"; + } + } + else + { + _interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server"; + } + } + + Task PersistPrerenderingState() + { + PersistentComponentState.PersistAsJson("PrerenderingState", _prerenderingStateValue); + return Task.CompletedTask; + } + + Task PersistEnhancedNavState() + { + PersistentComponentState.PersistAsJson("EnhancedNavState", _enhancedNavStateValue); + return Task.CompletedTask; + } + + Task PersistCircuitPauseState() + { + PersistentComponentState.PersistAsJson("CircuitPauseState", _circuitPauseStateValue); + return Task.CompletedTask; + } + + Task PersistCombinedFiltersState() + { + PersistentComponentState.PersistAsJson("CombinedFiltersState", _combinedFiltersStateValue); + return Task.CompletedTask; + } +} \ No newline at end of file