Skip to content

Commit 8883b98

Browse files
authored
[Blazor] Add IPersistentComponentStateSerializer<T> interface for custom serialization extensibility (#62559)
* Implements support for custom serialization on the declarative persistent component model. * Developers can register an instance of `builder.Services.AddSingleton<PersistentComponentStateSerializer<TData>, CustomSerializer>();` to handle serialization of persistent component state properties of that type.
1 parent c3fa2bb commit 8883b98

15 files changed

+329
-17
lines changed

activate.sh

100644100755
File mode changed.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
6+
namespace Microsoft.AspNetCore.Components;
7+
8+
internal interface IPersistentComponentStateSerializer
9+
{
10+
void Persist(Type type, object value, IBufferWriter<byte> writer);
11+
object Restore(Type type, ReadOnlySequence<byte> data);
12+
}

src/Components/Components/src/Microsoft.AspNetCore.Components.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadManager.cs" LinkBase="HotReload" />
2020
<Compile Include="$(ComponentsSharedSourceRoot)src\RootTypeCache.cs" LinkBase="Shared" />
2121
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
22+
<Compile Include="$(SharedSourceRoot)PooledArrayBufferWriter.cs" LinkBase="Shared" />
2223
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
2324
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryItemDebugView.cs" LinkBase="Shared" />
2425
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryDebugView.cs" LinkBase="Shared" />

src/Components/Components/src/PersistentComponentState.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,28 @@ internal void PersistAsJson(string key, object instance, [DynamicallyAccessedMem
110110
_currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, type, JsonSerializerOptionsProvider.Options));
111111
}
112112

113+
/// <summary>
114+
/// Persists the provided byte array under the given key.
115+
/// </summary>
116+
/// <param name="key">The key to use to persist the state.</param>
117+
/// <param name="data">The byte array to persist.</param>
118+
internal void PersistAsBytes(string key, byte[] data)
119+
{
120+
ArgumentNullException.ThrowIfNull(key);
121+
122+
if (!PersistingState)
123+
{
124+
throw new InvalidOperationException("Persisting state is only allowed during an OnPersisting callback.");
125+
}
126+
127+
if (_currentState.ContainsKey(key))
128+
{
129+
throw new ArgumentException($"There is already a persisted object under the same key '{key}'");
130+
}
131+
132+
_currentState.Add(key, data);
133+
}
134+
113135
/// <summary>
114136
/// Tries to retrieve the persisted state as JSON with the given <paramref name="key"/> and deserializes it into an
115137
/// instance of type <typeparamref name="TValue"/>.
@@ -155,6 +177,19 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial
155177
}
156178
}
157179

180+
/// <summary>
181+
/// Tries to retrieve the persisted state as raw bytes with the given <paramref name="key"/>.
182+
/// When the key is present, the raw bytes are successfully returned via <paramref name="data"/>
183+
/// and removed from the <see cref="PersistentComponentState"/>.
184+
/// </summary>
185+
/// <param name="key">The key used to persist the data.</param>
186+
/// <param name="data">The persisted raw bytes.</param>
187+
/// <returns><c>true</c> if the state was found; <c>false</c> otherwise.</returns>
188+
internal bool TryTakeBytes(string key, [MaybeNullWhen(false)] out byte[]? data)
189+
{
190+
return TryTake(key, out data);
191+
}
192+
158193
private bool TryTake(string key, out byte[]? value)
159194
{
160195
ArgumentNullException.ThrowIfNull(key);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
6+
namespace Microsoft.AspNetCore.Components;
7+
8+
/// <summary>
9+
/// Provides custom serialization logic for persistent component state values of type <typeparamref name="T"/>.
10+
/// </summary>
11+
/// <typeparam name="T">The type of the value to serialize.</typeparam>
12+
public abstract class PersistentComponentStateSerializer<T> : IPersistentComponentStateSerializer
13+
{
14+
/// <summary>
15+
/// Serializes the provided <paramref name="value"/> and writes it to the <paramref name="writer"/>.
16+
/// </summary>
17+
/// <param name="value">The value to serialize.</param>
18+
/// <param name="writer">The buffer writer to write the serialized data to.</param>
19+
public abstract void Persist(T value, IBufferWriter<byte> writer);
20+
21+
/// <summary>
22+
/// Deserializes a value of type <typeparamref name="T"/> from the provided <paramref name="data"/>.
23+
/// This method must be synchronous to avoid UI tearing during component state restoration.
24+
/// </summary>
25+
/// <param name="data">The serialized data to deserialize.</param>
26+
/// <returns>The deserialized value.</returns>
27+
public abstract T Restore(ReadOnlySequence<byte> data);
28+
29+
/// <summary>
30+
/// Explicit interface implementation for non-generic serialization.
31+
/// </summary>
32+
void IPersistentComponentStateSerializer.Persist(Type type, object value, IBufferWriter<byte> writer)
33+
=> Persist((T)value, writer);
34+
35+
/// <summary>
36+
/// Explicit interface implementation for non-generic deserialization.
37+
/// </summary>
38+
object IPersistentComponentStateSerializer.Restore(Type type, ReadOnlySequence<byte> data)
39+
=> Restore(data)!;
40+
}

src/Components/Components/src/PersistentStateValueProvider.cs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515

1616
namespace Microsoft.AspNetCore.Components.Infrastructure;
1717

18-
internal sealed class PersistentStateValueProvider(PersistentComponentState state) : ICascadingValueSupplier
18+
internal sealed class PersistentStateValueProvider(PersistentComponentState state, IServiceProvider serviceProvider) : ICascadingValueSupplier
1919
{
2020
private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new();
2121
private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new();
22+
private readonly ConcurrentDictionary<Type, IPersistentComponentStateSerializer?> _serializerCache = new();
2223

2324
private readonly Dictionary<ComponentState, PersistingComponentStateSubscription> _subscriptions = [];
2425

@@ -42,6 +43,20 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo)
4243
var componentState = (ComponentState)key!;
4344
var storageKey = ComputeKey(componentState, parameterInfo.PropertyName);
4445

46+
// Try to get a custom serializer for this type first
47+
var customSerializer = _serializerCache.GetOrAdd(parameterInfo.PropertyType, SerializerFactory);
48+
49+
if (customSerializer != null)
50+
{
51+
if (state.TryTakeBytes(storageKey, out var data))
52+
{
53+
var sequence = new ReadOnlySequence<byte>(data!);
54+
return customSerializer.Restore(parameterInfo.PropertyType, sequence);
55+
}
56+
return null;
57+
}
58+
59+
// Fallback to JSON serialization
4560
return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null;
4661
}
4762

@@ -52,6 +67,10 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param
5267
{
5368
var propertyName = parameterInfo.PropertyName;
5469
var propertyType = parameterInfo.PropertyType;
70+
71+
// Resolve serializer outside the lambda
72+
var customSerializer = _serializerCache.GetOrAdd(propertyType, SerializerFactory);
73+
5574
_subscriptions[subscriber] = state.RegisterOnPersisting(() =>
5675
{
5776
var storageKey = ComputeKey(subscriber, propertyName);
@@ -61,6 +80,16 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param
6180
{
6281
return Task.CompletedTask;
6382
}
83+
84+
if (customSerializer != null)
85+
{
86+
using var writer = new PooledArrayBufferWriter<byte>();
87+
customSerializer.Persist(propertyType, property, writer);
88+
state.PersistAsBytes(storageKey, writer.WrittenMemory.ToArray());
89+
return Task.CompletedTask;
90+
}
91+
92+
// Fallback to JSON serialization
6493
state.PersistAsJson(storageKey, property, propertyType);
6594
return Task.CompletedTask;
6695
}, subscriber.Renderer.GetComponentRenderMode(subscriber.Component));
@@ -71,6 +100,15 @@ private static PropertyGetter ResolvePropertyGetter(Type type, string propertyNa
71100
return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory);
72101
}
73102

103+
private IPersistentComponentStateSerializer? SerializerFactory(Type type)
104+
{
105+
var serializerType = typeof(PersistentComponentStateSerializer<>).MakeGenericType(type);
106+
var serializer = serviceProvider.GetService(serializerType);
107+
108+
// The generic class now inherits from the internal interface, so we can cast directly
109+
return serializer as IPersistentComponentStateSerializer;
110+
}
111+
74112
[UnconditionalSuppressMessage(
75113
"Trimming",
76114
"IL2077:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The source field does not have matching annotations.",
@@ -281,4 +319,49 @@ private static bool IsSerializableKey(object key)
281319

282320
return result;
283321
}
322+
323+
/// <summary>
324+
/// Serializes <paramref name="instance"/> using the provided <paramref name="serializer"/> and persists it under the given <paramref name="key"/>.
325+
/// </summary>
326+
/// <typeparam name="TValue">The <paramref name="instance"/> type.</typeparam>
327+
/// <param name="key">The key to use to persist the state.</param>
328+
/// <param name="instance">The instance to persist.</param>
329+
/// <param name="serializer">The custom serializer to use for serialization.</param>
330+
internal void PersistAsync<TValue>(string key, TValue instance, PersistentComponentStateSerializer<TValue> serializer)
331+
{
332+
ArgumentNullException.ThrowIfNull(key);
333+
ArgumentNullException.ThrowIfNull(serializer);
334+
335+
using var writer = new PooledArrayBufferWriter<byte>();
336+
serializer.Persist(instance, writer);
337+
state.PersistAsBytes(key, writer.WrittenMemory.ToArray());
338+
}
339+
340+
/// <summary>
341+
/// Tries to retrieve the persisted state with the given <paramref name="key"/> and deserializes it using the provided <paramref name="serializer"/> into an
342+
/// instance of type <typeparamref name="TValue"/>.
343+
/// When the key is present, the state is successfully returned via <paramref name="instance"/>
344+
/// and removed from the <see cref="PersistentComponentState"/>.
345+
/// </summary>
346+
/// <param name="key">The key used to persist the instance.</param>
347+
/// <param name="serializer">The custom serializer to use for deserialization.</param>
348+
/// <param name="instance">The persisted instance.</param>
349+
/// <returns><c>true</c> if the state was found; <c>false</c> otherwise.</returns>
350+
internal bool TryTake<TValue>(string key, PersistentComponentStateSerializer<TValue> serializer, [MaybeNullWhen(false)] out TValue instance)
351+
{
352+
ArgumentNullException.ThrowIfNull(key);
353+
ArgumentNullException.ThrowIfNull(serializer);
354+
355+
if (state.TryTakeBytes(key, out var data))
356+
{
357+
var sequence = new ReadOnlySequence<byte>(data!);
358+
instance = serializer.Restore(sequence);
359+
return true;
360+
}
361+
else
362+
{
363+
instance = default;
364+
return false;
365+
}
366+
}
284367
}

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateS
1616
Microsoft.AspNetCore.Components.PersistentStateAttribute
1717
Microsoft.AspNetCore.Components.PersistentStateAttribute.PersistentStateAttribute() -> void
1818
Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions
19+
Microsoft.AspNetCore.Components.PersistentComponentStateSerializer<T>
20+
Microsoft.AspNetCore.Components.PersistentComponentStateSerializer<T>.PersistentComponentStateSerializer() -> void
21+
abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer<T>.Persist(T value, System.Buffers.IBufferWriter<byte>! writer) -> void
22+
abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer<T>.Restore(System.Buffers.ReadOnlySequence<byte> data) -> T
1923
static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<TService>(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2024
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
2125
static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers;
5+
using System.Text;
6+
using System.Text.Json;
7+
using Microsoft.AspNetCore.Components.Infrastructure;
8+
using Microsoft.Extensions.DependencyInjection;
9+
10+
namespace Microsoft.AspNetCore.Components;
11+
12+
public class IPersistentComponentStateSerializerTests
13+
{
14+
[Fact]
15+
public void PersistAsync_CanUseCustomSerializer()
16+
{
17+
// Arrange
18+
var currentState = new Dictionary<string, byte[]>();
19+
var state = new PersistentComponentState(currentState, []);
20+
var serviceProvider = new ServiceCollection().BuildServiceProvider();
21+
var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider);
22+
var customSerializer = new TestStringSerializer();
23+
var testValue = "Hello, World!";
24+
25+
state.PersistingState = true;
26+
27+
// Act
28+
stateValueProvider.PersistAsync("test-key", testValue, customSerializer);
29+
30+
// Assert
31+
state.PersistingState = false;
32+
33+
// Simulate the state transfer that happens between persist and restore phases
34+
var newState = new PersistentComponentState(new Dictionary<string, byte[]>(), []);
35+
newState.InitializeExistingState(currentState);
36+
var newStateValueProvider = new PersistentStateValueProvider(newState, serviceProvider);
37+
38+
Assert.True(newStateValueProvider.TryTake("test-key", customSerializer, out var retrievedValue));
39+
Assert.Equal(testValue, retrievedValue);
40+
}
41+
42+
[Fact]
43+
public void TryTake_CanUseCustomSerializer()
44+
{
45+
// Arrange
46+
var customData = "Custom Data";
47+
var customBytes = Encoding.UTF8.GetBytes(customData);
48+
var existingState = new Dictionary<string, byte[]> { { "test-key", customBytes } };
49+
50+
var state = new PersistentComponentState(new Dictionary<string, byte[]>(), []);
51+
state.InitializeExistingState(existingState);
52+
53+
var serviceProvider = new ServiceCollection().BuildServiceProvider();
54+
var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider);
55+
var customSerializer = new TestStringSerializer();
56+
57+
// Act
58+
var success = stateValueProvider.TryTake("test-key", customSerializer, out var retrievedValue);
59+
60+
// Assert
61+
Assert.True(success);
62+
Assert.Equal(customData, retrievedValue);
63+
}
64+
65+
private class TestStringSerializer : PersistentComponentStateSerializer<string>
66+
{
67+
public override void Persist(string value, IBufferWriter<byte> writer)
68+
{
69+
var bytes = Encoding.UTF8.GetBytes(value);
70+
writer.Write(bytes);
71+
}
72+
73+
public override string Restore(ReadOnlySequence<byte> data)
74+
{
75+
var bytes = data.ToArray();
76+
return Encoding.UTF8.GetString(bytes);
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)