Skip to content

[Blazor] Add IPersistentComponentStateSerializer<T> interface for custom serialization extensibility #62559

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

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e2d0602
Initial plan
Copilot Jul 3, 2025
726d6f5
Initial planning for persistent component state serialization extensi…
Copilot Jul 3, 2025
efb1c3b
Implement IPersistentComponentStateSerializer interface and core func…
Copilot Jul 3, 2025
6f2f33c
Add comprehensive tests and finalize serialization extensibility impl…
Copilot Jul 3, 2025
147b5bc
Make PersistAsync and TryTake methods internal for custom serializers
Copilot Jul 3, 2025
d7ed4d9
Implement interface architecture changes for custom serializers per f…
Copilot Jul 3, 2025
fc6a4d9
Fix serializer cache to be instance-based to prevent cross-test conta…
Copilot Jul 3, 2025
358f9e4
Make IPersistentComponentStateSerializer interface internal per code …
Copilot Jul 3, 2025
4c3dcac
Make IPersistentComponentStateSerializer&lt;T&gt; public while keepin…
Copilot Jul 3, 2025
df6eb65
Move PersistAsync and TryTake methods to PersistentStateValueProvider…
Copilot Jul 3, 2025
cc5109e
Add custom serializer support to E2E tests for persistent component s…
Copilot Jul 3, 2025
2085568
Fix CA1310 analyzer error by specifying StringComparison.Ordinal in C…
Copilot Jul 3, 2025
b6723a6
Move CustomIntSerializer to shared TestContentPackage and register in…
Copilot Jul 3, 2025
67ca5ec
Register CustomIntSerializer in Components.WasmMinimal for E2E test c…
Copilot Jul 4, 2025
c1fefc5
Make IPersistentComponentStateSerializer internal and move generic in…
Copilot Jul 4, 2025
cbf9fa9
Implement explicit interface implementation for custom serializers an…
Copilot Jul 4, 2025
365064d
Implement explicit interface implementation in IPersistentComponentSt…
Copilot Jul 4, 2025
3b3af1d
Remove redundant CustomIntSerializer registration from BasicTestApp
Copilot Jul 4, 2025
970977b
Convert IPersistentComponentStateSerializer&lt;T&gt; interface to abs…
Copilot Jul 4, 2025
0d5d8df
Make PersistAsync method void returning instead of Task returning
Copilot Jul 4, 2025
e922076
Inline ResolveSerializer method call in PersistentStateValueProvider
Copilot Jul 4, 2025
ebe6f52
Drop "Async" suffix from Persist method names since they now return void
Copilot Jul 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified activate.sh
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Provides custom serialization logic for persistent component state values of type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of the value to serialize.</typeparam>
public interface IPersistentComponentStateSerializer<T>
{
/// <summary>
/// Serializes the provided <paramref name="value"/> and writes it to the <paramref name="writer"/>.
/// </summary>
/// <param name="value">The value to serialize.</param>
/// <param name="writer">The buffer writer to write the serialized data to.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the serialization operation.</param>
/// <returns>A task that represents the asynchronous serialization operation.</returns>
Task PersistAsync(T value, IBufferWriter<byte> writer, CancellationToken cancellationToken);
Copy link
Member

Choose a reason for hiding this comment

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

No cancellation token

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 cancellation token parameter from the PersistAsync method as requested. Updated both the interface definition and all related implementations. Commit d7ed4d9


/// <summary>
/// Deserializes a value of type <typeparamref name="T"/> from the provided <paramref name="data"/>.
/// This method must be synchronous to avoid UI tearing during component state restoration.
/// </summary>
/// <param name="data">The serialized data to deserialize.</param>
/// <returns>The deserialized value.</returns>
T Restore(ReadOnlySequence<byte> data);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadManager.cs" LinkBase="HotReload" />
<Compile Include="$(ComponentsSharedSourceRoot)src\RootTypeCache.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)PooledArrayBufferWriter.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryItemDebugView.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryDebugView.cs" LinkBase="Shared" />
Expand Down
57 changes: 57 additions & 0 deletions src/Components/Components/src/PersistentComponentState.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using static Microsoft.AspNetCore.Internal.LinkerFlags;
Expand Down Expand Up @@ -110,6 +111,34 @@ internal void PersistAsJson(string key, object instance, [DynamicallyAccessedMem
_currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, type, JsonSerializerOptionsProvider.Options));
}

/// <summary>
/// Serializes <paramref name="instance"/> using the provided <paramref name="serializer"/> and persists it under the given <paramref name="key"/>.
/// </summary>
/// <typeparam name="TValue">The <paramref name="instance"/> type.</typeparam>
/// <param name="key">The key to use to persist the state.</param>
/// <param name="instance">The instance to persist.</param>
/// <param name="serializer">The custom serializer to use for serialization.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the serialization operation.</param>
public async Task PersistAsync<TValue>(string key, TValue instance, IPersistentComponentStateSerializer<TValue> serializer, CancellationToken cancellationToken = default)
Copy link
Member

Choose a reason for hiding this comment

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

Make this internal

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 this method internal as requested, along with the corresponding TryTake method for consistency. Updated reflection calls in PersistentStateValueProvider to use NonPublic binding flags. Commit 147b5bc

{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(serializer);

if (!PersistingState)
{
throw new InvalidOperationException("Persisting state is only allowed during an OnPersisting callback.");
}

if (_currentState.ContainsKey(key))
{
throw new ArgumentException($"There is already a persisted object under the same key '{key}'");
}

using var writer = new PooledArrayBufferWriter<byte>();
await serializer.PersistAsync(instance, writer, cancellationToken);
_currentState.Add(key, writer.WrittenMemory.ToArray());
}

/// <summary>
/// Tries to retrieve the persisted state as JSON with the given <paramref name="key"/> and deserializes it into an
/// instance of type <typeparamref name="TValue"/>.
Expand Down Expand Up @@ -155,6 +184,34 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial
}
}

/// <summary>
/// Tries to retrieve the persisted state with the given <paramref name="key"/> and deserializes it using the provided <paramref name="serializer"/> into an
/// instance of type <typeparamref name="TValue"/>.
/// When the key is present, the state is successfully returned via <paramref name="instance"/>
/// and removed from the <see cref="PersistentComponentState"/>.
/// </summary>
/// <param name="key">The key used to persist the instance.</param>
/// <param name="serializer">The custom serializer to use for deserialization.</param>
/// <param name="instance">The persisted instance.</param>
/// <returns><c>true</c> if the state was found; <c>false</c> otherwise.</returns>
public bool TryTake<TValue>(string key, IPersistentComponentStateSerializer<TValue> serializer, [MaybeNullWhen(false)] out TValue instance)
Copy link
Member

Choose a reason for hiding this comment

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

Make this API internal

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 TryTake API internal as requested. This was already done in the previous commit but confirmed it's correct. The method is now properly scoped to internal access only. Commit d7ed4d9

{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(serializer);

if (TryTake(key, out var data))
{
var sequence = new ReadOnlySequence<byte>(data!);
instance = serializer.Restore(sequence);
return true;
}
else
{
instance = default;
return false;
}
}

private bool TryTake(string key, out byte[]? value)
{
ArgumentNullException.ThrowIfNull(key);
Expand Down
42 changes: 38 additions & 4 deletions src/Components/Components/src/PersistentStateValueProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

namespace Microsoft.AspNetCore.Components.Infrastructure;

internal sealed class PersistentStateValueProvider(PersistentComponentState state) : ICascadingValueSupplier
internal sealed class PersistentStateValueProvider(PersistentComponentState state, IServiceProvider serviceProvider) : ICascadingValueSupplier
{
private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new();
private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new();
Expand All @@ -42,6 +42,23 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo)
var componentState = (ComponentState)key!;
var storageKey = ComputeKey(componentState, parameterInfo.PropertyName);

// Try to get a custom serializer for this type first
var serializerType = typeof(IPersistentComponentStateSerializer<>).MakeGenericType(parameterInfo.PropertyType);
var customSerializer = serviceProvider.GetService(serializerType);

if (customSerializer != null)
{
// Use reflection to call the generic TryTake method with the custom serializer
var tryTakeMethod = typeof(PersistentComponentState).GetMethod(nameof(PersistentComponentState.TryTake), BindingFlags.Instance | BindingFlags.Public, [typeof(string), serializerType, parameterInfo.PropertyType.MakeByRefType()]);
if (tryTakeMethod != null)
{
var parameters = new object?[] { storageKey, customSerializer, null };
var success = (bool)tryTakeMethod.Invoke(state, parameters)!;
return success ? parameters[2] : null;
}
}

// Fallback to JSON serialization
return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null;
}

Expand All @@ -52,17 +69,34 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param
{
var propertyName = parameterInfo.PropertyName;
var propertyType = parameterInfo.PropertyType;
_subscriptions[subscriber] = state.RegisterOnPersisting(() =>
_subscriptions[subscriber] = state.RegisterOnPersisting(async () =>
{
var storageKey = ComputeKey(subscriber, propertyName);
var propertyGetter = ResolvePropertyGetter(subscriber.Component.GetType(), propertyName);
var property = propertyGetter.GetValue(subscriber.Component);
if (property == null)
{
return Task.CompletedTask;
return;
}

// Try to get a custom serializer for this type first
var serializerType = typeof(IPersistentComponentStateSerializer<>).MakeGenericType(propertyType);
var customSerializer = serviceProvider.GetService(serializerType);

if (customSerializer != null)
{
// Use reflection to call the generic PersistAsync method with the custom serializer
var persistMethod = typeof(PersistentComponentState).GetMethod(nameof(PersistentComponentState.PersistAsync), BindingFlags.Instance | BindingFlags.Public, [typeof(string), propertyType, serializerType, typeof(CancellationToken)]);
if (persistMethod != null)
{
var task = (Task)persistMethod.Invoke(state, [storageKey, property, customSerializer, CancellationToken.None])!;
await task;
return;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Cache the serializers by type on a ConcurrentDictionary<Type, IPersistentComponentStateSerializer> (see how it's done in this file.

IPersistentComponentStateSerializer should be an internal interface, have the same methods as IPersistentComponentStateSerializer but take the Type type argument as a first argument.

IPersistentComponentStateSerializer<T> should implement IPersistentComponentStateSerializer with a default interface implementation that calls into Persist no matter the Type t passed as first argument

Copy link
Member

Choose a reason for hiding this comment

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

Move this code outside of the lambda.

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 requested architecture changes:

  • Added ConcurrentDictionary<Type, IPersistentComponentStateSerializer> for serializer caching (made instance-based to avoid cross-test contamination)
  • Created internal IPersistentComponentStateSerializer base interface with Type parameter
  • Made IPersistentComponentStateSerializer implement the base interface with default implementations
  • Moved serializer resolution code outside of lambda for better performance
  • Used non-generic interface methods to avoid reflection

Commit d7ed4d9


// Fallback to JSON serialization
state.PersistAsJson(storageKey, property, propertyType);
return Task.CompletedTask;
}, subscriber.Renderer.GetComponentRenderMode(subscriber.Component));
}

Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCo
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
static Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object?
Microsoft.AspNetCore.Components.IPersistentComponentStateSerializer<T>
Microsoft.AspNetCore.Components.IPersistentComponentStateSerializer<T>.PersistAsync(T value, System.Buffers.IBufferWriter<byte>! writer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.IPersistentComponentStateSerializer<T>.Restore(System.Buffers.ReadOnlySequence<byte> data) -> T
Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsync<TValue>(string! key, TValue instance, Microsoft.AspNetCore.Components.IPersistentComponentStateSerializer<TValue>! serializer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.PersistentComponentState.TryTake<TValue>(string! key, Microsoft.AspNetCore.Components.IPersistentComponentStateSerializer<TValue>! serializer, out TValue instance) -> bool
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Components;

public class IPersistentComponentStateSerializerTests
{
[Fact]
public async Task PersistAsync_CanUseCustomSerializer()
{
// Arrange
var currentState = new Dictionary<string, byte[]>();
var state = new PersistentComponentState(currentState, []);
var customSerializer = new TestStringSerializer();
var testValue = "Hello, World!";

state.PersistingState = true;

// Act
await state.PersistAsync("test-key", testValue, customSerializer);

// Assert
state.PersistingState = false;

// Simulate the state transfer that happens between persist and restore phases
var newState = new PersistentComponentState(new Dictionary<string, byte[]>(), []);
newState.InitializeExistingState(currentState);

Assert.True(newState.TryTake("test-key", customSerializer, out var retrievedValue));
Assert.Equal(testValue, retrievedValue);
}

[Fact]
public void TryTake_CanUseCustomSerializer()
{
// Arrange
var customData = "Custom Data";
var customBytes = Encoding.UTF8.GetBytes(customData);
var existingState = new Dictionary<string, byte[]> { { "test-key", customBytes } };

var state = new PersistentComponentState(new Dictionary<string, byte[]>(), []);
state.InitializeExistingState(existingState);

var customSerializer = new TestStringSerializer();

// Act
var success = state.TryTake("test-key", customSerializer, out var retrievedValue);

// Assert
Assert.True(success);
Assert.Equal(customData, retrievedValue);
}

private class TestStringSerializer : IPersistentComponentStateSerializer<string>
{
public Task PersistAsync(string value, IBufferWriter<byte> writer, CancellationToken cancellationToken)
{
var bytes = Encoding.UTF8.GetBytes(value);
writer.Write(bytes);
return Task.CompletedTask;
}

public string Restore(ReadOnlySequence<byte> data)
{
var bytes = data.ToArray();
return Encoding.UTF8.GetString(bytes);
}
}
}
Loading
Loading