diff --git a/activate.sh b/activate.sh old mode 100644 new mode 100755 diff --git a/src/Components/Components/src/IPersistentComponentStateSerializer.cs b/src/Components/Components/src/IPersistentComponentStateSerializer.cs new file mode 100644 index 000000000000..bcbfebbacf0a --- /dev/null +++ b/src/Components/Components/src/IPersistentComponentStateSerializer.cs @@ -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 +{ + void Persist(Type type, object value, IBufferWriter writer); + object Restore(Type type, ReadOnlySequence data); +} \ No newline at end of file diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index ea4689586d69..8d36fe78ad80 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a3dd2fdddc81..d3f5e9fd9309 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -110,6 +110,28 @@ internal void PersistAsJson(string key, object instance, [DynamicallyAccessedMem _currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, type, JsonSerializerOptionsProvider.Options)); } + /// + /// Persists the provided byte array under the given key. + /// + /// The key to use to persist the state. + /// The byte array to persist. + internal void PersistAsBytes(string key, byte[] data) + { + ArgumentNullException.ThrowIfNull(key); + + 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}'"); + } + + _currentState.Add(key, data); + } + /// /// Tries to retrieve the persisted state as JSON with the given and deserializes it into an /// instance of type . @@ -155,6 +177,19 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial } } + /// + /// Tries to retrieve the persisted state as raw bytes with the given . + /// When the key is present, the raw bytes are successfully returned via + /// and removed from the . + /// + /// The key used to persist the data. + /// The persisted raw bytes. + /// true if the state was found; false otherwise. + internal bool TryTakeBytes(string key, [MaybeNullWhen(false)] out byte[]? data) + { + return TryTake(key, out data); + } + private bool TryTake(string key, out byte[]? value) { ArgumentNullException.ThrowIfNull(key); diff --git a/src/Components/Components/src/PersistentComponentStateSerializer.cs b/src/Components/Components/src/PersistentComponentStateSerializer.cs new file mode 100644 index 000000000000..c3705bdc4197 --- /dev/null +++ b/src/Components/Components/src/PersistentComponentStateSerializer.cs @@ -0,0 +1,40 @@ +// 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; + +/// +/// Provides custom serialization logic for persistent component state values of type . +/// +/// The type of the value to serialize. +public abstract class PersistentComponentStateSerializer : IPersistentComponentStateSerializer +{ + /// + /// Serializes the provided and writes it to the . + /// + /// The value to serialize. + /// The buffer writer to write the serialized data to. + public abstract void Persist(T value, IBufferWriter writer); + + /// + /// Deserializes a value of type from the provided . + /// This method must be synchronous to avoid UI tearing during component state restoration. + /// + /// The serialized data to deserialize. + /// The deserialized value. + public abstract T Restore(ReadOnlySequence data); + + /// + /// Explicit interface implementation for non-generic serialization. + /// + void IPersistentComponentStateSerializer.Persist(Type type, object value, IBufferWriter writer) + => Persist((T)value, writer); + + /// + /// Explicit interface implementation for non-generic deserialization. + /// + object IPersistentComponentStateSerializer.Restore(Type type, ReadOnlySequence data) + => Restore(data)!; +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistentStateValueProvider.cs b/src/Components/Components/src/PersistentStateValueProvider.cs index 0ebaa2a34577..669e0b5a5363 100644 --- a/src/Components/Components/src/PersistentStateValueProvider.cs +++ b/src/Components/Components/src/PersistentStateValueProvider.cs @@ -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 _serializerCache = new(); private readonly Dictionary _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 = _serializerCache.GetOrAdd(parameterInfo.PropertyType, SerializerFactory); + + if (customSerializer != null) + { + if (state.TryTakeBytes(storageKey, out var data)) + { + var sequence = new ReadOnlySequence(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,6 +67,10 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param { var propertyName = parameterInfo.PropertyName; var propertyType = parameterInfo.PropertyType; + + // Resolve serializer outside the lambda + var customSerializer = _serializerCache.GetOrAdd(propertyType, SerializerFactory); + _subscriptions[subscriber] = state.RegisterOnPersisting(() => { var storageKey = ComputeKey(subscriber, propertyName); @@ -61,6 +80,16 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param { return Task.CompletedTask; } + + if (customSerializer != null) + { + using var writer = new PooledArrayBufferWriter(); + customSerializer.Persist(propertyType, property, writer); + state.PersistAsBytes(storageKey, writer.WrittenMemory.ToArray()); + return Task.CompletedTask; + } + + // Fallback to JSON serialization state.PersistAsJson(storageKey, property, propertyType); return Task.CompletedTask; }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); @@ -71,6 +100,15 @@ private static PropertyGetter ResolvePropertyGetter(Type type, string propertyNa return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory); } + private IPersistentComponentStateSerializer? SerializerFactory(Type type) + { + var serializerType = typeof(PersistentComponentStateSerializer<>).MakeGenericType(type); + var serializer = serviceProvider.GetService(serializerType); + + // The generic class now inherits from the internal interface, so we can cast directly + return serializer as IPersistentComponentStateSerializer; + } + [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 +319,49 @@ private static bool IsSerializableKey(object key) return result; } + + /// + /// Serializes using the provided and persists it under the given . + /// + /// The type. + /// The key to use to persist the state. + /// The instance to persist. + /// The custom serializer to use for serialization. + internal void PersistAsync(string key, TValue instance, PersistentComponentStateSerializer serializer) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(serializer); + + using var writer = new PooledArrayBufferWriter(); + serializer.Persist(instance, writer); + state.PersistAsBytes(key, writer.WrittenMemory.ToArray()); + } + + /// + /// Tries to retrieve the persisted state with the given and deserializes it using the provided into an + /// instance of type . + /// When the key is present, the state is successfully returned via + /// and removed from the . + /// + /// The key used to persist the instance. + /// The custom serializer to use for deserialization. + /// The persisted instance. + /// true if the state was found; false otherwise. + internal bool TryTake(string key, PersistentComponentStateSerializer serializer, [MaybeNullWhen(false)] out TValue instance) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(serializer); + + if (state.TryTakeBytes(key, out var data)) + { + var sequence = new ReadOnlySequence(data!); + instance = serializer.Restore(sequence); + return true; + } + else + { + instance = default; + return false; + } + } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index f11613946794..311fe70d9848 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -16,6 +16,10 @@ Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateS Microsoft.AspNetCore.Components.PersistentStateAttribute Microsoft.AspNetCore.Components.PersistentStateAttribute.PersistentStateAttribute() -> void Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions +Microsoft.AspNetCore.Components.PersistentComponentStateSerializer +Microsoft.AspNetCore.Components.PersistentComponentStateSerializer.PersistentComponentStateSerializer() -> void +abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer.Persist(T value, System.Buffers.IBufferWriter! writer) -> void +abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer.Restore(System.Buffers.ReadOnlySequence data) -> T static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs b/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs new file mode 100644 index 000000000000..2b237fb2431b --- /dev/null +++ b/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs @@ -0,0 +1,79 @@ +// 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 void PersistAsync_CanUseCustomSerializer() + { + // Arrange + var currentState = new Dictionary(); + 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 + 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(), []); + 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 { { "test-key", customBytes } }; + + var state = new PersistentComponentState(new Dictionary(), []); + 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 : PersistentComponentStateSerializer + { + public override void Persist(string value, IBufferWriter writer) + { + var bytes = Encoding.UTF8.GetBytes(value); + writer.Write(bytes); + } + + public override string Restore(ReadOnlySequence data) + { + var bytes = data.ToArray(); + return Encoding.UTF8.GetString(bytes); + } + } +} \ No newline at end of file diff --git a/src/Components/Components/test/PersistentStateValueProviderTests.cs b/src/Components/Components/test/PersistentStateValueProviderTests.cs index 9ffd37eb8124..6f8fc48e968f 100644 --- a/src/Components/Components/test/PersistentStateValueProviderTests.cs +++ b/src/Components/Components/test/PersistentStateValueProviderTests.cs @@ -25,7 +25,7 @@ public void CanRestoreState_ForComponentWithProperties() new Dictionary(), []); - var provider = new PersistentStateValueProvider(state); + var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); var renderer = new TestRenderer(); var component = new TestComponent(); // Update the method call to match the correct signature @@ -53,7 +53,7 @@ public void Subscribe_RegistersPersistenceCallback() var state = new PersistentComponentState( new Dictionary(), []); - var provider = new PersistentStateValueProvider(state); + var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); var renderer = new TestRenderer(); var component = new TestComponent(); var componentStates = CreateComponentState(renderer, [(component, null)], null); @@ -75,7 +75,7 @@ public void Unsubscribe_RemovesCallbackFromRegisteredCallbacks() var state = new PersistentComponentState( new Dictionary(), []); - var provider = new PersistentStateValueProvider(state); + var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); var renderer = new TestRenderer(); var component = new TestComponent(); var componentStates = CreateComponentState(renderer, [(component, null)], null); @@ -108,7 +108,7 @@ public async Task PersistAsync_PersistsStateForSubscribedComponentProperties() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState, cascadingParameterInfo); @@ -147,7 +147,7 @@ public async Task PersistAsync_UsesParentComponentType_WhenAvailable() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState, cascadingParameterInfo); @@ -187,7 +187,7 @@ public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_WhenParent var componentState2 = componentStates.Last(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -260,7 +260,7 @@ public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_SupportsDi var componentState2 = componentStates.Last(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -305,7 +305,7 @@ public async Task PersistenceFails_IfMultipleComponentsOfSameType_TryToPersistDa var componentState2 = componentStates.Last(); // Create the provider and subscribe the components - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -346,7 +346,7 @@ public async Task PersistentceFails_IfMultipleComponentsOfSameType_TryToPersistD var componentState2 = componentStates.Last(); // Create the provider and subscribe the components - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -379,7 +379,7 @@ public async Task PersistenceFails_MultipleComponentsUseTheSameKey() var componentState2 = componentStates.Last(); // Create the provider and subscribe the components - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -419,7 +419,7 @@ public async Task PersistenceFails_MultipleComponentsUseInvalidKeyTypes(object c var componentState2 = componentStates.Last(); // Create the provider and subscribe the components - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); provider.Subscribe(componentState1, cascadingParameterInfo); provider.Subscribe(componentState2, cascadingParameterInfo); @@ -448,7 +448,7 @@ public async Task PersistAsync_CanPersistValueTypes_IntProperty() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.IntValue), typeof(int)); provider.Subscribe(componentState, cascadingParameterInfo); @@ -483,7 +483,7 @@ public async Task PersistAsync_CanPersistValueTypes_NullableIntProperty() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableIntValue), typeof(int?)); provider.Subscribe(componentState, cascadingParameterInfo); @@ -518,7 +518,7 @@ public async Task PersistAsync_CanPersistValueTypes_TupleProperty() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.TupleValue), typeof((string, int))); provider.Subscribe(componentState, cascadingParameterInfo); @@ -553,7 +553,7 @@ public async Task PersistAsync_CanPersistValueTypes_NullableTupleProperty() var componentState = componentStates.First(); // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State); + var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableTupleValue), typeof((string, int)?)); provider.Subscribe(componentState, cascadingParameterInfo); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 130b518aa210..c17a07065691 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1059,6 +1059,7 @@ public void CanPersistPrerenderedStateDeclaratively_Server() Navigate($"{ServerPathBase}/persist-state?server=true&declarative=true"); Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text); + Browser.Equal("42", () => Browser.FindElement(By.Id("custom-server")).Text); Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text); } @@ -1077,6 +1078,7 @@ public void CanPersistPrerenderedStateDeclaratively_WebAssembly() Navigate($"{ServerPathBase}/persist-state?wasm=true&declarative=true"); Browser.Equal("restored", () => Browser.FindElement(By.Id("wasm")).Text); + Browser.Equal("42", () => Browser.FindElement(By.Id("custom-wasm")).Text); Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm")).Text); } @@ -1095,6 +1097,7 @@ public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnWebAssembly() Navigate($"{ServerPathBase}/persist-state?auto=true&declarative=true"); Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); + Browser.Equal("42", () => Browser.FindElement(By.Id("custom-auto")).Text); Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } @@ -1156,6 +1159,7 @@ public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnServer() Navigate($"{ServerPathBase}/persist-state?auto=true&declarative=true"); Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); + Browser.Equal("42", () => Browser.FindElement(By.Id("custom-auto")).Text); Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index d545fe83461a..6ff528a5a0eb 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -8,10 +8,12 @@ using Components.TestServer.RazorComponents; using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Server; using Microsoft.AspNetCore.Mvc; +using TestContentPackage; using TestContentPackage.Services; namespace TestServer; @@ -64,6 +66,9 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + // Register custom serializer for E2E testing of persistent component state serialization extensibility + services.AddSingleton, CustomIntSerializer>(); + services.AddHttpContextAccessor(); services.AddSingleton(); services.AddCascadingAuthenticationState(); diff --git a/src/Components/test/testassets/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index 88a28726961b..57f0689b6e44 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -4,8 +4,10 @@ using System.Runtime.InteropServices.JavaScript; using System.Security.Claims; using Components.TestServer.Services; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using TestContentPackage; using TestContentPackage.Services; var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -14,6 +16,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Register custom serializer for persistent component state +builder.Services.AddSingleton, CustomIntSerializer>(); + builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthenticationStateDeserialization(options => diff --git a/src/Components/test/testassets/TestContentPackage/CustomIntSerializer.cs b/src/Components/test/testassets/TestContentPackage/CustomIntSerializer.cs new file mode 100644 index 000000000000..487a7b2de45e --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/CustomIntSerializer.cs @@ -0,0 +1,36 @@ +// 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.AspNetCore.Components; + +namespace TestContentPackage; + +/// +/// A custom serializer for int values that uses a custom format to test serialization extensibility. +/// This serializer prefixes integer values with "CUSTOM:" to clearly distinguish them from JSON serialization. +/// +public class CustomIntSerializer : PersistentComponentStateSerializer +{ + public override void Persist(int value, IBufferWriter writer) + { + var customFormat = $"CUSTOM:{value}"; + var bytes = Encoding.UTF8.GetBytes(customFormat); + writer.Write(bytes); + } + + public override int Restore(ReadOnlySequence data) + { + var bytes = data.ToArray(); + var text = Encoding.UTF8.GetString(bytes); + + if (text.StartsWith("CUSTOM:", StringComparison.Ordinal) && int.TryParse(text.Substring(7), out var value)) + { + return value; + } + + // Fallback to direct parsing if format is unexpected + return int.TryParse(text, out var fallbackValue) ? fallbackValue : 0; + } +} \ No newline at end of file diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor index 07cec64a2706..a0a8400c17cb 100644 --- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -1,4 +1,5 @@ 

Application state is @Value

+

Custom value is @CustomValue

Render mode: @_renderMode

@code { @@ -11,11 +12,18 @@ [PersistentState] public string Value { get; set; } + [PersistentState] + public int CustomValue { get; set; } + private string _renderMode = "SSR"; protected override void OnInitialized() { Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored"; + if (CustomValue == 0) + { + CustomValue = !RendererInfo.IsInteractive ? 42 : 0; + } _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server"; } } diff --git a/src/Shared/PooledArrayBufferWriter.cs b/src/Shared/PooledArrayBufferWriter.cs index 9fad4a0a404a..cf580650d4ce 100644 --- a/src/Shared/PooledArrayBufferWriter.cs +++ b/src/Shared/PooledArrayBufferWriter.cs @@ -93,7 +93,7 @@ public void Dispose() ClearHelper(); ArrayPool.Shared.Return(_rentedBuffer); - _rentedBuffer = null; + _rentedBuffer = null!; } private void CheckIfDisposed()