Skip to content

[Blazor] Add ability to filter persistent component state callbacks based on persistence reason #62394

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

Copilot
Copy link
Contributor

@Copilot Copilot AI commented Jun 18, 2025

This PR implements the ability to filter persistent component state callbacks based on the reason for persistence, addressing scenarios where components need different persistence behavior for prerendering, enhanced navigation, and circuit pause events.

Changes

Core Interfaces and Classes

  • Added IPersistenceReason interface with PersistByDefault property
  • Added concrete persistence reason implementations:
    • PersistOnPrerendering (default: true)
    • PersistOnEnhancedNavigation (default: false)
    • PersistOnCircuitPause (default: true)
  • Added IPersistenceReasonFilter interface for filtering logic
  • Added generic PersistReasonFilter<TReason> base class

Filter Classes in Components.Web

  • PersistOnPrerenderingFilter - Controls persistence during prerendering
  • PersistOnEnhancedNavigationFilter - Controls persistence during enhanced navigation
  • PersistOnCircuitPauseFilter - Controls persistence during circuit pause

Infrastructure Updates

  • Enhanced PersistComponentStateRegistration to include reason filters
  • Added new RegisterOnPersisting overload accepting IReadOnlyList<IPersistenceReasonFilter>
  • Added new PersistStateAsync overload accepting IPersistenceReason
  • Updated calling code to pass appropriate persistence reasons:
    • RazorComponentEndpointInvoker uses PersistOnPrerendering
    • CircuitPersistenceManager uses PersistOnCircuitPause

Filtering Logic

The persistence manager now evaluates filters in order:

  1. If any filter returns a definitive result (true/false), that decision is used
  2. If no filters match the persistence reason, the reason's PersistByDefault value is used
  3. Maintains full backward compatibility - existing code works unchanged

Usage Example

// Register callback with filtering
var filters = new List<IPersistenceReasonFilter>
{
    new PersistOnPrerenderingFilter(persist: true),
    new PersistOnEnhancedNavigationFilter(persist: false),
    new PersistOnCircuitPauseFilter(persist: true)
};

PersistentState.RegisterOnPersisting(PersistData, renderMode, filters);

This allows fine-grained control over when component state should be persisted based on the specific persistence scenario.

Fixes #62393.


💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click here to start the survey.

Copy link
Contributor

Greetings human! You've submitted a PR that modifies code that is shared with https://github.com/dotnet/runtime . Please make sure you synchronize this code with the changes in that repo!

@javiercn
Copy link
Member

Create concrete persistence reason implementations: PersistOnPrerendering, PersistOnEnhancedNavigation, PersistOnCircuitPause must live on the Components.Web assembly.

@javiercn
Copy link
Member

RazorComponentEndpointInvoker needs to pass in Prerendering as the reason during the initial rendering and EnhancedNavigation when the request comes from an enhanced navigation

Copilot AI and others added 2 commits June 18, 2025 14:07
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
@Copilot Copilot AI changed the title [WIP] [Blazor] Provide the ability to filter persistent component state callbacks based on the reason for persistence [Blazor] Add ability to filter persistent component state callbacks based on persistence reason Jun 18, 2025
@Copilot Copilot AI requested a review from javiercn June 18, 2025 14:16
Copilot finished work on behalf of javiercn June 18, 2025 14:16
…RazorComponentEndpointInvoker to detect enhanced navigation

Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jun 18, 2025

Create concrete persistence reason implementations: PersistOnPrerendering, PersistOnEnhancedNavigation, PersistOnCircuitPause must live on the Components.Web assembly.

Moved the concrete persistence reason implementations (PersistOnPrerendering, PersistOnEnhancedNavigation, PersistOnCircuitPause) to the Components.Web assembly as requested. Updated all references and PublicAPI files accordingly. Created a DefaultPersistenceReason for internal fallback use in the Components assembly to avoid dependency issues. (f938ea4)

RazorComponentEndpointInvoker needs to pass in Prerendering as the reason during the initial rendering and EnhancedNavigation when the request comes from an enhanced navigation

Updated RazorComponentEndpointInvoker to detect enhanced navigation requests using the IsProgressivelyEnhancedNavigation method. Now passes PersistOnPrerendering for initial rendering and PersistOnEnhancedNavigation when the request comes from enhanced navigation, as determined by the "text/html; blazor-enhanced-nav=on" Accept header. (f938ea4)

Copilot finished work on behalf of javiercn June 18, 2025 14:45
@@ -7,6 +7,7 @@

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components" />
<Reference Include="Microsoft.AspNetCore.Components.Web" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not add this reference here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the Microsoft.AspNetCore.Components.Web reference from the Components.Tests project. Created test-specific implementations of persistence reasons within the test file to maintain test functionality without the dependency. (4439a8c)

Comment on lines 189 to 192
internal Task<bool> TryPauseAsync(IPersistentComponentStateStore store)
=> TryPauseAsync(store, DefaultPersistenceReason.Instance);

internal Task<bool> TryPauseAsync(IPersistentComponentStateStore store, IPersistenceReason persistenceReason)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not create and overload. Simply change this method and adjust all the places it's used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the overload from ComponentStatePersistenceManager.PersistStateAsync and modified the existing method to accept an optional nullable IPersistenceReason parameter. Updated all calling sites to work with the new signature. (a2c9f1c)

Comment on lines 9 to 16
internal sealed class DefaultPersistenceReason : IPersistenceReason
{
public static readonly DefaultPersistenceReason Instance = new();

private DefaultPersistenceReason() { }

/// <inheritdoc />
public bool PersistByDefault => true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use null as a value in legacy overloads (and handle that within PauseAsync) instead of a "default" reason

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced DefaultPersistenceReason with null handling in legacy overloads. The ComponentStatePersistenceManager now handles null persistence reasons with default behavior (persist by default). Removed the DefaultPersistenceReason.cs file entirely. (a2c9f1c)

/// <param name="renderMode"></param>
/// <param name="reasonFilters">Filters to control when the callback should be invoked based on the persistence reason.</param>
/// <returns>A subscription that can be used to unregister the callback when disposed.</returns>
public PersistingComponentStateSubscription RegisterOnPersisting(Func<Task> callback, IComponentRenderMode? renderMode, IReadOnlyList<IPersistenceReasonFilter>? reasonFilters)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IReadOnlyList? reasonFilters <= Make this mandatory

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the reasonFilters parameter mandatory in the RegisterOnPersisting overload that accepts filters. Updated the legacy overloads to pass Array.Empty<IPersistenceReasonFilter>() when no filters are provided. (a2c9f1c)

{
public Func<Task> Callback { get; } = callback;

public IComponentRenderMode? RenderMode { get; } = renderMode;

public IReadOnlyList<IPersistenceReasonFilter>? ReasonFilters { get; } = reasonFilters;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public IReadOnlyList<IPersistenceReasonFilter> ReasonFilters { get; } = reasonFilters ?? Array.Empty<IPersistenceReasonFilter>();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated PersistComponentStateRegistration.ReasonFilters property initialization to use the suggested pattern: reasonFilters ?? Array.Empty&lt;IPersistenceReasonFilter&gt;(). The property is now non-nullable and always returns a valid collection. (a2c9f1c)

Comment on lines 9 to 13
public class PersistOnPrerendering : IPersistenceReason
{
/// <inheritdoc />
public bool PersistByDefault { get; } = true;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the singleton pattern here and provide a static Instance value that gets reused everywhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the singleton pattern for all persistence reason classes. Each class (PersistOnPrerendering, PersistOnEnhancedNavigation, PersistOnCircuitPause) now has a static Instance property and private constructor. Updated all usage sites to use the singleton instances. (a2c9f1c)

Comment on lines 9 to 31
public class PersistOnPrerendering : IPersistenceReason
{
/// <inheritdoc />
public bool PersistByDefault { get; } = true;
}

/// <summary>
/// Represents persistence during enhanced navigation.
/// </summary>
public class PersistOnEnhancedNavigation : IPersistenceReason
{
/// <inheritdoc />
public bool PersistByDefault { get; }
}

/// <summary>
/// Represents persistence when a circuit is paused.
/// </summary>
public class PersistOnCircuitPause : IPersistenceReason
{
/// <inheritdoc />
public bool PersistByDefault { get; } = true;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put each class in its own file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated the persistence reason classes into individual files: PersistOnPrerendering.cs, PersistOnEnhancedNavigation.cs, and PersistOnCircuitPause.cs. Removed the original PersistenceReasons.cs file. (a2c9f1c)

Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
@Copilot Copilot AI requested a review from javiercn June 18, 2025 15:01
Copilot finished work on behalf of javiercn June 18, 2025 15:01
@javiercn
Copy link
Member

javiercn commented Jun 18, 2025

Here are instructions on how to add an E2E test to validate the changes, separated by --------------------------


Instructions for Adding E2E Tests for a Feature

1. Create Test Class Files

Create test classes that inherit from appropriate base classes and follow naming conventions:

using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETests.Tests;

public class YourFeatureTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
{
    static int _nextTestIdContext;

    public YourFeatureTest(
        BrowserFixture browserFixture,
        BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
        ITestOutputHelper output)
        : base(browserFixture, serverFixture, output)
    {
    }

    // Separate contexts to ensure that caches and other state don't interfere across tests
    public override Task InitializeAsync()
        => InitializeAsync(BrowserFixture.StreamingContext + _nextTestIdContext++);
}

2. Add Test Methods with Theory/Fact Attributes

Use [Theory] for parameterized tests and [Fact] for single-scenario tests:

[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 CanUseYourFeature(bool parameter1, Type renderMode, string parameter2)
{
    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}")
    };

    // Test implementation
    Navigate($"your-feature-page?mode={mode}");
    
    // Assertions using Browser.Equal
    Browser.Equal("Expected Value", () => Browser.FindElement(By.Id("test-element")).Text);
}

[Fact]
public void YourFeature_SpecificScenario()
{
    Navigate($"{ServerPathBase}/your-feature-specific-page");
    
    Browser.Equal("Expected Result", () => Browser.FindElement(By.Id("result")).Text);
    Browser.Click(By.Id("action-button"));
    Browser.Equal("Updated Result", () => Browser.FindElement(By.Id("result")).Text);
}

3. Create Test Pages and Components

Add Razor pages for testing in the test assets:

@page "/your-feature-page"
@using Microsoft.AspNetCore.Components.Web

<h1>Your Feature Test Page</h1>

@if (Mode == "server") {
    <YourFeatureComponent @rendermode="@RenderMode.InteractiveServer" />
}

@if (Mode == "wasm") {
    <YourFeatureComponent @rendermode="@RenderMode.InteractiveWebAssembly" />
}

@if (Mode == "auto") {
    <YourFeatureComponent @rendermode="@RenderMode.InteractiveAuto" />
}

@code {
    [Parameter, SupplyParameterFromQuery(Name = "mode")]
    public string Mode { get; set; }
}

4. Create Test Components

Add test components in the TestContentPackage:

@implements IDisposable

<p id="test-element">@_value</p>
<p id="render-mode">Render mode: <span>@_renderMode</span></p>
<button id="action-button" @onclick="PerformAction">Test Action</button>

@code {
    private string _value = "Initial Value";
    private string _renderMode = "SSR";

    protected override void OnInitialized()
    {
        _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server";
        // Initialize your feature
    }

    private void PerformAction()
    {
        _value = "Updated Value";
    }

    public void Dispose()
    {
        // Cleanup if needed
    }
}

5. Add Helper Methods for Complex Scenarios

Create helper methods for reusable test logic:

private void ValidateFeatureState(
    string expectedValue,
    string renderMode,
    bool isInteractive = true)
{
    Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text);
    Browser.Equal($"Interactive: {isInteractive}", () => Browser.FindElement(By.Id("interactive")).Text);
    Browser.Equal(expectedValue, () => Browser.FindElement(By.Id("test-element")).Text);
}

private void BlockWebAssemblyResourceLoad()
{
    ((IJavaScriptExecutor)Browser).ExecuteScript("sessionStorage.setItem('block-load-boot-resource', 'true')");
    ((IJavaScriptExecutor)Browser).ExecuteScript("caches.keys().then(keys => keys.forEach(key => caches.delete(key)))");
}

6. Add Services for Test Support (if needed)

Create services that support your testing scenarios:

namespace TestContentPackage.Services;

public class YourFeatureTestService
{
    private readonly Dictionary<string, object> _state = new();

    public void SetState(string key, object value)
    {
        _state[key] = value;
    }

    public T GetState<T>(string key)
    {
        return _state.TryGetValue(key, out var value) ? (T)value : default(T);
    }
}

7. Register Services in Test Applications

Update Program.cs files to register test services:

// ...existing code...
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddSingleton<YourFeatureTestService>();
// ...existing code...

8. Use Proper Assertions

Use Browser.Equal for element text comparisons and other Browser methods for interactions:

// Wait for element to have expected value
Browser.Equal("Expected Text", () => Browser.FindElement(By.Id("element-id")).Text);

// Click elements
Browser.Click(By.Id("button-id"));

// Check element existence
Browser.Exists(By.Id("element-id"));

// Navigate to pages
Navigate($"{ServerPathBase}/test-page?param=value");

9. Add Comments for Test Organization

Include clear comments explaining test scenarios:

// These tests validate that your feature works correctly across different render modes
// and scenarios including enhanced navigation, streaming rendering, and state persistence
[Theory]
[InlineData("server")]
[InlineData("wasm")]
[InlineData("auto")]
public void YourFeature_WorksAcrossRenderModes(string renderMode)
{
    // Arrange: Navigate to test page
    Navigate($"your-feature-page?mode={renderMode}");
    
    // Act: Perform feature action
    Browser.Click(By.Id("trigger-feature"));
    
    // Assert: Verify expected behavior
    Browser.Equal("Feature Activated", () => Browser.FindElement(By.Id("status")).Text);
}

Use these instructions to add an E2E test for filtering

…fault reason, implement singleton pattern

Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
Copilot finished work on behalf of javiercn June 18, 2025 15:31
Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jun 18, 2025

Here are instructions on how to add an E2E test to validate the changes, separated by --------------------------


...

Added comprehensive E2E tests for the persistent component state filtering functionality. The tests validate:

  1. Basic filtering behavior - Components can selectively persist state based on persistence reasons (prerendering, enhanced navigation, circuit pause)
  2. Enhanced navigation filtering - State persistence can be controlled differently for enhanced navigation scenarios
  3. Prerendering filter disabling - Ability to disable persistence during prerendering when needed

Test Components Created:

  • FilteredPersistentStateComponent.razor - Demonstrates different filter combinations and validates selective persistence
  • FilteringTestPage.razor - Test page for E2E scenarios with parameterized filter configurations

Test Methods Added:

  • CanFilterPersistentStateCallbacks - Tests filtering across server/wasm/auto modes with enhanced navigation
  • CanFilterPersistentStateForEnhancedNavigation - Validates enhanced navigation-specific filtering behavior
  • CanDisablePersistenceForPrerendering - Tests disabling persistence during prerendering

The tests cover all render modes (server, WebAssembly, auto) and validate that the filtering logic works correctly for different persistence scenarios. (52e8488)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Blazor] Provide the ability to filter persistent component state callbacks based on the reason for persistence
2 participants