-
Notifications
You must be signed in to change notification settings - Fork 10.4k
[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
base: main
Are you sure you want to change the base?
[Blazor] Add IPersistentComponentStateSerializer<T> interface for custom serialization extensibility #62559
Changes from 16 commits
e2d0602
726d6f5
efb1c3b
6f2f33c
147b5bc
d7ed4d9
fc6a4d9
358f9e4
4c3dcac
df6eb65
cc5109e
2085568
b6723a6
67ca5ec
c1fefc5
cbf9fa9
365064d
3b3af1d
970977b
0d5d8df
e922076
ebe6f52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
// 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; | ||
|
||
internal interface IPersistentComponentStateSerializer | ||
{ | ||
Task PersistAsync(Type type, object value, IBufferWriter<byte> writer); | ||
object Restore(Type type, ReadOnlySequence<byte> data); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
// 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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to have an explicit interface implementation of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implemented explicit interface implementation in the SerializerAdapter class since C# doesn't allow public interfaces to inherit from internal interfaces. The adapter now uses explicit interface implementation for IPersistentComponentStateSerializer methods. Commit cbf9fa9 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrong again. This is what we want (with comments, formatting, etc where appropriate).
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implemented the requested architecture:
The generic interface now properly implements the base interface with explicit implementations as requested. Commit 365064d |
||
{ | ||
/// <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> | ||
/// <returns>A task that represents the asynchronous serialization operation.</returns> | ||
Task PersistAsync(T value, IBufferWriter<byte> writer); | ||
|
||
/// <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 |
---|---|---|
|
@@ -15,10 +15,11 @@ | |
|
||
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(); | ||
private readonly ConcurrentDictionary<Type, IPersistentComponentStateSerializer?> _serializerCache = new(); | ||
|
||
private readonly Dictionary<ComponentState, PersistingComponentStateSubscription> _subscriptions = []; | ||
|
||
|
@@ -42,6 +43,20 @@ 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 customSerializer = ResolveSerializer(parameterInfo.PropertyType); | ||
|
||
if (customSerializer != null) | ||
{ | ||
if (state.TryTakeBytes(storageKey, out var data)) | ||
{ | ||
var sequence = new ReadOnlySequence<byte>(data!); | ||
return customSerializer.Restore(parameterInfo.PropertyType, sequence); | ||
} | ||
return null; | ||
} | ||
|
||
// Fallback to JSON serialization | ||
return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; | ||
} | ||
|
||
|
@@ -52,17 +67,30 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param | |
{ | ||
var propertyName = parameterInfo.PropertyName; | ||
var propertyType = parameterInfo.PropertyType; | ||
_subscriptions[subscriber] = state.RegisterOnPersisting(() => | ||
|
||
// Resolve serializer outside the lambda | ||
var customSerializer = ResolveSerializer(propertyType); | ||
|
||
_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; | ||
} | ||
|
||
if (customSerializer != null) | ||
{ | ||
using var writer = new PooledArrayBufferWriter<byte>(); | ||
await customSerializer.PersistAsync(propertyType, property, writer); | ||
state.PersistAsBytes(storageKey, writer.WrittenMemory.ToArray()); | ||
return; | ||
} | ||
|
||
// Fallback to JSON serialization | ||
state.PersistAsJson(storageKey, property, propertyType); | ||
return Task.CompletedTask; | ||
}, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); | ||
} | ||
|
||
|
@@ -71,6 +99,26 @@ private static PropertyGetter ResolvePropertyGetter(Type type, string propertyNa | |
return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory); | ||
} | ||
|
||
private IPersistentComponentStateSerializer? ResolveSerializer(Type type) | ||
{ | ||
return _serializerCache.GetOrAdd(type, SerializerFactory); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inline this method call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inlined the ResolveSerializer method call at both usage sites and removed the method definition. Commit e922076 |
||
|
||
private IPersistentComponentStateSerializer? SerializerFactory(Type type) | ||
{ | ||
var serializerType = typeof(IPersistentComponentStateSerializer<>).MakeGenericType(type); | ||
var serializer = serviceProvider.GetService(serializerType); | ||
|
||
if (serializer != null) | ||
{ | ||
// Create an adapter that implements the internal interface | ||
var adapterType = typeof(SerializerAdapter<>).MakeGenericType(type); | ||
return (IPersistentComponentStateSerializer?)Activator.CreateInstance(adapterType, serializer); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
[UnconditionalSuppressMessage( | ||
"Trimming", | ||
"IL2077:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The source field does not have matching annotations.", | ||
|
@@ -281,4 +329,58 @@ private static bool IsSerializableKey(object key) | |
|
||
return result; | ||
} | ||
|
||
/// <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> | ||
internal async Task PersistAsync<TValue>(string key, TValue instance, IPersistentComponentStateSerializer<TValue> serializer) | ||
{ | ||
ArgumentNullException.ThrowIfNull(key); | ||
ArgumentNullException.ThrowIfNull(serializer); | ||
|
||
using var writer = new PooledArrayBufferWriter<byte>(); | ||
await serializer.PersistAsync(instance, writer); | ||
state.PersistAsBytes(key, writer.WrittenMemory.ToArray()); | ||
} | ||
|
||
/// <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> | ||
internal bool TryTake<TValue>(string key, IPersistentComponentStateSerializer<TValue> serializer, [MaybeNullWhen(false)] out TValue instance) | ||
{ | ||
ArgumentNullException.ThrowIfNull(key); | ||
ArgumentNullException.ThrowIfNull(serializer); | ||
|
||
if (state.TryTakeBytes(key, out var data)) | ||
{ | ||
var sequence = new ReadOnlySequence<byte>(data!); | ||
instance = serializer.Restore(sequence); | ||
return true; | ||
} | ||
else | ||
{ | ||
instance = default; | ||
return false; | ||
} | ||
} | ||
|
||
private sealed class SerializerAdapter<T>(IPersistentComponentStateSerializer<T> serializer) : IPersistentComponentStateSerializer | ||
{ | ||
Task IPersistentComponentStateSerializer.PersistAsync(Type type, object value, IBufferWriter<byte> writer) | ||
=> serializer.PersistAsync((T)value, writer); | ||
|
||
object IPersistentComponentStateSerializer.Restore(Type type, ReadOnlySequence<byte> data) | ||
=> serializer.Restore(data)!; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
// 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 System.Text.Json; | ||
using Microsoft.AspNetCore.Components.Infrastructure; | ||
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 serviceProvider = new ServiceCollection().BuildServiceProvider(); | ||
var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider); | ||
var customSerializer = new TestStringSerializer(); | ||
var testValue = "Hello, World!"; | ||
|
||
state.PersistingState = true; | ||
|
||
// Act | ||
await stateValueProvider.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); | ||
var newStateValueProvider = new PersistentStateValueProvider(newState, serviceProvider); | ||
|
||
Assert.True(newStateValueProvider.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 serviceProvider = new ServiceCollection().BuildServiceProvider(); | ||
var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider); | ||
var customSerializer = new TestStringSerializer(); | ||
|
||
// Act | ||
var success = stateValueProvider.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) | ||
{ | ||
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); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drop the task and the async suffix (return void)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The interface method is already void returning as requested. This was addressed in a previous commit (0d5d8df).