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..1fa33e6b2412 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) { public Func Callback { get; } = callback; public IComponentRenderMode? RenderMode { get; } = renderMode; + + public IReadOnlyList ReasonFilters { get; } = reasonFilters ?? Array.Empty(); } 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/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a3dd2fdddc81..4dd16ac986b1 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -43,7 +43,31 @@ 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. + /// 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. @@ -61,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 72c1ca666411..f8057eff6c01 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -66,8 +66,9 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) /// /// 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) + public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer, IPersistenceReason? persistenceReason = null) { if (_stateIsPersisted) { @@ -113,7 +114,7 @@ async Task PauseAndPersistState() async Task TryPersistState(IPersistentComponentStateStore store) { - if (!await TryPauseAsync(store)) + if (!await TryPauseAsync(store, persistenceReason)) { _currentState.Clear(); return false; @@ -159,7 +160,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 { @@ -176,7 +177,7 @@ private void InferRenderModes(Renderer renderer) } } - internal Task TryPauseAsync(IPersistentComponentStateStore store) + internal Task TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason? persistenceReason = null) { List>? pendingCallbackTasks = null; @@ -199,6 +200,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.Count > 0) + { + 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 ?? true)) + { + // No filter matched and default is not to persist + continue; + } + } + else if (!(persistenceReason?.PersistByDefault ?? true)) + { + // No filters defined and default is not to persist + continue; + } + var result = TryExecuteCallback(registration.Callback, _logger); if (!result.IsCompletedSuccessfully) { @@ -271,4 +293,25 @@ static async Task AnyTaskFailed(List> pendingCallbackTasks) return true; } } + + 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); + if (shouldPersist.HasValue) + { + return shouldPersist.Value; + } + } + + // No filter matched + return null; + } } 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 07e51aca6bd3..6ee13f2cd42f 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -13,6 +13,14 @@ Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager. 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.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/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 4e5708c10f4d..c6e8d9eb3415 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -418,10 +418,118 @@ IEnumerator IEnumerable.GetEnumerator() } } + [Fact] + public void PersistenceReasons_HaveCorrectDefaults() + { + // Arrange & Act + var prerenderingReason = new TestPersistOnPrerendering(); + var enhancedNavReason = new TestPersistOnEnhancedNavigation(); + var circuitPauseReason = new TestPersistOnCircuitPause(); + + // 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 TestPersistOnEnhancedNavigation()); + + // 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 TestPersistOnPrerendering()); + + // 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 { } + // 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; } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index 161967076ec5..19ad0944481c 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); + IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) + ? PersistOnEnhancedNavigation.Instance + : PersistOnPrerendering.Instance; + 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); + IPersistenceReason persistenceReason = IsProgressivelyEnhancedNavigation(httpContext.Request) + ? 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 285bf5a02eef..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); + 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/PersistenceReasonFilters.cs b/src/Components/Web/src/PersistenceReasonFilters.cs new file mode 100644 index 000000000000..ce87e6844931 --- /dev/null +++ b/src/Components/Web/src/PersistenceReasonFilters.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.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 diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 99365e10804e..8ff842d031a8 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,3 +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 +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 +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 +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 new file mode 100644 index 000000000000..65baa6677f4f --- /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 = PersistOnPrerendering.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnPrerenderingFilter_CanBlock() + { + // Arrange + var filter = new PersistOnPrerenderingFilter(persist: false); + var reason = PersistOnPrerendering.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.False(result); + } + + [Fact] + public void PersistOnEnhancedNavigationFilter_AllowsByDefault() + { + // Arrange + var filter = new PersistOnEnhancedNavigationFilter(); + var reason = PersistOnEnhancedNavigation.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnEnhancedNavigationFilter_DoesNotMatchDifferentReason() + { + // Arrange + var filter = new PersistOnEnhancedNavigationFilter(); + var reason = PersistOnPrerendering.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.Null(result); + } + + [Fact] + public void PersistOnCircuitPauseFilter_AllowsByDefault() + { + // Arrange + var filter = new PersistOnCircuitPauseFilter(); + var reason = PersistOnCircuitPause.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.True(result); + } + + [Fact] + public void PersistOnCircuitPauseFilter_CanBlock() + { + // Arrange + var filter = new PersistOnCircuitPauseFilter(persist: false); + var reason = PersistOnCircuitPause.Instance; + + // Act + var result = filter.ShouldPersist(reason); + + // Assert + Assert.False(result); + } +} \ No newline at end of file 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