-
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 3 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,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); | ||
|
||
/// <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 |
---|---|---|
@@ -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; | ||
|
@@ -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) | ||
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. Make this internal 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. 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"/>. | ||
|
@@ -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) | ||
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. Make this API internal 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. 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); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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(); | ||
|
@@ -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; | ||
} | ||
|
||
|
@@ -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; | ||
} | ||
} | ||
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. Cache the serializers by type on a IPersistentComponentStateSerializer should be an internal interface, have the same methods as IPersistentComponentStateSerializer but take the
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. Move this code outside of the lambda. 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 changes:
Commit d7ed4d9 |
||
|
||
// Fallback to JSON serialization | ||
state.PersistAsJson(storageKey, property, propertyType); | ||
return Task.CompletedTask; | ||
}, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); | ||
} | ||
|
||
|
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); | ||
} | ||
} | ||
} |
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.
No cancellation token
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.
Removed the cancellation token parameter from the PersistAsync method as requested. Updated both the interface definition and all related implementations. Commit d7ed4d9