From b956382118e09ea04bc791e101e65444205c14b6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 26 Feb 2025 20:41:32 +0100 Subject: [PATCH 01/37] Initial support for persisting component state --- .../Components/src/CascadingParameterState.cs | 13 +- .../Components/src/ICascadingValueSupplier.cs | 2 + .../ComponentStatePersistenceManager.cs | 15 ++ .../Components/src/ParameterView.cs | 2 +- .../src/PersistentComponentState.cs | 36 +++ .../IPersistentComponentRegistration.cs | 10 + .../PersistentComponentRegistration.cs | 15 ++ .../PersistentServiceTypeCache.cs | 82 +++++++ .../PersistentServicesRegistry.cs | 213 ++++++++++++++++++ .../Components/src/PublicAPI.Unshipped.txt | 6 + .../src/Reflection/PropertyGetter.cs | 56 +++++ .../Components/src/RenderTree/Renderer.cs | 8 +- .../src/Rendering/ComponentState.cs | 2 + ...erFromPersistentComponentStateAttribute.cs | 13 ++ ...tateProviderServiceCollectionExtensions.cs | 54 +++++ ...omPersistentComponentStateValueProvider.cs | 188 ++++++++++++++++ .../ComponentStatePersistenceManagerTest.cs | 21 +- ...orComponentsServiceCollectionExtensions.cs | 5 +- .../Forms/EndpointAntiforgeryStateProvider.cs | 5 +- .../EndpointHtmlRenderer.PrerenderingState.cs | 1 - .../Data/SamplePersistentService.cs | 12 + .../BlazorServerApp/Pages/FetchData.razor | 11 +- .../Samples/BlazorServerApp/Pages/Index.razor | 22 +- .../Pages/StatefulComponent.razor | 17 ++ .../BlazorServerApp/Pages/_Host.cshtml | 2 + .../BlazorServerApp/Pages/_Layout.cshtml | 8 +- .../Properties/launchSettings.json | 1 + .../Samples/BlazorServerApp/Startup.cs | 4 + .../Server/src/Circuits/CircuitFactory.cs | 11 - .../Server/src/Circuits/CircuitHost.cs | 11 - .../src/DefaultAntiforgeryStateProvider.cs | 34 +-- .../src/Hosting/WebAssemblyHost.cs | 10 - 32 files changed, 798 insertions(+), 92 deletions(-) create mode 100644 src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs create mode 100644 src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs create mode 100644 src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs create mode 100644 src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs create mode 100644 src/Components/Components/src/Reflection/PropertyGetter.cs create mode 100644 src/Components/Components/src/SupplyParameterFromPersistentComponentStateAttribute.cs create mode 100644 src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs create mode 100644 src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs create mode 100644 src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs create mode 100644 src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor diff --git a/src/Components/Components/src/CascadingParameterState.cs b/src/Components/Components/src/CascadingParameterState.cs index ae4f526d5d3a..2193e49b4766 100644 --- a/src/Components/Components/src/CascadingParameterState.cs +++ b/src/Components/Components/src/CascadingParameterState.cs @@ -13,17 +13,16 @@ namespace Microsoft.AspNetCore.Components; internal readonly struct CascadingParameterState + (in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier, object? key) { private static readonly ConcurrentDictionary _cachedInfos = new(); - public CascadingParameterInfo ParameterInfo { get; } - public ICascadingValueSupplier ValueSupplier { get; } + public CascadingParameterInfo ParameterInfo { get; } = parameterInfo; + public ICascadingValueSupplier ValueSupplier { get; } = valueSupplier; + public object? Key { get; } = key; public CascadingParameterState(in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier) - { - ParameterInfo = parameterInfo; - ValueSupplier = valueSupplier; - } + : this(parameterInfo, valueSupplier, key: null) { } public static IReadOnlyList FindCascadingParameters(ComponentState componentState, out bool hasSingleDeliveryParameters) { @@ -55,7 +54,7 @@ public static IReadOnlyList FindCascadingParameters(Com { // Although not all parameters might be matched, we know the maximum number resultStates ??= new List(infos.Length - infoIndex); - resultStates.Add(new CascadingParameterState(info, supplier)); + resultStates.Add(new CascadingParameterState(info, supplier, componentState)); if (info.Attribute.SingleDelivery) { diff --git a/src/Components/Components/src/ICascadingValueSupplier.cs b/src/Components/Components/src/ICascadingValueSupplier.cs index c535d9cfda16..dd1765bfc624 100644 --- a/src/Components/Components/src/ICascadingValueSupplier.cs +++ b/src/Components/Components/src/ICascadingValueSupplier.cs @@ -13,6 +13,8 @@ internal interface ICascadingValueSupplier object? GetCurrentValue(in CascadingParameterInfo parameterInfo); + object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) => GetCurrentValue(parameterInfo); + void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); diff --git a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs index e1b4fdf605ec..c3595785486a 100644 --- a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Infrastructure; @@ -15,17 +16,30 @@ public class ComponentStatePersistenceManager private readonly ILogger _logger; private bool _stateIsPersisted; + private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); /// /// Initializes a new instance of . /// + /// public ComponentStatePersistenceManager(ILogger logger) { State = new PersistentComponentState(_currentState, _registeredCallbacks); _logger = logger; } + /// + /// Initializes a new instance of . + /// + /// + /// + public ComponentStatePersistenceManager(ILogger logger, IServiceProvider serviceProvider) : this(logger) + { + _servicesRegistry = serviceProvider.GetService(); + _servicesRegistry?.RegisterForPersistence(State); + } + /// /// Gets the associated with the . /// @@ -40,6 +54,7 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) { var data = await store.GetPersistedStateAsync(); State.InitializeExistingState(data); + _servicesRegistry?.Restore(State); } /// diff --git a/src/Components/Components/src/ParameterView.cs b/src/Components/Components/src/ParameterView.cs index 01252270fbe5..a1dc464399d6 100644 --- a/src/Components/Components/src/ParameterView.cs +++ b/src/Components/Components/src/ParameterView.cs @@ -437,7 +437,7 @@ public bool MoveNext() _currentIndex = nextIndex; var state = _cascadingParameters[_currentIndex]; - var currentValue = state.ValueSupplier.GetCurrentValue(state.ParameterInfo); + var currentValue = state.ValueSupplier.GetCurrentValue(state.Key, state.ParameterInfo); _current = new ParameterValue(state.ParameterInfo.PropertyName, currentValue!, true); return true; } diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index bc193dd77e5f..8f53a706468a 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -87,6 +87,24 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call _currentState.Add(key, JsonSerializer.SerializeToUtf8Bytes(instance, JsonSerializerOptionsProvider.Options)); } + [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")] + internal void PersistAsJson(string key, object instance, [DynamicallyAccessedMembers(JsonSerialized)] Type type) + { + 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, JsonSerializer.SerializeToUtf8Bytes(instance, type, JsonSerializerOptionsProvider.Options)); + } + /// /// Tries to retrieve the persisted state as JSON with the given and deserializes it into an /// instance of type . @@ -114,6 +132,24 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call } } + [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")] + internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerialized)] Type type, [MaybeNullWhen(false)] out object? instance) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(key); + if (TryTake(key, out var data)) + { + var reader = new Utf8JsonReader(data); + instance = JsonSerializer.Deserialize(ref reader, type, JsonSerializerOptionsProvider.Options); + return true; + } + else + { + instance = default; + return false; + } + } + private bool TryTake(string key, out byte[]? value) { ArgumentNullException.ThrowIfNull(key); diff --git a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs new file mode 100644 index 000000000000..658117f93976 --- /dev/null +++ b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +internal interface IPersistentComponentRegistration +{ + public string Assembly { get; } + public string FullTypeName { get; } +} diff --git a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs new file mode 100644 index 000000000000..45bf9c63dbb9 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Components; + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +internal class PersistentComponentRegistration : IPersistentComponentRegistration +{ + public string Assembly => typeof(TService).Assembly.GetName().Name!; + public string FullTypeName => typeof(TService).FullName!; + + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; +} diff --git a/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs b/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs new file mode 100644 index 000000000000..a9a656da9619 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Microsoft.AspNetCore.Components; + +// A cache for registered persistent services. This is similar to the `RootComponentTypeCache`. +internal sealed class PersistentServiceTypeCache +{ + private readonly ConcurrentDictionary _typeToKeyLookUp = new(); + + public Type? GetPersistentService(string assembly, string type) + { + var key = new Key(assembly, type); + if (_typeToKeyLookUp.TryGetValue(key, out var resolvedType)) + { + return resolvedType; + } + else + { + return _typeToKeyLookUp.GetOrAdd(key, ResolveType, AppDomain.CurrentDomain.GetAssemblies()); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Types in this cache are added by calling `AddPersistentService` and are expected to be preserved.")] + private static Type? ResolveType(Key key, Assembly[] assemblies) + { + Assembly? assembly = null; + for (var i = 0; i < assemblies.Length; i++) + { + var current = assemblies[i]; + if (current.GetName().Name == key.Assembly) + { + assembly = current; + break; + } + } + + if (assembly == null) + { + // It might be that the assembly is not loaded yet, this can happen if the root component is defined in a + // different assembly than the app and there is no reference from the app assembly to any type in the class + // library that has been used yet. + // In this case, try and load the assembly and look up the type again. + // We only need to do this in the browser because its a different process, in the server the assembly will already + // be loaded. + if (OperatingSystem.IsBrowser()) + { + try + { + assembly = Assembly.Load(key.Assembly); + } + catch + { + // It's fine to ignore the exception, since we'll return null below. + } + } + } + + return assembly?.GetType(key.Type, throwOnError: false, ignoreCase: false); + } + + private readonly struct Key : IEquatable + { + public Key(string assembly, string type) => + (Assembly, Type) = (assembly, type); + + public string Assembly { get; } + + public string Type { get; } + + public override bool Equals(object? obj) => obj is Key key && Equals(key); + + public bool Equals(Key other) => string.Equals(Assembly, other.Assembly, StringComparison.Ordinal) && + string.Equals(Type, other.Type, StringComparison.Ordinal); + + public override int GetHashCode() => HashCode.Combine(Assembly, Type); + } +} diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs new file mode 100644 index 000000000000..c99b783d3508 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -0,0 +1,213 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Components.Reflection; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Components; + +internal class PersistentServicesRegistry +{ + private static readonly string _registryKey = typeof(PersistentServicesRegistry).FullName!; + + private readonly IServiceProvider _serviceProvider; + private readonly PersistentServiceTypeCache _persistentServiceTypeCache; + private IEnumerable _registrations; + private PersistingComponentStateSubscription _subscription; + private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); + + public PersistentServicesRegistry( + IServiceProvider serviceProvider, + IEnumerable registrations) + { + _serviceProvider = serviceProvider; + _persistentServiceTypeCache = new PersistentServiceTypeCache(); + _registrations = registrations; + } + + [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] + private class PersistentComponentRegistration : IPersistentComponentRegistration + { + public string Assembly { get; set; } = ""; + + public string FullTypeName { get; set; } = ""; + + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; + } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + private void RestoreRegistrationsIfAvailable(PersistentComponentState state) + { + foreach (var registration in _registrations) + { + var type = ResolveType(registration.Assembly, registration.FullTypeName); + if (type == null) + { + continue; + } + + var instance = _serviceProvider.GetService(type); + if (instance != null) + { + RestoreInstanceState(instance, type, state); + } + } + } + + [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(String, Type, out Object)")] + private static void RestoreInstanceState(object instance, Type type, PersistentComponentState state) + { + var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (Type runtimeType, Type declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); + foreach (var (key, propertyType) in accessors.KeyTypePairs) + { + if (state.TryTakeFromJson(key, propertyType, out var result)) + { + var (setter, getter) = accessors.GetAccessor(key); + setter.SetValue(instance, result!); + } + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + private void PersistServicesState(PersistentComponentState state) + { + // Persist all the registrations + state.PersistAsJson(_registryKey, _registrations); + foreach (var registration in _registrations) + { + var type = ResolveType(registration.Assembly, registration.FullTypeName); + if (type == null) + { + continue; + } + + var instance = _serviceProvider.GetRequiredService(type); + PersistInstanceState(instance, type, state); + } + } + + [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsJson(String, Object, Type)")] + private static void PersistInstanceState(object instance, Type type, PersistentComponentState state) + { + var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (Type runtimeType, Type declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); + foreach (var (key, propertyType) in accessors.KeyTypePairs) + { + var (setter, getter) = accessors.GetAccessor(key); + var value = getter.GetValue(instance); + if (value != null) + { + state.PersistAsJson(key, value, propertyType); + } + } + } + + private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + internal void Restore(PersistentComponentState state) + { + if (_registrations?.Any() != true && + state.TryTakeFromJson>(_registryKey, out var registry) && + registry != null) + { + _registrations = registry; + } + + RestoreRegistrationsIfAvailable(state); + } + + internal void RegisterForPersistence(PersistentComponentState state) + { + if (!_subscription.Equals(default(PersistingComponentStateSubscription))) + { + return; + } + + _subscription = state.RegisterOnPersisting(() => + { + PersistServicesState(state); + return Task.CompletedTask; + }); + } + + private sealed class PropertiesAccessor + { + internal const BindingFlags BindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase; + + private readonly Dictionary _underlyingAccessors; + private readonly (string, Type)[] _cachedKeysForService; + + public PropertiesAccessor([DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType, Type keyType) + { + _underlyingAccessors = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var keys = new List<(string, Type)>(); + foreach (var propertyInfo in GetCandidateBindableProperties(targetType)) + { + SupplyParameterFromPersistentComponentStateAttribute? parameterAttribute = null; + foreach (var attribute in propertyInfo.GetCustomAttributes()) + { + if (attribute is SupplyParameterFromPersistentComponentStateAttribute persistentStateAttribute) + { + parameterAttribute = persistentStateAttribute; + break; + } + } + if (parameterAttribute == null) + { + continue; + } + + var propertyName = propertyInfo.Name; + var key = ComputeKey(keyType, propertyName); + keys.Add(new(key, propertyInfo.PropertyType)); + if (propertyInfo.SetMethod == null || !propertyInfo.SetMethod.IsPublic) + { + throw new InvalidOperationException( + $"The type '{targetType.FullName}' declares a property matching the name '{propertyName}' that is not public. Persistent service properties must be public."); + } + + if (propertyInfo.GetMethod == null || !propertyInfo.GetMethod.IsPublic) + { + throw new InvalidOperationException( + $"The type '{targetType.FullName}' declares a property matching the name '{propertyName}' that is not public. Persistent service properties must be public."); + } + + var propertySetter = new PropertySetter(targetType, propertyInfo); + var propertyGetter = new PropertyGetter(targetType, propertyInfo); + + _underlyingAccessors.Add(key, (propertySetter, propertyGetter)); + } + + _cachedKeysForService = [.. keys]; + } + + public (string, Type)[] KeyTypePairs => _cachedKeysForService; + + private static string ComputeKey(Type keyType, string propertyName) + { + // This happens once per type and property combo, so allocations are ok. + var assemblyName = keyType.Assembly.FullName; + var typeName = keyType.FullName; + var input = Encoding.UTF8.GetBytes(string.Join(".", assemblyName, typeName, propertyName)); + return Convert.ToBase64String(SHA256.HashData(input)); + } + + internal static IEnumerable GetCandidateBindableProperties( + [DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType) + => MemberAssignment.GetPropertiesIncludingInherited(targetType, BindablePropertyFlags); + + internal (PropertySetter setter, PropertyGetter getter) GetAccessor(string key) + { + return _underlyingAccessors.TryGetValue(key, out var result) ? result : default; + } + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..5be480ce98b6 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void +Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute +Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void +Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions +static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddPersistentService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/src/Reflection/PropertyGetter.cs b/src/Components/Components/src/Reflection/PropertyGetter.cs new file mode 100644 index 000000000000..ed5cd71225cc --- /dev/null +++ b/src/Components/Components/src/Reflection/PropertyGetter.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.Components.Reflection; + +internal sealed class PropertyGetter +{ + private static readonly MethodInfo CallPropertyGetterOpenGenericMethod = + typeof(PropertyGetter).GetMethod(nameof(CallPropertyGetter), BindingFlags.NonPublic | BindingFlags.Static)!; + + private readonly Func _GetterDelegate; + + [UnconditionalSuppressMessage( + "ReflectionAnalysis", + "IL2060:MakeGenericMethod", + Justification = "The referenced methods don't have any DynamicallyAccessedMembers annotations. See https://github.com/mono/linker/issues/1727")] + public PropertyGetter(Type targetType, PropertyInfo property) + { + if (property.GetMethod == null) + { + throw new InvalidOperationException("Cannot provide a value for property " + + $"'{property.Name}' on type '{targetType.FullName}' because the property " + + "has no getter."); + } + + if (RuntimeFeature.IsDynamicCodeSupported) + { + var getMethod = property.GetMethod; + + var propertyGetterAsFunc = + getMethod.CreateDelegate(typeof(Func<,>).MakeGenericType(targetType, property.PropertyType)); + var callPropertyGetterClosedGenericMethod = + CallPropertyGetterOpenGenericMethod.MakeGenericMethod(targetType, property.PropertyType); + _GetterDelegate = (Func) + callPropertyGetterClosedGenericMethod.CreateDelegate(typeof(Func), propertyGetterAsFunc); + } + else + { + _GetterDelegate = property.GetValue; + } + } + + public object? GetValue(object target) => _GetterDelegate(target); + + private static TValue CallPropertyGetter( + Func Getter, + object target) + where TTarget : notnull + { + return Getter((TTarget)target); + } +} diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e0fcfd834340..543dcd48c047 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -371,12 +371,16 @@ private ComponentState AttachAndInitComponent(IComponent component, int parentCo var parentComponentState = GetOptionalComponentState(parentComponentId); var componentState = CreateComponentState(componentId, component, parentComponentState); Log.InitializingComponent(_logger, componentState, parentComponentState); - _componentStateById.Add(componentId, componentState); - _componentStateByComponent.Add(component, componentState); component.Attach(new RenderHandle(this, componentId)); return componentState; } + internal void RegisterComponentState(IComponent component, int componentId, ComponentState componentState) + { + _componentStateById.Add(componentId, componentState); + _componentStateByComponent.Add(component, componentState); + } + /// /// Creates a instance to track state associated with a newly-instantiated component. /// This is called before the component is initialized and tracked within the . Subclasses diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index 4d973398f639..c7be2643edd9 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -44,6 +44,8 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, CurrentRenderTree = new RenderTreeBuilder(); _nextRenderTree = new RenderTreeBuilder(); + _renderer.RegisterComponentState(component, ComponentId, this); + if (_cascadingParameters.Count != 0) { _hasCascadingParameters = true; diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateAttribute.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateAttribute.cs new file mode 100644 index 000000000000..61a283fc46fd --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateAttribute.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Indicates that the value for the parameter might come from persistent component state from a +/// previous render. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class SupplyParameterFromPersistentComponentStateAttribute : CascadingParameterAttributeBase +{ +} diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs new file mode 100644 index 000000000000..406b1f8f96b7 --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using static Microsoft.AspNetCore.Internal.LinkerFlags; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.AspNetCore.Components; + +/// +/// Enables component parameters to be supplied from with . +/// +public static class SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions +{ + /// + /// Enables component parameters to be supplied from with .. + /// + /// The . + /// The . + public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvider(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + return services; + } + + /// + /// Saves state when the application is persisting state and restores it at the appropriate time automatically. + /// + /// + /// Only public properties annotated with are persisted and restored. + /// + /// The service type to register for persistence. + /// The . + /// The . + public static IServiceCollection AddPersistentService<[DynamicallyAccessedMembers(JsonSerialized)] TService>(this IServiceCollection services) + { + // This method does something very similar to what we do when we register root components, except in this case we are registering services. + // We collect a list of all the registrations on during static rendering mode and push those registrations to interactive mode. + // When the interactive mode starts, we retrieve the registry, and this triggers the process for restoring all the states for all the services. + // The process for retrieving the services is the same as we do for root components. + // We look for the assembly in the current list of loaded assemblies. + // We look for the type inside the assembly. + // We resolve the service from the DI container. + // TODO: We can support registering for a specific render mode at this level (that way no info gets sent to the client accidentally 4 example). + // Even as far as defaulting to Server (to avoid disclosing anything confidential to the client, even though is the Developer responsibility). + // We can choose to fail when the service is not registered on DI. + // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. + services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Singleton>()); + + return services; + } +} diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs new file mode 100644 index 000000000000..39b5ef65acb9 --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -0,0 +1,188 @@ +// 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.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(PersistentComponentState state) : ICascadingValueSupplier +{ + private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new(); + + public bool IsFixed => false; + private readonly Dictionary _subscriptions = []; + + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) + => parameterInfo.Attribute is SupplyParameterFromPersistentComponentStateAttribute; + + [UnconditionalSuppressMessage( + "ReflectionAnalysis", + "IL2026:RequiresUnreferencedCode message", + Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] + [UnconditionalSuppressMessage( + "Trimming", + "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", + Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] + public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + return state.TryTakeFromJson(parameterInfo.PropertyName, parameterInfo.PropertyType, out var value) ? value : null; + } + + [UnconditionalSuppressMessage( + "ReflectionAnalysis", + "IL2026:RequiresUnreferencedCode message", + Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] + [UnconditionalSuppressMessage( + "Trimming", + "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", + Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] + public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) + { + var componentState = (ComponentState)key!; + var storageKey = ComputeKey(componentState, parameterInfo.PropertyName); + + return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; + } + + private static string ComputeKey(ComponentState componentState, string propertyName) + { + // We need to come up with a pseudo-unique key for the storage key. + // We need to consider the property name, the component type, and its position within the component tree. + // If only one component of a given type is present on the page, then only the component type + property name is enough. + // If multiple components of the same type are present on the page, then we need to consider the position within the tree. + // To do that, we are going to use the `@key` directive on the component if present and if we deem it serializable. + // Serializable keys are Guid, DateOnly, TimeOnly, and any primitive type. + // The key is composed of four segments: + // Parent component type + // Component type + // Property name + // @key directive if present and serializable. + // We combine the first three parts into an identifier, and then we generate a derived identifier with the key + // We do it this way becasue the information for the first three pieces of data is static for the lifetime of the + // program and can be cached on each situation. + + var parentComponentType = GetParentComponentType(componentState); + var componentType = GetComponentType(componentState); + + var preKey = _keyCache.GetOrAdd((parentComponentType, componentType, propertyName), KeyFactory); + var finalKey = ComputeFinalKey(preKey, componentState); + + return finalKey; + } + + private static string ComputeFinalKey(byte[] preKey, ComponentState componentState) + { + Span keyHash = stackalloc byte[SHA256.HashSizeInBytes]; + + var key = GetSerializableKey(componentState); + byte[]? pool = null; + try + { + Span keyBuffer = stackalloc byte[1024]; + preKey.CopyTo(keyBuffer); + if (key is IUtf8SpanFormattable formattable) + { + while (!formattable.TryFormat(keyBuffer[preKey.Length..], out var written, "{0:G}", CultureInfo.InvariantCulture)) + { + // It is really unlikely that we will enter here, but we need to handle this case + Debug.Assert(written == 0); + var newPool = pool == null ? ArrayPool.Shared.Rent(2048) : ArrayPool.Shared.Rent(pool.Length * 2); + keyBuffer[0..preKey.Length].CopyTo(newPool); + keyBuffer = newPool; + if (pool != null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + pool = newPool; + } + } + + Debug.Assert(SHA256.TryHashData(keyBuffer, keyHash, out _)); + return Convert.ToBase64String(keyHash); + } + finally + { + if (pool != null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + } + } + + private static object? GetSerializableKey(ComponentState componentState) + { + if (componentState.LogicalParentComponentState is not { } parentComponentState) + { + return null; + } + + // Check if the parentComponentState has a `@key` directive applied to the current component. + var frames = parentComponentState.CurrentRenderTree.GetFrames(); + for (var i = 0; i < frames.Count; i++) + { + ref var currentFrame = ref frames.Array[i]; + if (currentFrame.FrameType != RenderTree.RenderTreeFrameType.Component || + !ReferenceEquals(componentState.Component, currentFrame.Component)) + { + // Skip any frame that is not the current component. + continue; + } + + var componentKey = currentFrame.ComponentKey; + return !IsSerializableKey(componentKey) ? null : componentKey; + } + + return null; + } + + private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!; + + private static string GetParentComponentType(ComponentState componentState) => componentState.LogicalParentComponentState == null ? + "" : + GetComponentType(componentState.LogicalParentComponentState); + + private static byte[] KeyFactory((string, string, string) parts) + { + return Encoding.UTF8.GetBytes(string.Join(".", parts.Item1, parts.Item2, parts.Item3)); + } + + // TODO: Complete later, support common types that have a string representation. + private static bool IsSerializableKey(object key) => + key is { } componentKey && componentKey.GetType() is Type type && + (Type.GetTypeCode(type) != TypeCode.Object + || type == typeof(Guid) + || type == typeof(DateOnly) + || type == typeof(TimeOnly)); + + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + var propertyName = parameterInfo.PropertyName; + var propertyType = parameterInfo.PropertyType; + _subscriptions[subscriber] = state.RegisterOnPersisting(() => + { + var storageKey = ComputeKey(subscriber, propertyName); + var property = subscriber.Component.GetType().GetProperty(propertyName)!.GetValue(subscriber.Component)!; + state.PersistAsJson(storageKey, property, propertyType); + return Task.CompletedTask; + }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); + } + + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + if (_subscriptions.TryGetValue(subscriber, out var subscription)) + { + subscription.Dispose(); + _subscriptions.Remove(subscriber); + } + } +} diff --git a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs index 02b104d41f43..4b005ffc72df 100644 --- a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs @@ -25,7 +25,7 @@ public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) }; var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); // Act await lifetime.RestoreStateAsync(store); @@ -45,7 +45,7 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() ["MyState"] = [0, 1, 2, 3, 4] }; var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); await lifetime.RestoreStateAsync(store); @@ -53,13 +53,16 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() await Assert.ThrowsAsync(() => lifetime.RestoreStateAsync(store)); } + private IServiceProvider CreateServiceProvider() => + new ServiceCollection().BuildServiceProvider(); + [Fact] public async Task PersistStateAsync_ThrowsWhenCallbackRenerModeCannotBeInferred() { // Arrange var state = new Dictionary(); var store = new CompositeTestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; @@ -81,7 +84,7 @@ public async Task PersistStateAsync_SavesPersistedStateToTheStore() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; @@ -106,7 +109,7 @@ public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; @@ -126,7 +129,7 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); var renderer = new TestRenderer(); var sequence = new List { }; @@ -154,7 +157,7 @@ public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDispo // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); var renderer = new TestRenderer(); var sequence = new List { }; @@ -188,7 +191,7 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist var logger = loggerFactory.CreateLogger(); var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger); + var lifetime = new ComponentStatePersistenceManager(logger, CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; @@ -214,7 +217,7 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist var logger = loggerFactory.CreateLogger(); var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger); + var lifetime = new ComponentStatePersistenceManager(logger, CreateServiceProvider()); var renderer = new TestRenderer(); var invoked = false; var tcs = new TaskCompletionSource(); diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 0836828dc949..acfbb86e3f80 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -62,7 +62,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(sp => sp.GetRequiredService().State); + services.TryAddScoped(sp => sp.GetRequiredService().State); services.TryAddScoped(); services.TryAddEnumerable( ServiceDescriptor.Singleton, DefaultRazorComponentsServiceOptionsConfiguration>()); @@ -70,6 +70,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(sp => sp.GetRequiredService()); services.TryAddScoped(); services.AddSupplyValueFromQueryProvider(); + services.AddSupplyValueFromPersistentComponentStateProvider(); services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); @@ -78,6 +79,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection // Form handling services.AddSupplyValueFromFormProvider(); services.TryAddScoped(); + services.TryAddScoped(sp => (EndpointAntiforgeryStateProvider)sp.GetRequiredService()); + services.AddPersistentService(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs index 5df713d111c7..01d44427f343 100644 --- a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs +++ b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints.Forms; -internal class EndpointAntiforgeryStateProvider(IAntiforgery antiforgery, PersistentComponentState state) : DefaultAntiforgeryStateProvider(state) +internal class EndpointAntiforgeryStateProvider(IAntiforgery antiforgery) : DefaultAntiforgeryStateProvider() { private HttpContext? _context; @@ -34,6 +34,7 @@ internal void SetRequestContext(HttpContext context) return null; } - return new AntiforgeryRequestToken(tokens.RequestToken, tokens.FormFieldName); + CurrentToken = new AntiforgeryRequestToken(tokens.RequestToken, tokens.FormFieldName); + return CurrentToken; } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index 161967076ec5..c5547e284082 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs @@ -134,7 +134,6 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht } var manager = _httpContext.RequestServices.GetRequiredService(); - // Now given the mode, we obtain a particular store for that mode // and persist the state and return the HTML content switch (serializationMode) diff --git a/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs b/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs new file mode 100644 index 000000000000..a920a021ab46 --- /dev/null +++ b/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.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 Microsoft.AspNetCore.Components; + +namespace BlazorServerApp.Data; + +public class SamplePersistentService +{ + [SupplyParameterFromPersistentComponentState] + public string SampleState { get; set; } = ""; +} diff --git a/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor b/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor index b992e82e7da1..54ce11abc072 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor +++ b/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor @@ -9,7 +9,7 @@

This component demonstrates fetching data from a service.

-@if (forecasts == null) +@if (Forecasts == null) {

Loading...

} @@ -25,7 +25,7 @@ else - @foreach (var forecast in forecasts) + @foreach (var forecast in Forecasts) { @forecast.Date.ToShortDateString() @@ -39,10 +39,13 @@ else } @code { - WeatherForecast[]? forecasts; + [SupplyParameterFromPersistentComponentState] public WeatherForecast[]? Forecasts { get; set; } protected override async Task OnInitializedAsync() { - forecasts = await ForecastService.GetForecastAsync(DateTime.Now); + if (Forecasts == null) + { + Forecasts = await ForecastService.GetForecastAsync(DateTime.Now); + } } } diff --git a/src/Components/Samples/BlazorServerApp/Pages/Index.razor b/src/Components/Samples/BlazorServerApp/Pages/Index.razor index 7b5a15e0e22b..8289af037014 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/Index.razor +++ b/src/Components/Samples/BlazorServerApp/Pages/Index.razor @@ -1,7 +1,27 @@ @page "/" +@inject Data.SamplePersistentService Service +@inject AntiforgeryStateProvider AntiforgeryStateProvider Index

Hello, world!

-Welcome to your new app. + + +

Service state: @Service.SampleState

+ +

Antifoguerty @AntiforgeryStateProvider.GetAntiforgeryToken()?.Value

+ +@for (var i = 0; i < 10; i++) +{ + var current = i; + + +} + +@code { + protected override void OnInitialized() + { + Service.SampleState ??= Guid.NewGuid().ToString(); + } +} diff --git a/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor b/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor new file mode 100644 index 000000000000..6f23ac146ef3 --- /dev/null +++ b/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor @@ -0,0 +1,17 @@ +

StatefulComponent

+ +

My value is @Value

+ +@code { + [Parameter] public Guid InitialValue { get; set; } + + [SupplyParameterFromPersistentComponentState] public Guid Value { get; set; } + + override protected void OnInitialized() + { + if (Value == Guid.Empty) + { + Value = InitialValue; + } + } +} diff --git a/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml b/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml index 6153324e90ea..aa4d27c4bfcd 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml +++ b/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml @@ -7,3 +7,5 @@ } + + diff --git a/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml b/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml index 91499422deb9..ad65a057bc2a 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml +++ b/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml @@ -17,11 +17,15 @@ @RenderBody() - diff --git a/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json b/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json index a18eefe81370..61c2ff93a6f4 100644 --- a/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json @@ -11,6 +11,7 @@ "BlazorServerApp": { "commandName": "Project", "launchBrowser": true, + "launchUrl": "https://localhost:5001/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/src/Components/Samples/BlazorServerApp/Startup.cs b/src/Components/Samples/BlazorServerApp/Startup.cs index d630a127c242..b2373b799adb 100644 --- a/src/Components/Samples/BlazorServerApp/Startup.cs +++ b/src/Components/Samples/BlazorServerApp/Startup.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using BlazorServerApp.Data; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; namespace BlazorServerApp; @@ -20,6 +22,8 @@ public void ConfigureServices(IServiceCollection services) { services.AddRazorPages(); services.AddServerSideBlazor(); + services.AddScoped(); + services.AddPersistentService(); services.AddSingleton(); } diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 92526e87ad3c..c22aca391664 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Security.Claims; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.DependencyInjection; @@ -70,7 +69,6 @@ public async ValueTask CreateCircuitHostAsync( // when the first set of components is provided via an UpdateRootComponents call. var appLifetime = scope.ServiceProvider.GetRequiredService(); await appLifetime.RestoreStateAsync(store); - RestoreAntiforgeryToken(scope); } var serverComponentDeserializer = scope.ServiceProvider.GetRequiredService(); @@ -114,15 +112,6 @@ public async ValueTask CreateCircuitHostAsync( return circuitHost; } - private static void RestoreAntiforgeryToken(AsyncServiceScope scope) - { - // GetAntiforgeryToken makes sure the antiforgery token is restored from persitent component - // state and is available on the circuit whether or not is used by a component on the first - // render. - var antiforgery = scope.ServiceProvider.GetService(); - _ = antiforgery?.GetAntiforgeryToken(); - } - private static partial class Log { [LoggerMessage(1, LogLevel.Debug, "Created circuit {CircuitId} for connection {ConnectionId}", EventName = "CreatedCircuit")] diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 82eb2ffa524c..e34ccb8ca4cd 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; @@ -759,7 +758,6 @@ internal Task UpdateRootComponents( // provided during the start up process var appLifetime = _scope.ServiceProvider.GetRequiredService(); await appLifetime.RestoreStateAsync(store); - RestoreAntiforgeryToken(_scope); } // Retrieve the circuit handlers at this point. @@ -804,15 +802,6 @@ internal Task UpdateRootComponents( }); } - private static void RestoreAntiforgeryToken(AsyncServiceScope scope) - { - // GetAntiforgeryToken makes sure the antiforgery token is restored from persitent component - // state and is available on the circuit whether or not is used by a component on the first - // render. - var antiforgery = scope.ServiceProvider.GetService(); - _ = antiforgery?.GetAntiforgeryToken(); - } - private async ValueTask PerformRootComponentOperations( RootComponentOperation[] operations, bool shouldWaitForQuiescence) diff --git a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs index 6a3d926a73a2..0750f0c82b91 100644 --- a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs +++ b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs @@ -1,39 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components.Web; - namespace Microsoft.AspNetCore.Components.Forms; -internal class DefaultAntiforgeryStateProvider : AntiforgeryStateProvider, IDisposable +internal class DefaultAntiforgeryStateProvider : AntiforgeryStateProvider { - private const string PersistenceKey = $"__internal__{nameof(AntiforgeryRequestToken)}"; - private readonly PersistingComponentStateSubscription _subscription; - private readonly AntiforgeryRequestToken? _currentToken; - - [UnconditionalSuppressMessage( - "Trimming", - "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", - Justification = $"{nameof(DefaultAntiforgeryStateProvider)} uses the {nameof(PersistentComponentState)} APIs to deserialize the token, which are already annotated.")] - public DefaultAntiforgeryStateProvider(PersistentComponentState state) - { - // Automatically flow the Request token to server/wasm through - // persistent component state. This guarantees that the antiforgery - // token is available on the interactive components, even when they - // don't have access to the request. - _subscription = state.RegisterOnPersisting(() => - { - state.PersistAsJson(PersistenceKey, GetAntiforgeryToken()); - return Task.CompletedTask; - }, RenderMode.InteractiveAuto); - - state.TryTakeFromJson(PersistenceKey, out _currentToken); - } - - /// - public override AntiforgeryRequestToken? GetAntiforgeryToken() => _currentToken; + [SupplyParameterFromPersistentComponentState] + public AntiforgeryRequestToken? CurrentToken { get; set; } /// - public void Dispose() => _subscription.Dispose(); + public override AntiforgeryRequestToken? GetAntiforgeryToken() => CurrentToken; } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index a36bb957ccce..32cabdf6c564 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection.Metadata; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Web.Infrastructure; using Microsoft.AspNetCore.Components.WebAssembly.HotReload; @@ -138,8 +137,6 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl await manager.RestoreStateAsync(store); - RestoreAntiforgeryToken(); - if (MetadataUpdater.IsSupported) { await WebAssemblyHotReload.InitializeAsync(); @@ -233,11 +230,4 @@ private static void AddWebRootComponents(WebAssemblyRenderer renderer, RootCompo renderer.NotifyEndUpdateRootComponents(operationBatch.BatchId); } - - private void RestoreAntiforgeryToken() - { - // The act of instantiating the DefaultAntiforgeryStateProvider will automatically - // retrieve the antiforgery token from the persistent state - _scope.ServiceProvider.GetRequiredService(); - } } From f1f5cd1040bac8b9fdb9dadc131f973b01ce5823 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 27 Feb 2025 19:34:44 +0100 Subject: [PATCH 02/37] add support for specifying the render mode for the persisted service --- .../ComponentStatePersistenceManager.cs | 7 ++ .../IPersistentComponentRegistration.cs | 20 ++++- .../PersistentComponentRegistration.cs | 4 +- .../PersistentServiceRenderMode.cs | 9 +++ .../PersistentServicesRegistry.cs | 79 ++++++++++++------- .../Components/src/PublicAPI.Unshipped.txt | 2 +- ...tateProviderServiceCollectionExtensions.cs | 8 +- ...orComponentsServiceCollectionExtensions.cs | 2 +- .../Samples/BlazorServerApp/Startup.cs | 3 +- 9 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs diff --git a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs index c3595785486a..0ac6bf8e7bc1 100644 --- a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs @@ -134,6 +134,13 @@ private void InferRenderModes(Renderer renderer) continue; } + if (registration.Callback.Target is PersistentServicesRegistry) + { + // The registration callback is associated with the services registry, which is a special case. + // We don't need to infer the render mode for this case. + continue; + } + throw new InvalidOperationException( $"The registered callback {registration.Callback.Method.Name} must be associated with a component or define" + $" an explicit render mode type during registration."); diff --git a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs index 658117f93976..5440a92a3e9d 100644 --- a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs @@ -3,8 +3,26 @@ namespace Microsoft.AspNetCore.Components; -internal interface IPersistentComponentRegistration +internal interface IPersistentComponentRegistration : IComparable, IEquatable { public string Assembly { get; } public string FullTypeName { get; } + + public IComponentRenderMode? GetRenderModeOrDefault(); + + int IComparable.CompareTo(IPersistentComponentRegistration? other) + { + var assemblyComparison = string.Compare(Assembly, other?.Assembly, StringComparison.Ordinal); + if (assemblyComparison != 0) + { + return assemblyComparison; + } + return string.Compare(FullTypeName, other?.FullTypeName, StringComparison.Ordinal); + } + + bool IEquatable.Equals(IPersistentComponentRegistration? other) + { + return string.Equals(Assembly, other?.Assembly, StringComparison.Ordinal) && + string.Equals(FullTypeName, other?.FullTypeName, StringComparison.Ordinal); + } } diff --git a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs index 45bf9c63dbb9..37b9cb8c2d1a 100644 --- a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs @@ -6,10 +6,12 @@ namespace Microsoft.AspNetCore.Components; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class PersistentComponentRegistration : IPersistentComponentRegistration +internal class PersistentComponentRegistration(IComponentRenderMode componentRenderMode) : IPersistentComponentRegistration { public string Assembly => typeof(TService).Assembly.GetName().Name!; public string FullTypeName => typeof(TService).FullName!; + public IComponentRenderMode? GetRenderModeOrDefault() => componentRenderMode; + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; } diff --git a/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs b/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs new file mode 100644 index 000000000000..3f3155661af3 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +internal class PersistentServiceRenderMode(IComponentRenderMode componentRenderMode) +{ + public IComponentRenderMode ComponentRenderMode { get; } = componentRenderMode; +} diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index c99b783d3508..b45cf8ec7366 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -20,8 +20,8 @@ internal class PersistentServicesRegistry private readonly IServiceProvider _serviceProvider; private readonly PersistentServiceTypeCache _persistentServiceTypeCache; - private IEnumerable _registrations; - private PersistingComponentStateSubscription _subscription; + private IPersistentComponentRegistration[] _registrations; + private List _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); public PersistentServicesRegistry( @@ -30,7 +30,7 @@ public PersistentServicesRegistry( { _serviceProvider = serviceProvider; _persistentServiceTypeCache = new PersistentServiceTypeCache(); - _registrations = registrations; + _registrations = [.. registrations.Distinct().Order()]; } [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] @@ -40,6 +40,8 @@ private class PersistentComponentRegistration : IPersistentComponentRegistration public string FullTypeName { get; set; } = ""; + public IComponentRenderMode? GetRenderModeOrDefault() => null; + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; } @@ -76,24 +78,6 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC } } - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - private void PersistServicesState(PersistentComponentState state) - { - // Persist all the registrations - state.PersistAsJson(_registryKey, _registrations); - foreach (var registration in _registrations) - { - var type = ResolveType(registration.Assembly, registration.FullTypeName); - if (type == null) - { - continue; - } - - var instance = _serviceProvider.GetRequiredService(type); - PersistInstanceState(instance, type, state); - } - } - [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsJson(String, Object, Type)")] private static void PersistInstanceState(object instance, Type type, PersistentComponentState state) { @@ -114,28 +98,67 @@ private static void PersistInstanceState(object instance, Type type, PersistentC [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] internal void Restore(PersistentComponentState state) { - if (_registrations?.Any() != true && - state.TryTakeFromJson>(_registryKey, out var registry) && + if (_registrations.Length == 0 && + state.TryTakeFromJson(_registryKey, out var registry) && registry != null) { - _registrations = registry; + _registrations = registry ?? []; } RestoreRegistrationsIfAvailable(state); } + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] internal void RegisterForPersistence(PersistentComponentState state) { - if (!_subscription.Equals(default(PersistingComponentStateSubscription))) + if (_subscriptions.Count != 0) { return; } - _subscription = state.RegisterOnPersisting(() => + var comparer = EqualityComparer.Create((x, y) => { - PersistServicesState(state); - return Task.CompletedTask; + var xType = x?.GetType(); + var yType = y?.GetType(); + return xType == yType; }); + + var renderModes = new HashSet(comparer); + + var subscriptions = new List(_registrations.Length + 1); + for (var i = 1; i < _registrations.Length; i++) + { + var registration = _registrations[i]; + var type = ResolveType(registration.Assembly, registration.FullTypeName); + if (type == null) + { + continue; + } + + var renderMode = registration.GetRenderModeOrDefault(); + if (renderMode != null) + { + renderModes.Add(renderMode); + } + + var instance = _serviceProvider.GetRequiredService(type); + subscriptions.Add(state.RegisterOnPersisting(() => + { + PersistInstanceState(instance, type, state); + return Task.CompletedTask; + }, renderMode)); + } + + foreach (var renderMode in renderModes) + { + subscriptions.Add(state.RegisterOnPersisting(() => + { + state.PersistAsJson(_registryKey, _registrations); + return Task.CompletedTask; + }, renderMode)); + } + + _subscriptions = subscriptions; } private sealed class PropertiesAccessor diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 5be480ce98b6..854c066b9f52 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -3,5 +3,5 @@ Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager. Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions -static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddPersistentService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddPersistentService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index 406b1f8f96b7..eb38c85144cd 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -32,8 +32,11 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi /// /// The service type to register for persistence. /// The . + /// The to register the service for. /// The . - public static IServiceCollection AddPersistentService<[DynamicallyAccessedMembers(JsonSerialized)] TService>(this IServiceCollection services) + public static IServiceCollection AddPersistentService<[DynamicallyAccessedMembers(JsonSerialized)] TService>( + this IServiceCollection services, + IComponentRenderMode componentRenderMode) { // This method does something very similar to what we do when we register root components, except in this case we are registering services. // We collect a list of all the registrations on during static rendering mode and push those registrations to interactive mode. @@ -47,7 +50,8 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi // We can choose to fail when the service is not registered on DI. // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. services.TryAddScoped(); - services.TryAddEnumerable(ServiceDescriptor.Singleton>()); + //services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentServiceRenderMode(componentRenderMode))); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentComponentRegistration(componentRenderMode))); return services; } diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index acfbb86e3f80..f974b3ae0d7e 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -80,7 +80,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.AddSupplyValueFromFormProvider(); services.TryAddScoped(); services.TryAddScoped(sp => (EndpointAntiforgeryStateProvider)sp.GetRequiredService()); - services.AddPersistentService(); + services.AddPersistentService(RenderMode.InteractiveAuto); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Components/Samples/BlazorServerApp/Startup.cs b/src/Components/Samples/BlazorServerApp/Startup.cs index b2373b799adb..3ef25ca4048f 100644 --- a/src/Components/Samples/BlazorServerApp/Startup.cs +++ b/src/Components/Samples/BlazorServerApp/Startup.cs @@ -4,6 +4,7 @@ using BlazorServerApp.Data; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; namespace BlazorServerApp; @@ -23,7 +24,7 @@ public void ConfigureServices(IServiceCollection services) services.AddRazorPages(); services.AddServerSideBlazor(); services.AddScoped(); - services.AddPersistentService(); + services.AddPersistentService(RenderMode.InteractiveServer); services.AddSingleton(); } From 0377a29f7348f60bcf6966d529e621c84f9bf195 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 28 Feb 2025 12:54:59 +0100 Subject: [PATCH 03/37] Fix solution filter --- AspNetCore.sln | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AspNetCore.sln b/AspNetCore.sln index 09105a846cb0..905e1cb62970 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1772,6 +1772,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Http.R EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Http.ValidationsGenerator", "src\Http\Http.Extensions\gen\Microsoft.AspNetCore.Http.ValidationsGenerator\Microsoft.AspNetCore.Http.ValidationsGenerator.csproj", "{7899F5DD-AA7C-4561-BAC4-E2EC78B7D157}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Endpoints", "Endpoints", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CustomElements", "CustomElements", "{E22DD5A6-06E2-490E-BD32-88D629FD6668}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -11768,6 +11772,12 @@ Global {7324770C-0871-4D73-BE3D-5E2F3E9E1B1E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} {B54A8F61-60DE-4AD9-87CA-D102F230678E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} = {5E46DC83-C39C-4E3A-B242-C064607F4367} + {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {E22DD5A6-06E2-490E-BD32-88D629FD6668} + {AE4D272D-6F13-42C8-9404-C149188AFA33} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {5D438258-CB19-4282-814F-974ABBC71411} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {F5AE525F-F435-40F9-A567-4D5EC3B50D6E} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1} + {87D58D50-20D1-4091-88C5-8D88DCCC2DE3} = {6126DCE4-9692-4EE2-B240-C65743572995} + {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} {96EC4DD3-028E-6E27-5B14-08C21B07CE89} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {1BBD75D2-429D-D565-A98E-36437448E8C0} = {96EC4DD3-028E-6E27-5B14-08C21B07CE89} {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2} = {1BBD75D2-429D-D565-A98E-36437448E8C0} @@ -11777,6 +11787,8 @@ Global {01A75167-DF5A-AF38-8700-C3FBB2C2CFF5} = {225AEDCF-7162-4A86-AC74-06B84660B379} {E6D564C0-4CA5-411C-BF40-9802AF7900CB} = {01A75167-DF5A-AF38-8700-C3FBB2C2CFF5} {7899F5DD-AA7C-4561-BAC4-E2EC78B7D157} = {01A75167-DF5A-AF38-8700-C3FBB2C2CFF5} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} + {E22DD5A6-06E2-490E-BD32-88D629FD6668} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} From 8736766baeed9997b1404415a5a6a298c65113b1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 28 Feb 2025 12:57:38 +0100 Subject: [PATCH 04/37] Capture exceptions when disposing the host that result on false failures --- .../ServerFixtures/WebHostServerFixture.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs index 7d61fafe5b17..2baa92792a72 100644 --- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs +++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/WebHostServerFixture.cs @@ -36,13 +36,15 @@ public override void Dispose() private async ValueTask DisposeCore() { - // This can be null if creating the webhost throws, we don't want to throw here and hide - // the original exception. - Host?.Dispose(); - - if (Host != null) + try + { + await Host?.StopAsync(); + // This can be null if creating the webhost throws, we don't want to throw here and hide + // the original exception. + Host?.Dispose(); + } + catch { - await Host.StopAsync(); } } } From 16b72f5a2c018025a7ee260621a1a630cb020592 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 28 Feb 2025 19:19:37 +0100 Subject: [PATCH 05/37] Fix tests --- AspNetCore.sln | 3 ++ .../Microsoft.AspNetCore.Components.csproj | 14 +++--- .../ComponentStatePersistenceManager.cs | 25 +++++++++-- .../PersistentServicesRegistry.cs | 44 +++++++++---------- .../Components/src/PublicAPI.Unshipped.txt | 4 ++ ...tateProviderServiceCollectionExtensions.cs | 5 ++- src/Components/ComponentsNoDeps.slnf | 3 ++ .../Forms/EndpointAntiforgeryStateProvider.cs | 8 ++++ .../src/Rendering/EndpointHtmlRenderer.cs | 2 + .../Samples/BlazorUnitedApp/Program.cs | 4 ++ .../Properties/launchSettings.json | 2 +- .../Server/src/Circuits/CircuitFactory.cs | 2 + .../Server/src/Circuits/CircuitHost.cs | 2 + .../src/DefaultAntiforgeryStateProvider.cs | 2 +- .../src/Hosting/WebAssemblyHost.cs | 2 + .../src/Hosting/WebAssemblyHostBuilder.cs | 9 ++-- .../Properties/launchSettings.json | 1 + .../RazorComponentEndpointsStartup.cs | 8 ++++ 18 files changed, 100 insertions(+), 40 deletions(-) rename src/Components/Components/src/{Infrastructure => PersistentState}/ComponentStatePersistenceManager.cs (90%) diff --git a/AspNetCore.sln b/AspNetCore.sln index 905e1cb62970..b572530c7f5b 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -11773,11 +11773,14 @@ Global {B54A8F61-60DE-4AD9-87CA-D102F230678E} = {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} {D30A658D-61F6-444B-9AC7-F66A1A1B86B6} = {5E46DC83-C39C-4E3A-B242-C064607F4367} {76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {E22DD5A6-06E2-490E-BD32-88D629FD6668} + {A05652B3-953E-4915-9D7F-0E361D988815} = {0CE1CC26-98CE-4022-A81C-E32AAFC9B819} {AE4D272D-6F13-42C8-9404-C149188AFA33} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {5D438258-CB19-4282-814F-974ABBC71411} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {F5AE525F-F435-40F9-A567-4D5EC3B50D6E} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1} {87D58D50-20D1-4091-88C5-8D88DCCC2DE3} = {6126DCE4-9692-4EE2-B240-C65743572995} {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} + {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995} + {757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1} {96EC4DD3-028E-6E27-5B14-08C21B07CE89} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {1BBD75D2-429D-D565-A98E-36437448E8C0} = {96EC4DD3-028E-6E27-5B14-08C21B07CE89} {C10EB67A-F43E-4B85-AEFD-7064C9B3DBE2} = {1BBD75D2-429D-D565-A98E-36437448E8C0} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 9ebdbfc3778f..1beabf89624f 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -48,16 +48,12 @@ - + - - + + @@ -89,4 +85,8 @@ + + + + diff --git a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs similarity index 90% rename from src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs rename to src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 0ac6bf8e7bc1..215587d9d592 100644 --- a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -36,8 +36,8 @@ public ComponentStatePersistenceManager(ILogger public ComponentStatePersistenceManager(ILogger logger, IServiceProvider serviceProvider) : this(logger) { - _servicesRegistry = serviceProvider.GetService(); - _servicesRegistry?.RegisterForPersistence(State); + _servicesRegistry = new PersistentServicesRegistry(serviceProvider); + _servicesRegistry.RegisterForPersistence(State); } /// @@ -107,6 +107,25 @@ async Task PersistState(IPersistentComponentStateStore store) } } + /// + /// Initializes the render mode for state persisted by the platform. + /// + /// The render mode to use for state persisted by the platform. + /// when the render mode is already set. + public void SetPlatformRenderMode(IComponentRenderMode renderMode) + { + if (_servicesRegistry == null) + { + return; + } + else if (_servicesRegistry?.RenderMode != null) + { + throw new InvalidOperationException("Render mode already set."); + } + + _servicesRegistry!.RenderMode = renderMode; + } + private void InferRenderModes(Renderer renderer) { for (var i = 0; i < _registeredCallbacks.Count; i++) @@ -151,7 +170,7 @@ internal Task PauseAsync(IPersistentComponentStateStore store) { List? pendingCallbackTasks = null; - for (var i = 0; i < _registeredCallbacks.Count; i++) + for (var i = _registeredCallbacks.Count - 1; i >= 0; i--) { var registration = _registeredCallbacks[i]; diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index b45cf8ec7366..f4b39166981e 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.DependencyInjection; -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components.Infrastructure; internal class PersistentServicesRegistry { @@ -24,10 +24,11 @@ internal class PersistentServicesRegistry private List _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); - public PersistentServicesRegistry( - IServiceProvider serviceProvider, - IEnumerable registrations) + public IComponentRenderMode? RenderMode { get; internal set; } + + public PersistentServicesRegistry(IServiceProvider serviceProvider) { + var registrations = serviceProvider.GetRequiredService>(); _serviceProvider = serviceProvider; _persistentServiceTypeCache = new PersistentServiceTypeCache(); _registrations = [.. registrations.Distinct().Order()]; @@ -67,7 +68,7 @@ private void RestoreRegistrationsIfAvailable(PersistentComponentState state) [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(String, Type, out Object)")] private static void RestoreInstanceState(object instance, Type type, PersistentComponentState state) { - var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (Type runtimeType, Type declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); + var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); foreach (var (key, propertyType) in accessors.KeyTypePairs) { if (state.TryTakeFromJson(key, propertyType, out var result)) @@ -81,7 +82,7 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsJson(String, Object, Type)")] private static void PersistInstanceState(object instance, Type type, PersistentComponentState state) { - var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (Type runtimeType, Type declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); + var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); foreach (var (key, propertyType) in accessors.KeyTypePairs) { var (setter, getter) = accessors.GetAccessor(key); @@ -116,17 +117,8 @@ internal void RegisterForPersistence(PersistentComponentState state) return; } - var comparer = EqualityComparer.Create((x, y) => - { - var xType = x?.GetType(); - var yType = y?.GetType(); - return xType == yType; - }); - - var renderModes = new HashSet(comparer); - var subscriptions = new List(_registrations.Length + 1); - for (var i = 1; i < _registrations.Length; i++) + for (var i = 0; i < _registrations.Length; i++) { var registration = _registrations[i]; var type = ResolveType(registration.Assembly, registration.FullTypeName); @@ -136,10 +128,6 @@ internal void RegisterForPersistence(PersistentComponentState state) } var renderMode = registration.GetRenderModeOrDefault(); - if (renderMode != null) - { - renderModes.Add(renderMode); - } var instance = _serviceProvider.GetRequiredService(type); subscriptions.Add(state.RegisterOnPersisting(() => @@ -149,13 +137,13 @@ internal void RegisterForPersistence(PersistentComponentState state) }, renderMode)); } - foreach (var renderMode in renderModes) + if(RenderMode != null) { subscriptions.Add(state.RegisterOnPersisting(() => { state.PersistAsJson(_registryKey, _registrations); return Task.CompletedTask; - }, renderMode)); + }, RenderMode)); } _subscriptions = subscriptions; @@ -233,4 +221,16 @@ internal static IEnumerable GetCandidateBindableProperties( return _underlyingAccessors.TryGetValue(key, out var result) ? result : default; } } + + private class RenderModeComparer : IEqualityComparer + { + public static RenderModeComparer Instance { get; } = new RenderModeComparer(); + + public bool Equals(IComponentRenderMode? x, IComponentRenderMode? y) + { + return x?.GetType() == y?.GetType(); + } + + public int GetHashCode([DisallowNull] IComponentRenderMode? obj) => obj?.GetHashCode() ?? 1; + } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 854c066b9f52..1f515b9d1c0a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,9 @@ #nullable enable +Microsoft.AspNetCore.Components.IComponentRenderMode.Includes(Microsoft.AspNetCore.Components.IComponentRenderMode! other) -> bool Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void +Microsoft.AspNetCore.Components.Infrastructure.PersistentServicesRegistry +Microsoft.AspNetCore.Components.Infrastructure.PersistentServicesRegistry.PersistentServicesRegistry(System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index eb38c85144cd..58ab76075fdf 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -5,6 +5,8 @@ using static Microsoft.AspNetCore.Internal.LinkerFlags; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Components.Infrastructure; namespace Microsoft.AspNetCore.Components; @@ -49,8 +51,7 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi // Even as far as defaulting to Server (to avoid disclosing anything confidential to the client, even though is the Developer responsibility). // We can choose to fail when the service is not registered on DI. // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. - services.TryAddScoped(); - //services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentServiceRenderMode(componentRenderMode))); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentComponentRegistration(componentRenderMode))); return services; diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index 854164ba02f3..c40dd89bd317 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/src/Components/ComponentsNoDeps.slnf @@ -15,6 +15,7 @@ "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj", "src\\Components\\Forms\\test\\Microsoft.AspNetCore.Components.Forms.Tests.csproj", "src\\Components\\Samples\\BlazorServerApp\\BlazorServerApp.csproj", + "src\\Components\\Samples\\BlazorUnitedApp.Client\\BlazorUnitedApp.Client.csproj", "src\\Components\\Samples\\BlazorUnitedApp\\BlazorUnitedApp.csproj", "src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj", "src\\Components\\Server\\test\\Microsoft.AspNetCore.Components.Server.Tests.csproj", @@ -44,6 +45,7 @@ "src\\Components\\WebView\\WebView\\test\\Microsoft.AspNetCore.Components.WebView.Test.csproj", "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", "src\\Components\\Web\\test\\Microsoft.AspNetCore.Components.Web.Tests.csproj", + "src\\Components\\benchmarkapps\\BlazingPizza.Server\\BlazingPizza.Server.csproj", "src\\Components\\benchmarkapps\\Wasm.Performance\\ConsoleHost\\Wasm.Performance.ConsoleHost.csproj", "src\\Components\\benchmarkapps\\Wasm.Performance\\Driver\\Wasm.Performance.Driver.csproj", "src\\Components\\benchmarkapps\\Wasm.Performance\\TestApp\\Wasm.Performance.TestApp.csproj", @@ -51,6 +53,7 @@ "src\\Components\\test\\testassets\\BasicTestApp\\BasicTestApp.csproj", "src\\Components\\test\\testassets\\Components.TestServer\\Components.TestServer.csproj", "src\\Components\\test\\testassets\\Components.WasmMinimal\\Components.WasmMinimal.csproj", + "src\\Components\\test\\testassets\\Components.WasmRemoteAuthentication\\Components.WasmRemoteAuthentication.csproj", "src\\Components\\test\\testassets\\ComponentsApp.App\\ComponentsApp.App.csproj", "src\\Components\\test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj", "src\\Components\\test\\testassets\\GlobalizationWasmApp\\GlobalizationWasmApp.csproj", diff --git a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs index 01d44427f343..411a1c55778e 100644 --- a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs +++ b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs @@ -16,6 +16,14 @@ internal void SetRequestContext(HttpContext context) _context = context; } + public override AntiforgeryRequestToken? CurrentToken { + get + { + return field ??= GetAntiforgeryToken(); + } + set; + } + public override AntiforgeryRequestToken? GetAntiforgeryToken() { if (_context == null) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index e66ca88536a9..a28fe996e554 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Routing; @@ -113,6 +114,7 @@ internal static async Task InitializeStandardComponentServicesAsync( // It's important that this is initialized since a component might try to restore state during prerendering // (which will obviously not work, but should not fail) var componentApplicationLifetime = httpContext.RequestServices.GetRequiredService(); + componentApplicationLifetime.SetPlatformRenderMode(RenderMode.InteractiveAuto); await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore()); if (componentType != null) diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 6492f3fb3e50..6180e5ac7fc7 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -22,6 +22,10 @@ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } +else +{ + app.UseWebAssemblyDebugging(); +} app.UseHttpsRedirection(); diff --git a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json index 265de0cd64ad..fc639e6af0ed 100644 --- a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json @@ -23,7 +23,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7247;http://localhost:5265", - //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index c22aca391664..28ba43aada35 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -68,6 +69,7 @@ public async ValueTask CreateCircuitHostAsync( // This is the case on Blazor Web scenarios, which will initialize the state // when the first set of components is provided via an UpdateRootComponents call. var appLifetime = scope.ServiceProvider.GetRequiredService(); + appLifetime.SetPlatformRenderMode(RenderMode.InteractiveServer); await appLifetime.RestoreStateAsync(store); } diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index e34ccb8ca4cd..66c636ba01f5 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -757,6 +758,7 @@ internal Task UpdateRootComponents( // We only do this if we have no root components. Otherwise, the state would have been // provided during the start up process var appLifetime = _scope.ServiceProvider.GetRequiredService(); + appLifetime.SetPlatformRenderMode(RenderMode.InteractiveServer); await appLifetime.RestoreStateAsync(store); } diff --git a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs index 0750f0c82b91..b870d2fcce5a 100644 --- a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs +++ b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Forms; internal class DefaultAntiforgeryStateProvider : AntiforgeryStateProvider { [SupplyParameterFromPersistentComponentState] - public AntiforgeryRequestToken? CurrentToken { get; set; } + public virtual AntiforgeryRequestToken? CurrentToken { get; set; } /// public override AntiforgeryRequestToken? GetAntiforgeryToken() => CurrentToken; diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index 32cabdf6c564..21607a9f29d9 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Infrastructure; using Microsoft.AspNetCore.Components.WebAssembly.HotReload; using Microsoft.AspNetCore.Components.WebAssembly.Infrastructure; @@ -135,6 +136,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl new PrerenderComponentApplicationStore(_persistedState) : new PrerenderComponentApplicationStore(); + manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly); await manager.RestoreStateAsync(store); if (MetadataUpdater.IsSupported) diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 8bee06649691..53a30524922d 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -300,18 +300,19 @@ internal void InitializeDefaultServices() Services.AddSingleton(WebAssemblyNavigationManager.Instance); Services.AddSingleton(WebAssemblyNavigationInterception.Instance); Services.AddSingleton(WebAssemblyScrollToLocationHash.Instance); - Services.AddSingleton(_jsMethods); + Services.AddSingleton(_jsMethods); Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance)); - Services.AddSingleton(_ => _rootComponentCache ?? new()); + Services.AddSingleton(_ => _rootComponentCache ?? new()); Services.AddSingleton(); - Services.AddSingleton(sp => sp.GetRequiredService().State); - Services.AddSingleton(); + Services.AddSingleton(sp => sp.GetRequiredService().State); Services.AddSingleton(); Services.AddSingleton(); Services.AddLogging(builder => { builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance)); }); + Services.AddSingleton(); + Services.AddPersistentService(RenderMode.InteractiveWebAssembly); Services.AddSupplyValueFromQueryProvider(); } } diff --git a/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json b/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json index 38847dc2186f..81f00df340ff 100644 --- a/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json +++ b/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json @@ -11,6 +11,7 @@ // time you restart the test server "TESTSERVER_USE_DETERMINISTIC_PORTS": "true" }, + "inspectUri": "{wsProtocol}://{url.hostname}:5006/subdir/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5000" } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 943ee32d635c..231c8da0bd52 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -61,6 +61,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseDeveloperExceptionPage(); } + else + { + app.UseWebAssemblyDebugging(); + } app.Map("/subdir", app => { @@ -70,6 +74,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseExceptionHandler("/Error", createScopeForErrors: true); } + else + { + app.UseWebAssemblyDebugging(); + } app.UseRouting(); UseFakeAuthState(app); From ce89edd5ff40d86f62fc517255c7224b00c91528 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 28 Feb 2025 19:55:52 +0100 Subject: [PATCH 06/37] Fix build --- .../src/PersistentState/ComponentStatePersistenceManager.cs | 1 - src/Components/Components/src/PublicAPI.Unshipped.txt | 3 --- ...istentComponentStateProviderServiceCollectionExtensions.cs | 4 +--- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 215587d9d592..c8697a6773d9 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Infrastructure; diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 1f515b9d1c0a..d232c30f62fe 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,9 +1,6 @@ #nullable enable -Microsoft.AspNetCore.Components.IComponentRenderMode.Includes(Microsoft.AspNetCore.Components.IComponentRenderMode! other) -> bool Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void -Microsoft.AspNetCore.Components.Infrastructure.PersistentServicesRegistry -Microsoft.AspNetCore.Components.Infrastructure.PersistentServicesRegistry.PersistentServicesRegistry(System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index 58ab76075fdf..db4c24245f4f 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -2,11 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using static Microsoft.AspNetCore.Internal.LinkerFlags; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using System.Runtime.InteropServices; -using Microsoft.AspNetCore.Components.Infrastructure; +using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components; From 9e64c8a0eaf143c464177d415523e95b45d4652f Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 13:14:31 +0100 Subject: [PATCH 07/37] Fix tests --- .../ComponentStatePersistenceManagerTest.cs | 2 +- .../src/Forms/EndpointAntiforgeryStateProvider.cs | 14 +++----------- .../Shared/src/DefaultAntiforgeryStateProvider.cs | 10 ++++++++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs index 4b005ffc72df..a98bda5a0cbb 100644 --- a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs @@ -148,7 +148,7 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() await persistTask; // Assert - Assert.Equal(new[] { 1, 2, 3, 4 }, sequence); + Assert.Equal(new[] { 2, 1, 3, 4 }, sequence); } [Fact] diff --git a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs index 411a1c55778e..b1c113beca88 100644 --- a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs +++ b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs @@ -16,20 +16,12 @@ internal void SetRequestContext(HttpContext context) _context = context; } - public override AntiforgeryRequestToken? CurrentToken { - get - { - return field ??= GetAntiforgeryToken(); - } - set; - } - public override AntiforgeryRequestToken? GetAntiforgeryToken() { if (_context == null) { // We're in an interactive context. Use the token persisted during static rendering. - return base.GetAntiforgeryToken(); + return _currentToken; } // We already have a callback setup to generate the token when the response starts if needed. @@ -42,7 +34,7 @@ public override AntiforgeryRequestToken? CurrentToken { return null; } - CurrentToken = new AntiforgeryRequestToken(tokens.RequestToken, tokens.FormFieldName); - return CurrentToken; + _currentToken = new AntiforgeryRequestToken(tokens.RequestToken, tokens.FormFieldName); + return _currentToken; } } diff --git a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs index b870d2fcce5a..75dec5721d38 100644 --- a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs +++ b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs @@ -5,9 +5,15 @@ namespace Microsoft.AspNetCore.Components.Forms; internal class DefaultAntiforgeryStateProvider : AntiforgeryStateProvider { + protected AntiforgeryRequestToken? _currentToken; + [SupplyParameterFromPersistentComponentState] - public virtual AntiforgeryRequestToken? CurrentToken { get; set; } + public AntiforgeryRequestToken? CurrentToken + { + get => _currentToken ??= GetAntiforgeryToken(); + set => _currentToken = value; + } /// - public override AntiforgeryRequestToken? GetAntiforgeryToken() => CurrentToken; + public override AntiforgeryRequestToken? GetAntiforgeryToken() => _currentToken; } From 29dcd96c9bda8f475ae567db78e8a611ddda25da Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 16:10:00 +0100 Subject: [PATCH 08/37] Swallow exceptions instead of failing the test during cleanup --- src/Shared/E2ETesting/BrowserFixture.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Shared/E2ETesting/BrowserFixture.cs b/src/Shared/E2ETesting/BrowserFixture.cs index 5f13637ed9b6..e046634232b3 100644 --- a/src/Shared/E2ETesting/BrowserFixture.cs +++ b/src/Shared/E2ETesting/BrowserFixture.cs @@ -65,14 +65,20 @@ public static bool IsHostAutomationSupported() public async Task DisposeAsync() { - var browsers = _browsers.Values; - foreach (var (browser, _) in browsers) + try { - browser?.Quit(); - browser?.Dispose(); - } + var browsers = _browsers.Values; + foreach (var (browser, _) in browsers) + { + browser?.Quit(); + browser?.Dispose(); + } - await DeleteBrowserUserProfileDirectoriesAsync(); + await DeleteBrowserUserProfileDirectoriesAsync(); + } + catch + { + } } private async Task DeleteBrowserUserProfileDirectoriesAsync() From e14ea2bd5d8948fec0e588daac72c8afd6981ea8 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 16:11:05 +0100 Subject: [PATCH 09/37] Undo Components.csproj changes --- .../src/Microsoft.AspNetCore.Components.csproj | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 1beabf89624f..9ebdbfc3778f 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -48,12 +48,16 @@ - + - - + + @@ -85,8 +89,4 @@ - - - - From 24502b5540450427fe7c8f2b015bd7ea30e2488f Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 16:46:10 +0100 Subject: [PATCH 10/37] Cleanups --- .../src/PersistentComponentState.cs | 5 + .../ComponentStatePersistenceManager.cs | 13 +- .../IPersistentComponentRegistration.cs | 19 +-- .../PersistentComponentRegistration.cs | 2 +- .../PersistentServiceRenderMode.cs | 9 -- .../PersistentServiceTypeCache.cs | 2 +- .../PersistentServicesRegistry.cs | 120 ++++++++---------- ...omPersistentComponentStateValueProvider.cs | 70 +++++----- 8 files changed, 100 insertions(+), 140 deletions(-) delete mode 100644 src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index 8f53a706468a..a02a4804b5c9 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -56,6 +56,11 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call { ArgumentNullException.ThrowIfNull(callback); + if (PersistingState) + { + throw new InvalidOperationException("Registering a callback during while persisting state is not allowed."); + } + var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode); _registeredCallbacks.Add(persistenceCallback); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index c8697a6773d9..4e83a8370f8c 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -152,13 +152,6 @@ private void InferRenderModes(Renderer renderer) continue; } - if (registration.Callback.Target is PersistentServicesRegistry) - { - // The registration callback is associated with the services registry, which is a special case. - // We don't need to infer the render mode for this case. - continue; - } - throw new InvalidOperationException( $"The registered callback {registration.Callback.Method.Name} must be associated with a component or define" + $" an explicit render mode type during registration."); @@ -169,6 +162,12 @@ internal Task PauseAsync(IPersistentComponentStateStore store) { List? pendingCallbackTasks = null; + // We are iterating backwards to allow the callbacks to remove themselves from the list. + // Otherwise, we would have to make a copy of the list to avoid running into situations + // where we don't run all the callbacks because the count of the list changed while we + // were iterating over it. + // It is not allowed to register a callback while we are persisting the state, so we don't + // need to worry about new callbacks being added to the list. for (var i = _registeredCallbacks.Count - 1; i >= 0; i--) { var registration = _registeredCallbacks[i]; diff --git a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs index 5440a92a3e9d..75db4bd20884 100644 --- a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs @@ -3,26 +3,11 @@ namespace Microsoft.AspNetCore.Components; -internal interface IPersistentComponentRegistration : IComparable, IEquatable +// Represents a component that is registered for state persistence. +internal interface IPersistentComponentRegistration { public string Assembly { get; } public string FullTypeName { get; } public IComponentRenderMode? GetRenderModeOrDefault(); - - int IComparable.CompareTo(IPersistentComponentRegistration? other) - { - var assemblyComparison = string.Compare(Assembly, other?.Assembly, StringComparison.Ordinal); - if (assemblyComparison != 0) - { - return assemblyComparison; - } - return string.Compare(FullTypeName, other?.FullTypeName, StringComparison.Ordinal); - } - - bool IEquatable.Equals(IPersistentComponentRegistration? other) - { - return string.Equals(Assembly, other?.Assembly, StringComparison.Ordinal) && - string.Equals(FullTypeName, other?.FullTypeName, StringComparison.Ordinal); - } } diff --git a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs index 37b9cb8c2d1a..b381d8fe8119 100644 --- a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal class PersistentComponentRegistration(IComponentRenderMode componentRenderMode) : IPersistentComponentRegistration +internal sealed class PersistentComponentRegistration(IComponentRenderMode componentRenderMode) : IPersistentComponentRegistration { public string Assembly => typeof(TService).Assembly.GetName().Name!; public string FullTypeName => typeof(TService).FullName!; diff --git a/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs b/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs deleted file mode 100644 index 3f3155661af3..000000000000 --- a/src/Components/Components/src/PersistentState/PersistentServiceRenderMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Components; - -internal class PersistentServiceRenderMode(IComponentRenderMode componentRenderMode) -{ - public IComponentRenderMode ComponentRenderMode { get; } = componentRenderMode; -} diff --git a/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs b/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs index a9a656da9619..434fa6a5adc4 100644 --- a/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs +++ b/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components; -// A cache for registered persistent services. This is similar to the `RootComponentTypeCache`. +// A cache for registered persistent services. This is similar to the RootComponentTypeCache. internal sealed class PersistentServiceTypeCache { private readonly ConcurrentDictionary _typeToKeyLookUp = new(); diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index f4b39166981e..10d398fe7e16 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Components.Infrastructure; -internal class PersistentServicesRegistry +internal sealed class PersistentServicesRegistry { private static readonly string _registryKey = typeof(PersistentServicesRegistry).FullName!; @@ -23,7 +23,6 @@ internal class PersistentServicesRegistry private IPersistentComponentRegistration[] _registrations; private List _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); - public IComponentRenderMode? RenderMode { get; internal set; } public PersistentServicesRegistry(IServiceProvider serviceProvider) @@ -31,52 +30,47 @@ public PersistentServicesRegistry(IServiceProvider serviceProvider) var registrations = serviceProvider.GetRequiredService>(); _serviceProvider = serviceProvider; _persistentServiceTypeCache = new PersistentServiceTypeCache(); - _registrations = [.. registrations.Distinct().Order()]; - } - - [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] - private class PersistentComponentRegistration : IPersistentComponentRegistration - { - public string Assembly { get; set; } = ""; - - public string FullTypeName { get; set; } = ""; - - public IComponentRenderMode? GetRenderModeOrDefault() => null; - - private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; + _registrations = [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; } [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - private void RestoreRegistrationsIfAvailable(PersistentComponentState state) + internal void RegisterForPersistence(PersistentComponentState state) { - foreach (var registration in _registrations) + if (_subscriptions.Count != 0) { + return; + } + + var subscriptions = new List(_registrations.Length + 1); + for (var i = 0; i < _registrations.Length; i++) + { + var registration = _registrations[i]; var type = ResolveType(registration.Assembly, registration.FullTypeName); if (type == null) { continue; } - var instance = _serviceProvider.GetService(type); - if (instance != null) + var renderMode = registration.GetRenderModeOrDefault(); + + var instance = _serviceProvider.GetRequiredService(type); + subscriptions.Add(state.RegisterOnPersisting(() => { - RestoreInstanceState(instance, type, state); - } + PersistInstanceState(instance, type, state); + return Task.CompletedTask; + }, renderMode)); } - } - [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(String, Type, out Object)")] - private static void RestoreInstanceState(object instance, Type type, PersistentComponentState state) - { - var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); - foreach (var (key, propertyType) in accessors.KeyTypePairs) + if(RenderMode != null) { - if (state.TryTakeFromJson(key, propertyType, out var result)) + subscriptions.Add(state.RegisterOnPersisting(() => { - var (setter, getter) = accessors.GetAccessor(key); - setter.SetValue(instance, result!); - } + state.PersistAsJson(_registryKey, _registrations); + return Task.CompletedTask; + }, RenderMode)); } + + _subscriptions = subscriptions; } [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsJson(String, Object, Type)")] @@ -94,8 +88,6 @@ private static void PersistInstanceState(object instance, Type type, PersistentC } } - private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] internal void Restore(PersistentComponentState state) { @@ -110,45 +102,41 @@ internal void Restore(PersistentComponentState state) } [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - internal void RegisterForPersistence(PersistentComponentState state) + private void RestoreRegistrationsIfAvailable(PersistentComponentState state) { - if (_subscriptions.Count != 0) - { - return; - } - - var subscriptions = new List(_registrations.Length + 1); - for (var i = 0; i < _registrations.Length; i++) + foreach (var registration in _registrations) { - var registration = _registrations[i]; var type = ResolveType(registration.Assembly, registration.FullTypeName); if (type == null) { continue; } - var renderMode = registration.GetRenderModeOrDefault(); - - var instance = _serviceProvider.GetRequiredService(type); - subscriptions.Add(state.RegisterOnPersisting(() => + var instance = _serviceProvider.GetService(type); + if (instance != null) { - PersistInstanceState(instance, type, state); - return Task.CompletedTask; - }, renderMode)); + RestoreInstanceState(instance, type, state); + } } + } - if(RenderMode != null) + [RequiresUnreferencedCode("Calls Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(String, Type, out Object)")] + private static void RestoreInstanceState(object instance, Type type, PersistentComponentState state) + { + var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); + foreach (var (key, propertyType) in accessors.KeyTypePairs) { - subscriptions.Add(state.RegisterOnPersisting(() => + if (state.TryTakeFromJson(key, propertyType, out var result)) { - state.PersistAsJson(_registryKey, _registrations); - return Task.CompletedTask; - }, RenderMode)); + var (setter, getter) = accessors.GetAccessor(key); + setter.SetValue(instance, result!); + } } - - _subscriptions = subscriptions; } + + private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); + private sealed class PropertiesAccessor { internal const BindingFlags BindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase; @@ -216,21 +204,19 @@ internal static IEnumerable GetCandidateBindableProperties( [DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType) => MemberAssignment.GetPropertiesIncludingInherited(targetType, BindablePropertyFlags); - internal (PropertySetter setter, PropertyGetter getter) GetAccessor(string key) - { - return _underlyingAccessors.TryGetValue(key, out var result) ? result : default; - } + internal (PropertySetter setter, PropertyGetter getter) GetAccessor(string key) => + _underlyingAccessors.TryGetValue(key, out var result) ? result : default; } - private class RenderModeComparer : IEqualityComparer + [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] + private class PersistentComponentRegistration : IPersistentComponentRegistration { - public static RenderModeComparer Instance { get; } = new RenderModeComparer(); + public string Assembly { get; set; } = ""; - public bool Equals(IComponentRenderMode? x, IComponentRenderMode? y) - { - return x?.GetType() == y?.GetType(); - } + public string FullTypeName { get; set; } = ""; - public int GetHashCode([DisallowNull] IComponentRenderMode? obj) => obj?.GetHashCode() ?? 1; + public IComponentRenderMode? GetRenderModeOrDefault() => null; + + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; } } diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 39b5ef65acb9..05ead169e906 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -15,9 +15,9 @@ namespace Microsoft.AspNetCore.Components; internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(PersistentComponentState state) : ICascadingValueSupplier { private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new(); + private readonly Dictionary _subscriptions = []; public bool IsFixed => false; - private readonly Dictionary _subscriptions = []; public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterFromPersistentComponentStateAttribute; @@ -30,10 +30,8 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) "Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] - public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) - { - return state.TryTakeFromJson(parameterInfo.PropertyName, parameterInfo.PropertyType, out var value) ? value : null; - } + public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) => + state.TryTakeFromJson(parameterInfo.PropertyName, parameterInfo.PropertyType, out var value) ? value : null; [UnconditionalSuppressMessage( "ReflectionAnalysis", @@ -51,6 +49,31 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + var propertyName = parameterInfo.PropertyName; + var propertyType = parameterInfo.PropertyType; + _subscriptions[subscriber] = state.RegisterOnPersisting(() => + { + var storageKey = ComputeKey(subscriber, propertyName); + var property = subscriber.Component.GetType().GetProperty(propertyName)!.GetValue(subscriber.Component)!; + state.PersistAsJson(storageKey, property, propertyType); + return Task.CompletedTask; + }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); + } + + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + if (_subscriptions.TryGetValue(subscriber, out var subscription)) + { + subscription.Dispose(); + _subscriptions.Remove(subscriber); + } + } + private static string ComputeKey(ComponentState componentState, string propertyName) { // We need to come up with a pseudo-unique key for the storage key. @@ -144,45 +167,16 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!; - private static string GetParentComponentType(ComponentState componentState) => componentState.LogicalParentComponentState == null ? - "" : - GetComponentType(componentState.LogicalParentComponentState); + private static string GetParentComponentType(ComponentState componentState) => + componentState.LogicalParentComponentState == null ? "" : GetComponentType(componentState.LogicalParentComponentState); - private static byte[] KeyFactory((string, string, string) parts) - { - return Encoding.UTF8.GetBytes(string.Join(".", parts.Item1, parts.Item2, parts.Item3)); - } + private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) => + Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName)); - // TODO: Complete later, support common types that have a string representation. private static bool IsSerializableKey(object key) => key is { } componentKey && componentKey.GetType() is Type type && (Type.GetTypeCode(type) != TypeCode.Object || type == typeof(Guid) || type == typeof(DateOnly) || type == typeof(TimeOnly)); - - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] - public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - { - var propertyName = parameterInfo.PropertyName; - var propertyType = parameterInfo.PropertyType; - _subscriptions[subscriber] = state.RegisterOnPersisting(() => - { - var storageKey = ComputeKey(subscriber, propertyName); - var property = subscriber.Component.GetType().GetProperty(propertyName)!.GetValue(subscriber.Component)!; - state.PersistAsJson(storageKey, property, propertyType); - return Task.CompletedTask; - }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); - } - - public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) - { - if (_subscriptions.TryGetValue(subscriber, out var subscription)) - { - subscription.Dispose(); - _subscriptions.Remove(subscriber); - } - } } From 05ac066dc6cef7c974b22145c345bdf3f7e54170 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 16:47:43 +0100 Subject: [PATCH 11/37] Undo sample changes --- .../Data/SamplePersistentService.cs | 12 ---------- .../BlazorServerApp/Pages/FetchData.razor | 11 ++++------ .../Samples/BlazorServerApp/Pages/Index.razor | 22 +------------------ .../Pages/StatefulComponent.razor | 17 -------------- .../BlazorServerApp/Pages/_Host.cshtml | 2 -- .../BlazorServerApp/Pages/_Layout.cshtml | 8 ++----- .../Properties/launchSettings.json | 1 - .../Samples/BlazorServerApp/Startup.cs | 5 ----- .../Samples/BlazorUnitedApp/Program.cs | 4 ---- .../Properties/launchSettings.json | 2 +- 10 files changed, 8 insertions(+), 76 deletions(-) delete mode 100644 src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs delete mode 100644 src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor diff --git a/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs b/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs deleted file mode 100644 index a920a021ab46..000000000000 --- a/src/Components/Samples/BlazorServerApp/Data/SamplePersistentService.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components; - -namespace BlazorServerApp.Data; - -public class SamplePersistentService -{ - [SupplyParameterFromPersistentComponentState] - public string SampleState { get; set; } = ""; -} diff --git a/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor b/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor index 54ce11abc072..b992e82e7da1 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor +++ b/src/Components/Samples/BlazorServerApp/Pages/FetchData.razor @@ -9,7 +9,7 @@

This component demonstrates fetching data from a service.

-@if (Forecasts == null) +@if (forecasts == null) {

Loading...

} @@ -25,7 +25,7 @@ else - @foreach (var forecast in Forecasts) + @foreach (var forecast in forecasts) { @forecast.Date.ToShortDateString() @@ -39,13 +39,10 @@ else } @code { - [SupplyParameterFromPersistentComponentState] public WeatherForecast[]? Forecasts { get; set; } + WeatherForecast[]? forecasts; protected override async Task OnInitializedAsync() { - if (Forecasts == null) - { - Forecasts = await ForecastService.GetForecastAsync(DateTime.Now); - } + forecasts = await ForecastService.GetForecastAsync(DateTime.Now); } } diff --git a/src/Components/Samples/BlazorServerApp/Pages/Index.razor b/src/Components/Samples/BlazorServerApp/Pages/Index.razor index 8289af037014..7b5a15e0e22b 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/Index.razor +++ b/src/Components/Samples/BlazorServerApp/Pages/Index.razor @@ -1,27 +1,7 @@ @page "/" -@inject Data.SamplePersistentService Service -@inject AntiforgeryStateProvider AntiforgeryStateProvider Index

Hello, world!

- - -

Service state: @Service.SampleState

- -

Antifoguerty @AntiforgeryStateProvider.GetAntiforgeryToken()?.Value

- -@for (var i = 0; i < 10; i++) -{ - var current = i; - - -} - -@code { - protected override void OnInitialized() - { - Service.SampleState ??= Guid.NewGuid().ToString(); - } -} +Welcome to your new app. diff --git a/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor b/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor deleted file mode 100644 index 6f23ac146ef3..000000000000 --- a/src/Components/Samples/BlazorServerApp/Pages/StatefulComponent.razor +++ /dev/null @@ -1,17 +0,0 @@ -

StatefulComponent

- -

My value is @Value

- -@code { - [Parameter] public Guid InitialValue { get; set; } - - [SupplyParameterFromPersistentComponentState] public Guid Value { get; set; } - - override protected void OnInitialized() - { - if (Value == Guid.Empty) - { - Value = InitialValue; - } - } -} diff --git a/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml b/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml index aa4d27c4bfcd..6153324e90ea 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml +++ b/src/Components/Samples/BlazorServerApp/Pages/_Host.cshtml @@ -7,5 +7,3 @@ } - - diff --git a/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml b/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml index ad65a057bc2a..91499422deb9 100644 --- a/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml +++ b/src/Components/Samples/BlazorServerApp/Pages/_Layout.cshtml @@ -17,15 +17,11 @@ @RenderBody() - - diff --git a/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json b/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json index 61c2ff93a6f4..a18eefe81370 100644 --- a/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorServerApp/Properties/launchSettings.json @@ -11,7 +11,6 @@ "BlazorServerApp": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "https://localhost:5001/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/src/Components/Samples/BlazorServerApp/Startup.cs b/src/Components/Samples/BlazorServerApp/Startup.cs index 3ef25ca4048f..d630a127c242 100644 --- a/src/Components/Samples/BlazorServerApp/Startup.cs +++ b/src/Components/Samples/BlazorServerApp/Startup.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using BlazorServerApp.Data; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Web; namespace BlazorServerApp; @@ -23,8 +20,6 @@ public void ConfigureServices(IServiceCollection services) { services.AddRazorPages(); services.AddServerSideBlazor(); - services.AddScoped(); - services.AddPersistentService(RenderMode.InteractiveServer); services.AddSingleton(); } diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 6180e5ac7fc7..6492f3fb3e50 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -22,10 +22,6 @@ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } -else -{ - app.UseWebAssemblyDebugging(); -} app.UseHttpsRedirection(); diff --git a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json index fc639e6af0ed..265de0cd64ad 100644 --- a/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json +++ b/src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json @@ -23,7 +23,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7247;http://localhost:5265", - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From 92bdc151073dba1c9829ef834bca6911af5c45b1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 16:49:52 +0100 Subject: [PATCH 12/37] Undo test infrastructure changes --- .../Components.TestServer/Properties/launchSettings.json | 1 - .../RazorComponentEndpointsStartup.cs | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json b/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json index 81f00df340ff..38847dc2186f 100644 --- a/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json +++ b/src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json @@ -11,7 +11,6 @@ // time you restart the test server "TESTSERVER_USE_DETERMINISTIC_PORTS": "true" }, - "inspectUri": "{wsProtocol}://{url.hostname}:5006/subdir/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5000" } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 231c8da0bd52..943ee32d635c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -61,10 +61,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseDeveloperExceptionPage(); } - else - { - app.UseWebAssemblyDebugging(); - } app.Map("/subdir", app => { @@ -74,10 +70,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseExceptionHandler("/Error", createScopeForErrors: true); } - else - { - app.UseWebAssemblyDebugging(); - } app.UseRouting(); UseFakeAuthState(app); From cd3a946c87733f4a6e125358fbaaba862a7a7fbb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 17:33:09 +0100 Subject: [PATCH 13/37] Fix build --- .../Components/src/PersistentState/PersistentServicesRegistry.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index 10d398fe7e16..50c0cfd24caa 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -134,7 +134,6 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC } } - private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); private sealed class PropertiesAccessor From 1e781211d4cf36e1b860c8bdcf39d2fd1d099c45 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 3 Mar 2025 18:08:52 +0100 Subject: [PATCH 14/37] Apply suggestions from code review --- src/Components/Components/src/PersistentComponentState.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a02a4804b5c9..a3dd2fdddc81 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -58,7 +58,7 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call if (PersistingState) { - throw new InvalidOperationException("Registering a callback during while persisting state is not allowed."); + throw new InvalidOperationException("Registering a callback while persisting state is not allowed."); } var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode); From 5e0215d477b54cd1411e3ae8c72ef6e7f1f12ebc Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 5 Mar 2025 17:26:44 +0100 Subject: [PATCH 15/37] Add PersistentServicesRegistry tests --- .../ComponentStatePersistenceManager.cs | 10 +- .../PersistentServicesRegistry.cs | 17 +- .../ComponentApplicationStateTest.cs | 32 +- .../ComponentStatePersistenceManagerTest.cs | 161 ++++-- .../PersistentServicesRegistryTest.cs | 500 ++++++++++++++++++ 5 files changed, 655 insertions(+), 65 deletions(-) rename src/Components/Components/test/{Lifetime => PersistentState}/ComponentApplicationStateTest.cs (88%) rename src/Components/Components/test/{Lifetime => PersistentState}/ComponentStatePersistenceManagerTest.cs (54%) create mode 100644 src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 4e83a8370f8c..c301ae6276e6 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -36,9 +36,14 @@ public ComponentStatePersistenceManager(ILogger logger, IServiceProvider serviceProvider) : this(logger) { _servicesRegistry = new PersistentServicesRegistry(serviceProvider); - _servicesRegistry.RegisterForPersistence(State); } + // For testing purposes only + internal PersistentServicesRegistry? ServicesRegistry => _servicesRegistry; + + // For testing purposes only + internal List RegisteredCallbacks => _registeredCallbacks; + /// /// Gets the associated with the . /// @@ -73,6 +78,9 @@ public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer ren async Task PauseAndPersistState() { + // Ensure that we register the services before we start persisting the state. + _servicesRegistry?.RegisterForPersistence(State); + State.PersistingState = true; if (store is IEnumerable compositeStore) diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index 50c0cfd24caa..86d375a66a3e 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -23,16 +23,19 @@ internal sealed class PersistentServicesRegistry private IPersistentComponentRegistration[] _registrations; private List _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); - public IComponentRenderMode? RenderMode { get; internal set; } public PersistentServicesRegistry(IServiceProvider serviceProvider) { var registrations = serviceProvider.GetRequiredService>(); _serviceProvider = serviceProvider; _persistentServiceTypeCache = new PersistentServiceTypeCache(); - _registrations = [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; + _registrations = ResolveRegistrations(registrations); } + internal IComponentRenderMode? RenderMode { get; set; } + + internal IReadOnlyList Registrations => _registrations; + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] internal void RegisterForPersistence(PersistentComponentState state) { @@ -61,7 +64,7 @@ internal void RegisterForPersistence(PersistentComponentState state) }, renderMode)); } - if(RenderMode != null) + if (RenderMode != null) { subscriptions.Add(state.RegisterOnPersisting(() => { @@ -91,11 +94,9 @@ private static void PersistInstanceState(object instance, Type type, PersistentC [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] internal void Restore(PersistentComponentState state) { - if (_registrations.Length == 0 && - state.TryTakeFromJson(_registryKey, out var registry) && - registry != null) + if (state.TryTakeFromJson(_registryKey, out var registry) && registry != null) { - _registrations = registry ?? []; + _registrations = ResolveRegistrations(_registrations.Concat(registry)); } RestoreRegistrationsIfAvailable(state); @@ -134,6 +135,8 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC } } + private static IPersistentComponentRegistration[] ResolveRegistrations(IEnumerable registrations) => [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; + private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); private sealed class PropertiesAccessor diff --git a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs b/src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs similarity index 88% rename from src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs rename to src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs index bed42d42dfbf..5c13d647e3a9 100644 --- a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs @@ -11,7 +11,7 @@ public class ComponentApplicationStateTest public void InitializeExistingState_SetupsState() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), new List()); + var applicationState = new PersistentComponentState(new Dictionary(), []); var existingState = new Dictionary { ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 }) @@ -29,7 +29,7 @@ public void InitializeExistingState_SetupsState() public void InitializeExistingState_ThrowsIfAlreadyInitialized() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), new List()); + var applicationState = new PersistentComponentState(new Dictionary(), []); var existingState = new Dictionary { ["MyState"] = new byte[] { 1, 2, 3, 4 } @@ -41,11 +41,25 @@ public void InitializeExistingState_ThrowsIfAlreadyInitialized() Assert.Throws(() => applicationState.InitializeExistingState(existingState)); } + [Fact] + public void RegisterOnPersisting_ThrowsIfCalledDuringOnPersisting() + { + // Arrange + var currentState = new Dictionary(); + var applicationState = new PersistentComponentState(currentState, []) + { + PersistingState = true + }; + + // Act & Assert + Assert.Throws(() => applicationState.RegisterOnPersisting(() => Task.CompletedTask)); + } + [Fact] public void TryRetrieveState_ReturnsStateWhenItExists() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), new List()); + var applicationState = new PersistentComponentState(new Dictionary(), []); var existingState = new Dictionary { ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 }) @@ -65,7 +79,7 @@ public void PersistState_SavesDataToTheStoreAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, new List()) + var applicationState = new PersistentComponentState(currentState, []) { PersistingState = true }; @@ -84,7 +98,7 @@ public void PersistState_ThrowsForDuplicateKeys() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, new List()) + var applicationState = new PersistentComponentState(currentState, []) { PersistingState = true }; @@ -101,7 +115,7 @@ public void PersistAsJson_SerializesTheDataToJsonAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, new List()) + var applicationState = new PersistentComponentState(currentState, []) { PersistingState = true }; @@ -120,7 +134,7 @@ public void PersistAsJson_NullValueAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, new List()) + var applicationState = new PersistentComponentState(currentState, []) { PersistingState = true }; @@ -140,7 +154,7 @@ public void TryRetrieveFromJson_DeserializesTheDataFromJson() var myState = new byte[] { 1, 2, 3, 4 }; var serialized = JsonSerializer.SerializeToUtf8Bytes(myState); var existingState = new Dictionary() { ["MyState"] = serialized }; - var applicationState = new PersistentComponentState(new Dictionary(), new List()); + var applicationState = new PersistentComponentState(new Dictionary(), []); applicationState.InitializeExistingState(existingState); @@ -158,7 +172,7 @@ public void TryRetrieveFromJson_NullValue() // Arrange var serialized = JsonSerializer.SerializeToUtf8Bytes(null); var existingState = new Dictionary() { ["MyState"] = serialized }; - var applicationState = new PersistentComponentState(new Dictionary(), new List()); + var applicationState = new PersistentComponentState(new Dictionary(), []); applicationState.InitializeExistingState(existingState); diff --git a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs similarity index 54% rename from src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs rename to src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index a98bda5a0cbb..caca9154441c 100644 --- a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -10,11 +10,32 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; +using Moq; namespace Microsoft.AspNetCore.Components; public class ComponentStatePersistenceManagerTest { + [Fact] + public void Constructor_InitializesPersistentServicesRegistry() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddScoped(sp => new TestStore([])) + .AddPersistentService(Mock.Of()) + .BuildServiceProvider(); + + // Act + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider); + persistenceManager.SetPlatformRenderMode(new TestRenderMode()); + + // Assert + Assert.NotNull(persistenceManager.ServicesRegistry); + Assert.Empty(persistenceManager.RegisteredCallbacks); + } + [Fact] public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore() { @@ -25,13 +46,15 @@ public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) }; var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); // Act - await lifetime.RestoreStateAsync(store); + await persistenceManager.RestoreStateAsync(store); // Assert - Assert.True(lifetime.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); Assert.Empty(state); Assert.Equal(data, retrieved); } @@ -45,12 +68,14 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() ["MyState"] = [0, 1, 2, 3, 4] }; var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); - await lifetime.RestoreStateAsync(store); + await persistenceManager.RestoreStateAsync(store); // Assert - await Assert.ThrowsAsync(() => lifetime.RestoreStateAsync(store)); + await Assert.ThrowsAsync(() => persistenceManager.RestoreStateAsync(store)); } private IServiceProvider CreateServiceProvider() => @@ -62,20 +87,48 @@ public async Task PersistStateAsync_ThrowsWhenCallbackRenerModeCannotBeInferred( // Arrange var state = new Dictionary(); var store = new CompositeTestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; - lifetime.State.RegisterOnPersisting(() => + persistenceManager.State.RegisterOnPersisting(() => { - lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); + persistenceManager.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); return Task.CompletedTask; }); // Act // Assert - await Assert.ThrowsAsync(() => lifetime.PersistStateAsync(store, renderer)); + await Assert.ThrowsAsync(() => persistenceManager.PersistStateAsync(store, renderer)); + } + + [Fact] + public async Task PersistStateAsync_PersistsRegistry() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddScoped(sp => new TestStore([])) + .AddPersistentService(new TestRenderMode()) + .BuildServiceProvider(); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider); + persistenceManager.SetPlatformRenderMode(new TestRenderMode()); + var testStore = new TestStore([]); + + // Act + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + // Assert + var persisted = Assert.Single(testStore.State); + Assert.True(testStore.State.TryGetValue(typeof(PersistentServicesRegistry).FullName, out var registrations)); + var registration = Assert.Single(JsonSerializer.Deserialize(registrations, JsonSerializerOptions.Web)); + Assert.Equal(typeof(TestStore).Assembly.GetName().Name, registration.Assembly); + Assert.Equal(typeof(TestStore).FullName, registration.FullTypeName); } [Fact] @@ -84,19 +137,21 @@ public async Task PersistStateAsync_SavesPersistedStateToTheStore() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; - lifetime.State.RegisterOnPersisting(() => + persistenceManager.State.RegisterOnPersisting(() => { - lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); + persistenceManager.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); return Task.CompletedTask; }, new TestRenderMode()); // Act - await lifetime.PersistStateAsync(store, renderer); + await persistenceManager.PersistStateAsync(store, renderer); // Assert Assert.True(store.State.TryGetValue("MyState", out var persisted)); @@ -109,15 +164,17 @@ public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; - lifetime.State.RegisterOnPersisting(() => { invoked = true; return default; }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(() => { invoked = true; return default; }, new TestRenderMode()); // Act - await lifetime.PersistStateAsync(store, renderer); + await persistenceManager.PersistStateAsync(store, renderer); // Assert Assert.True(invoked); @@ -129,7 +186,9 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); var renderer = new TestRenderer(); var sequence = new List { }; @@ -137,11 +196,11 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() var tcs = new TaskCompletionSource(); var tcs2 = new TaskCompletionSource(); - lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }, new TestRenderMode()); - lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }, new TestRenderMode()); // Act - var persistTask = lifetime.PersistStateAsync(store, renderer); + var persistTask = persistenceManager.PersistStateAsync(store, renderer); tcs.SetResult(); tcs2.SetResult(); @@ -157,7 +216,9 @@ public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDispo // Arrange var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); var renderer = new TestRenderer(); var sequence = new List { }; @@ -165,14 +226,14 @@ public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDispo var tcs = new TaskCompletionSource(); var tcs2 = new TaskCompletionSource(); - var subscription1 = lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }); - var subscription2 = lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }); + var subscription1 = persistenceManager.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }); + var subscription2 = persistenceManager.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }); // Act subscription1.Dispose(); subscription2.Dispose(); - var persistTask = lifetime.PersistStateAsync(store, renderer); + var persistTask = persistenceManager.PersistStateAsync(store, renderer); tcs.SetResult(); tcs2.SetResult(); @@ -191,16 +252,18 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist var logger = loggerFactory.CreateLogger(); var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + CreateServiceProvider()); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; - lifetime.State.RegisterOnPersisting(() => throw new InvalidOperationException(), new TestRenderMode()); - lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(() => throw new InvalidOperationException(), new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode()); // Act - await lifetime.PersistStateAsync(store, renderer); + await persistenceManager.PersistStateAsync(store, renderer); // Assert Assert.True(invoked); @@ -217,16 +280,18 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist var logger = loggerFactory.CreateLogger(); var state = new Dictionary(); var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger, CreateServiceProvider()); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + CreateServiceProvider()); var renderer = new TestRenderer(); var invoked = false; var tcs = new TaskCompletionSource(); - lifetime.State.RegisterOnPersisting(async () => { await tcs.Task; throw new InvalidOperationException(); }, new TestRenderMode()); - lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(async () => { await tcs.Task; throw new InvalidOperationException(); }, new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode()); // Act - var persistTask = lifetime.PersistStateAsync(store, renderer); + var persistTask = persistenceManager.PersistStateAsync(store, renderer); tcs.SetResult(); await persistTask; @@ -258,14 +323,9 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) } } - private class TestStore : IPersistentComponentStateStore + private class TestStore(Dictionary initialState) : IPersistentComponentStateStore { - public TestStore(IDictionary initialState) - { - State = initialState; - } - - public IDictionary State { get; set; } + public IDictionary State { get; set; } = initialState; public Task> GetPersistedStateAsync() { @@ -280,14 +340,10 @@ public Task PersistStateAsync(IReadOnlyDictionary state) } } - private class CompositeTestStore : IPersistentComponentStateStore, IEnumerable + private class CompositeTestStore(Dictionary initialState) + : IPersistentComponentStateStore, IEnumerable { - public CompositeTestStore(IDictionary initialState) - { - State = initialState; - } - - public IDictionary State { get; set; } + public Dictionary State { get; set; } = initialState; public IEnumerator GetEnumerator() { @@ -297,7 +353,7 @@ public IEnumerator GetEnumerator() public Task> GetPersistedStateAsync() { - return Task.FromResult(State); + return Task.FromResult(State as IDictionary); } public Task PersistStateAsync(IReadOnlyDictionary state) @@ -315,6 +371,15 @@ IEnumerator IEnumerable.GetEnumerator() private class TestRenderMode : IComponentRenderMode { + } + private class PersistentService : IPersistentComponentRegistration + { + public string Assembly { get; set; } + + public string FullTypeName { get; set; } + + public IComponentRenderMode GetRenderModeOrDefault() => null; } + } diff --git a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs new file mode 100644 index 000000000000..f12c8c5b9b9b --- /dev/null +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -0,0 +1,500 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Components.PersistentState; + +public class PersistentServicesRegistryTest +{ + [Fact] + public async Task PersistStateAsync_PersistsServiceProperties() + { + // Arrange + var state = "myState"; + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var testService = scope.GetService(); + testService.State = state; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + var registry = persistenceManager.ServicesRegistry; + + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + var componentState = new PersistentComponentState(testStore.State, []); + + var secondScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var secondManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + secondScope); + + await secondManager.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var service = secondScope.GetRequiredService(); + Assert.Equal(state, service.State); + } + + [Fact] + public async Task PersistStateAsync_PersistsBaseServiceProperties() + { + // Arrange + var state = "myState"; + var componentRenderMode = new TestRenderMode(); + var serviceProviderOne = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var serviceProviderTwo = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProviderOne.CreateAsyncScope().ServiceProvider; + var derivedOne = scope.GetService() as DerivedOne; + derivedOne.State = state; + + var persistenceManagerOne = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); + + var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; + var persistenceManagerTwo = new ComponentStatePersistenceManager( + NullLogger.Instance, + scopeTwo); + + await persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var derivedTwo = scopeTwo.GetRequiredService() as DerivedTwo; + Assert.Equal(state, derivedTwo.State); + } + + [Fact] + public async Task PersistStateAsync_PersistsBaseClassPropertiesInDerivedInstance() + { + // Arrange + var state = "baseState"; + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var derivedService = serviceProvider.GetService() as DerivedService; + derivedService.State = state; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider.CreateAsyncScope().ServiceProvider); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + var secondManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider.CreateAsyncScope().ServiceProvider); + + await secondManager.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var restoredService = serviceProvider.GetRequiredService() as DerivedService; + Assert.Equal(state, restoredService.State); + } + + [Fact] + public async Task PersistStateAsync_DoesNotPersistNullServiceProperties() + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var testService = scope.GetService(); + testService.State = null; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + // Act + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + // Assert + var kvp = Assert.Single(testStore.State); + Assert.Equal(typeof(PersistentServicesRegistry).FullName, kvp.Key); + } + + [Fact] + public async Task PersistStateAsync_DoesNotThrowIfServiceNotResolvedDuringRestore() + { + // Arrange + var state = "myState"; + var componentRenderMode = new TestRenderMode(); + var serviceProviderOne = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProviderOne.CreateAsyncScope().ServiceProvider; + var derivedOne = scope.GetService() as DerivedOne; + derivedOne.State = state; + + var persistenceManagerOne = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); + + var serviceProviderTwo = new ServiceCollection() + .BuildServiceProvider(); + + var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; + var persistenceManagerTwo = new ComponentStatePersistenceManager( + NullLogger.Instance, + scopeTwo); + + // Act & Assert + var exception = await Record.ExceptionAsync(() => persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State))); + Assert.Null(exception); + } + + [Fact] + public async Task PersistStateAsync_RestoresStateForPersistedRegistrations() + { + // Arrange + var state = "myState"; + var componentRenderMode = new TestRenderMode(); + var serviceProviderOne = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var serviceProviderTwo = new ServiceCollection() + .AddScoped() + .BuildServiceProvider(); + + var scope = serviceProviderOne.CreateAsyncScope().ServiceProvider; + var derivedOne = scope.GetService() as DerivedOne; + derivedOne.State = state; + + var persistenceManagerOne = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); + + var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; + var persistenceManagerTwo = new ComponentStatePersistenceManager( + NullLogger.Instance, + scopeTwo); + + await persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var derivedTwo = scopeTwo.GetRequiredService() as DerivedTwo; + Assert.Equal(state, derivedTwo.State); + } + + [Fact] + public async Task PersistStateAsync_DoesNotThrow_WhenTypeCantBeFoundForPersistedRegistrations() + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var serviceProviderOne = new ServiceCollection() + .AddSingleton(new TestPersistentRegistration { Assembly = "FakeAssembly", FullTypeName = "FakeType" }) + .BuildServiceProvider(); + + var serviceProviderTwo = new ServiceCollection() + .BuildServiceProvider(); + + var scope = serviceProviderOne.CreateAsyncScope().ServiceProvider; + + var persistenceManagerOne = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); + + var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; + var persistenceManagerTwo = new ComponentStatePersistenceManager( + NullLogger.Instance, + scopeTwo); + + var exception = await Record.ExceptionAsync(async () => await persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State))); + Assert.Null(exception); + } + + [Fact] + public void ResolveRegistrations_RemovesDuplicateRegistrations() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) // Duplicate + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly2", FullTypeName = "Type2" }) + .BuildServiceProvider(); + + var registry = new PersistentServicesRegistry(serviceProvider); + + // Act + var registrations = registry.Registrations; + + // Assert + Assert.Equal(2, registrations.Count); + Assert.Contains(registrations, r => r.Assembly == "Assembly1" && r.FullTypeName == "Type1"); + Assert.Contains(registrations, r => r.Assembly == "Assembly2" && r.FullTypeName == "Type2"); + } + + private class TestStore : IPersistentComponentStateStore + { + public IDictionary State { get; set; } + + public TestStore(IDictionary initialState) + { + State = initialState; + } + + public Task> GetPersistedStateAsync() + { + return Task.FromResult(State); + } + + public Task PersistStateAsync(IReadOnlyDictionary state) + { + State = state.ToDictionary(k => k.Key, v => v.Value); + return Task.CompletedTask; + } + } + + [Fact] + public async Task PersistStateAsync_PersistsMultipleServicesWithDifferentStates() + { + // Arrange + var state1 = "state1"; + var state2 = "state2"; + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddScoped() + .AddPersistentService(componentRenderMode) + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var testService = scope.GetService(); + var anotherTestService = scope.GetService(); + testService.State = state1; + anotherTestService.State = state2; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + var secondScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var secondManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + secondScope); + + await secondManager.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var restoredTestService = secondScope.GetRequiredService(); + var restoredAnotherTestService = secondScope.GetRequiredService(); + Assert.Equal(state1, restoredTestService.State); + Assert.Equal(state2, restoredAnotherTestService.State); + } + + [Fact] + public async Task PersistStateAsync_PersistsServiceWithComplexState() + { + // Arrange + var customer = new Customer + { + Name = "John Doe", + Addresses = + [ + new Address { Street = "123 Main St", ZipCode = "12345" }, + new Address { Street = "456 Elm St", ZipCode = "67890" } + ] + }; + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var customerService = scope.GetService(); + customerService.Customer = customer; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + var secondScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var secondManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + secondScope); + + await secondManager.RestoreStateAsync(new TestStore(testStore.State)); + + // Assert + var restoredCustomerService = secondScope.GetRequiredService(); + Assert.Equal(customer.Name, restoredCustomerService.Customer.Name); + Assert.Equal(customer.Addresses.Count, restoredCustomerService.Customer.Addresses.Count); + for (var i = 0; i < customer.Addresses.Count; i++) + { + Assert.Equal(customer.Addresses[i].Street, restoredCustomerService.Customer.Addresses[i].Street); + Assert.Equal(customer.Addresses[i].ZipCode, restoredCustomerService.Customer.Addresses[i].ZipCode); + } + } + + private class AnotherTestService + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + } + + private class CustomerService + { + [SupplyParameterFromPersistentComponentState] + public Customer Customer { get; set; } + } + + private class Customer + { + public string Name { get; set; } + public List
Addresses { get; set; } + } + + private class Address + { + public string Street { get; set; } + public string ZipCode { get; set; } + } + + private class TestRenderMode : IComponentRenderMode + { + } + + private class TestService + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + } + + private class BaseTestService + { + public string BaseState { get; } + + public BaseTestService(string baseState) + { + BaseState = baseState; + } + } + + private class DerivedTestService : BaseTestService + { + public string DerivedState { get; } + + public DerivedTestService(string baseState, string derivedState) + : base(baseState) + { + DerivedState = derivedState; + } + } + + private class TestRenderer : Renderer + { + public TestRenderer() : base(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) + { + } + + private readonly Dispatcher _dispatcher = Dispatcher.CreateDefault(); + + public override Dispatcher Dispatcher => _dispatcher; + + protected override void HandleException(Exception exception) + { + throw new NotImplementedException(); + } + + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) + { + throw new NotImplementedException(); + } + } + + private class BaseService + { + } + + private class DerivedOne : BaseService + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + } + + private class DerivedTwo : BaseService + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + } + + private class BaseServiceWithProperty + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + } + + private class DerivedService : BaseServiceWithProperty + { + } + + private class TestPersistentRegistration : IPersistentComponentRegistration + { + public string Assembly { get; set; } + public string FullTypeName { get; set; } + + public IComponentRenderMode GetRenderModeOrDefault() => null; + } +} From 5e60fee579d50f78e649a83489daf6548d3cd33b Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 6 Mar 2025 20:40:29 +0100 Subject: [PATCH 16/37] More tests --- .../ComponentStatePersistenceManager.cs | 61 +- ...tateProviderServiceCollectionExtensions.cs | 4 - ...omPersistentComponentStateValueProvider.cs | 97 +++- .../ComponentStatePersistenceManagerTest.cs | 49 ++ ...sistentComponentStateValueProviderTests.cs | 519 ++++++++++++++++++ 5 files changed, 693 insertions(+), 37 deletions(-) create mode 100644 src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index c301ae6276e6..72c1ca666411 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -94,23 +94,33 @@ async Task PauseAndPersistState() // the next store can start with a clean slate. foreach (var store in compositeStore) { - await PersistState(store); + var result = await TryPersistState(store); + if (!result) + { + break; + } _currentState.Clear(); } } else { - await PersistState(store); + await TryPersistState(store); } State.PersistingState = false; _stateIsPersisted = true; } - async Task PersistState(IPersistentComponentStateStore store) + async Task TryPersistState(IPersistentComponentStateStore store) { - await PauseAsync(store); + if (!await TryPauseAsync(store)) + { + _currentState.Clear(); + return false; + } + await store.PersistStateAsync(_currentState); + return true; } } @@ -166,9 +176,9 @@ private void InferRenderModes(Renderer renderer) } } - internal Task PauseAsync(IPersistentComponentStateStore store) + internal Task TryPauseAsync(IPersistentComponentStateStore store) { - List? pendingCallbackTasks = null; + List>? pendingCallbackTasks = null; // We are iterating backwards to allow the callbacks to remove themselves from the list. // Otherwise, we would have to make a copy of the list to avoid running into situations @@ -189,31 +199,38 @@ internal Task PauseAsync(IPersistentComponentStateStore store) continue; } - var result = ExecuteCallback(registration.Callback, _logger); + var result = TryExecuteCallback(registration.Callback, _logger); if (!result.IsCompletedSuccessfully) { - pendingCallbackTasks ??= new(); + pendingCallbackTasks ??= []; pendingCallbackTasks.Add(result); } + else + { + if (!result.Result) + { + return Task.FromResult(false); + } + } } if (pendingCallbackTasks != null) { - return Task.WhenAll(pendingCallbackTasks); + return AnyTaskFailed(pendingCallbackTasks); } else { - return Task.CompletedTask; + return Task.FromResult(true); } - static Task ExecuteCallback(Func callback, ILogger logger) + static Task TryExecuteCallback(Func callback, ILogger logger) { try { var current = callback(); if (current.IsCompletedSuccessfully) { - return current; + return Task.FromResult(true); } else { @@ -223,21 +240,35 @@ static Task ExecuteCallback(Func callback, ILogger logger) + static async Task Awaited(Task task, ILogger logger) { try { await task; + return true; } catch (Exception ex) { logger.LogError(new EventId(1000, "PersistenceCallbackError"), ex, "There was an error executing a callback while pausing the application."); - return; + return false; } } } + + static async Task AnyTaskFailed(List> pendingCallbackTasks) + { + foreach (var result in await Task.WhenAll(pendingCallbackTasks)) + { + if (!result) + { + return false; + } + } + + return true; + } } } diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index db4c24245f4f..70adf5adc968 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -45,11 +45,7 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi // We look for the assembly in the current list of loaded assemblies. // We look for the type inside the assembly. // We resolve the service from the DI container. - // TODO: We can support registering for a specific render mode at this level (that way no info gets sent to the client accidentally 4 example). - // Even as far as defaulting to Server (to avoid disclosing anything confidential to the client, even though is the Developer responsibility). - // We can choose to fail when the service is not registered on DI. // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. - services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentComponentRegistration(componentRenderMode))); return services; diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 05ead169e906..d772b0656de0 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -18,6 +18,8 @@ internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(P private readonly Dictionary _subscriptions = []; public bool IsFixed => false; + // For testing purposes only + internal Dictionary Subscriptions => _subscriptions; public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterFromPersistentComponentStateAttribute; @@ -31,7 +33,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) => - state.TryTakeFromJson(parameterInfo.PropertyName, parameterInfo.PropertyType, out var value) ? value : null; + throw new InvalidOperationException("Using this provider requires a key."); [UnconditionalSuppressMessage( "ReflectionAnalysis", @@ -74,7 +76,8 @@ public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo par } } - private static string ComputeKey(ComponentState componentState, string propertyName) + // Internal for testing only + internal static string ComputeKey(ComponentState componentState, string propertyName) { // We need to come up with a pseudo-unique key for the storage key. // We need to consider the property name, the component type, and its position within the component tree. @@ -110,20 +113,58 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta { Span keyBuffer = stackalloc byte[1024]; preKey.CopyTo(keyBuffer); - if (key is IUtf8SpanFormattable formattable) + if (key is IUtf8SpanFormattable spanFormattable) { - while (!formattable.TryFormat(keyBuffer[preKey.Length..], out var written, "{0:G}", CultureInfo.InvariantCulture)) + var wroteKey = false; + while (!wroteKey) { - // It is really unlikely that we will enter here, but we need to handle this case - Debug.Assert(written == 0); - var newPool = pool == null ? ArrayPool.Shared.Rent(2048) : ArrayPool.Shared.Rent(pool.Length * 2); - keyBuffer[0..preKey.Length].CopyTo(newPool); - keyBuffer = newPool; - if (pool != null) + wroteKey = spanFormattable.TryFormat(keyBuffer[preKey.Length..], out var written, "", CultureInfo.InvariantCulture); + if (!wroteKey) { - ArrayPool.Shared.Return(pool, clearArray: true); + // It is really unlikely that we will enter here, but we need to handle this case + Debug.Assert(written == 0); + GrowBuffer(preKey, ref pool, ref keyBuffer); + } + else + { + keyBuffer = keyBuffer[..(preKey.Length + written)]; + } + } + } + else if (key is IFormattable formattable) + { + var keyString = formattable.ToString("", CultureInfo.InvariantCulture); + var wroteKey = false; + while (!wroteKey) + { + wroteKey = Encoding.UTF8.TryGetBytes(keyString, keyBuffer[preKey.Length..], out var written); + if (!wroteKey) + { + Debug.Assert(written == 0); + GrowBuffer(preKey, ref pool, ref keyBuffer); + } + else + { + keyBuffer = keyBuffer[..(preKey.Length + written)]; + } + } + } + else if (key is IConvertible convertible) + { + var keyString = convertible.ToString(CultureInfo.InvariantCulture); + var wroteKey = false; + while (!wroteKey) + { + wroteKey = Encoding.UTF8.TryGetBytes(keyString, keyBuffer[preKey.Length..], out var written); + if (!wroteKey) + { + Debug.Assert(written == 0); + GrowBuffer(preKey, ref pool, ref keyBuffer); + } + else + { + keyBuffer = keyBuffer[..(preKey.Length + written)]; } - pool = newPool; } } @@ -139,6 +180,18 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta } } + private static void GrowBuffer(byte[] preKey, ref byte[]? pool, ref Span keyBuffer) + { + var newPool = pool == null ? ArrayPool.Shared.Rent(2048) : ArrayPool.Shared.Rent(pool.Length * 2); + keyBuffer[0..preKey.Length].CopyTo(newPool); + keyBuffer = newPool; + if (pool != null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + pool = newPool; + } + private static object? GetSerializableKey(ComponentState componentState) { if (componentState.LogicalParentComponentState is not { } parentComponentState) @@ -173,10 +226,18 @@ private static string GetParentComponentType(ComponentState componentState) => private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) => Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName)); - private static bool IsSerializableKey(object key) => - key is { } componentKey && componentKey.GetType() is Type type && - (Type.GetTypeCode(type) != TypeCode.Object - || type == typeof(Guid) - || type == typeof(DateOnly) - || type == typeof(TimeOnly)); + private static bool IsSerializableKey(object key) + { + if (key == null) + { + return false; + } + var keyType = key.GetType(); + var result = Type.GetTypeCode(keyType) != TypeCode.Object + || keyType == typeof(Guid) + || keyType == typeof(DateOnly) + || keyType == typeof(TimeOnly); + + return result; + } } diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index caca9154441c..730ba3c95ce8 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -302,6 +302,55 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist Assert.Equal(LogLevel.Error, log.LogLevel); } + [Fact] + public async Task PersistStateAsync_InvokesAllCallbacksEvenIfACallbackIsRemovedAsPartOfRunningIt() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var renderer = new TestRenderer(); + + var executionSequence = new List(); + + persistenceManager.State.RegisterOnPersisting(() => + { + executionSequence.Add(1); + return Task.CompletedTask; + }, new TestRenderMode()); + + PersistingComponentStateSubscription subscription2 = default; + subscription2 = persistenceManager.State.RegisterOnPersisting(() => + { + executionSequence.Add(2); + subscription2.Dispose(); + return Task.CompletedTask; + }, new TestRenderMode()); + + var tcs = new TaskCompletionSource(); + persistenceManager.State.RegisterOnPersisting(async () => + { + executionSequence.Add(3); + await tcs.Task; + executionSequence.Add(4); + }, new TestRenderMode()); + + // Act + var persistTask = persistenceManager.PersistStateAsync(store, renderer); + tcs.SetResult(); // Allow the async callback to complete + await persistTask; + + // Assert + Assert.Contains(3, executionSequence); + Assert.Contains(2, executionSequence); + Assert.Contains(1, executionSequence); + Assert.Contains(4, executionSequence); + + Assert.Equal(4, executionSequence.Count); + } + private class TestRenderer : Renderer { public TestRenderer() : base(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) diff --git a/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs new file mode 100644 index 000000000000..c480c431be0f --- /dev/null +++ b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs @@ -0,0 +1,519 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; + +namespace Microsoft.AspNetCore.Components; +public class SupplyParameterFromPersistentComponentStateValueProviderTests +{ + [Fact] + public void CanRestoreState_ForComponentWithProperties() + { + // Arrange + var state = new PersistentComponentState( + new Dictionary(), + []); + + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(state); + var renderer = new TestRenderer(); + var component = new TestComponent(); + // Update the method call to match the correct signature + var componentStates = CreateComponentState(renderer, [(component, null)], null); + var componentState = componentStates.First(); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + InitializeState(state, + new List<(ComponentState, string, string)> + { + (componentState, cascadingParameterInfo.PropertyName, "state") + }); + + // Act + var result = provider.GetCurrentValue(componentState, cascadingParameterInfo); + + // Assert + Assert.Equal("state", result); + } + + [Fact] + public void Subscribe_RegistersPersistenceCallback() + { + // Arrange + var state = new PersistentComponentState( + new Dictionary(), + []); + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(state); + var renderer = new TestRenderer(); + var component = new TestComponent(); + var componentStates = CreateComponentState(renderer, [(component, null)], null); + var componentState = componentStates.First(); + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + // Act + provider.Subscribe(componentState, cascadingParameterInfo); + + // Assert + Assert.Single(provider.Subscriptions); + } + + [Fact] + public void Unsubscribe_RemovesCallbackFromRegisteredCallbacks() + { + // Arrange + var state = new PersistentComponentState( + new Dictionary(), + []); + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(state); + var renderer = new TestRenderer(); + var component = new TestComponent(); + var componentStates = CreateComponentState(renderer, [(component, null)], null); + var componentState = componentStates.First(); + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + provider.Subscribe(componentState, cascadingParameterInfo); + + // Act + provider.Unsubscribe(componentState, cascadingParameterInfo); + + // Assert + Assert.Empty(provider.Subscriptions); + } + + [Fact] + public async Task PersistAsync_PersistsStateForSubscribedComponentProperties() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var component = new TestComponent { State = "testValue" }; + var componentStates = CreateComponentState(renderer, [(component, null)], null); + var componentState = componentStates.First(); + + // Create the provider and subscribe the component + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + // The key will be a hash computed from the component type and property name + // We can verify the state was persisted by checking if any entry exists in the store + Assert.NotEmpty(store.State); + + // To verify the actual content, we need to create a new state and restore it + var newState = new PersistentComponentState(new Dictionary(), []); + newState.InitializeExistingState(store.State); + + // The key used for storing the property value is computed by the SupplyParameterFromPersistentComponentStateValueProvider + var key = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key, out var retrievedValue)); + Assert.Equal("testValue", retrievedValue); + } + + [Fact] + public async Task PersistAsync_UsesParentComponentType_WhenAvailable() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component = new TestComponent { State = "testValue" }; + var componentStates = CreateComponentState(renderer, [(component, null)], parentComponent); + var componentState = componentStates.First(); + + // Create the provider and subscribe the component + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + // The key will be a hash computed from the parent component type, component type, and property name + Assert.NotEmpty(store.State); + + // To verify the actual content, we need to create a new state and restore it + var newState = new PersistentComponentState(new Dictionary(), []); + newState.InitializeExistingState(store.State); + + // The key used for storing the property value is computed by the SupplyParameterFromPersistentComponentStateValueProvider + var key = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key, out var retrievedValue)); + Assert.Equal("testValue", retrievedValue); + } + + [Fact] + public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_WhenParentProvidesDifferentKeys() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, 1), (component2, 2)], parentComponent); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the component + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + // The key will be a hash computed from the parent component type, component type, and property name + Assert.NotEmpty(store.State); + + // To verify the actual content, we need to create a new state and restore it + var newState = new PersistentComponentState(new Dictionary(), []); + newState.InitializeExistingState(store.State); + + // The key used for storing the property value is computed by the SupplyParameterFromPersistentComponentStateValueProvider + var key1 = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue)); + Assert.Equal("testValue1", retrievedValue); + + var key2 = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState2, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key2, out retrievedValue)); + Assert.Equal("testValue2", retrievedValue); + } + + public static TheoryData ValidKeyTypesData => new TheoryData + { + { true, false }, + { 'A', 'B' }, + { (sbyte)42, (sbyte)-42 }, + { (byte)240, (byte)15 }, + { (short)12345, (short)-12345 }, + { (ushort)54321, (ushort)12345 }, + { 42, -42 }, + { (uint)3000000000, (uint)1000000000 }, + { 9223372036854775807L, -9223372036854775808L }, + { (ulong)18446744073709551615UL, (ulong)1 }, + { 3.14159f, -3.14159f }, + { Math.PI, -Math.PI }, + { 123456.789m, -123456.789m }, + { new DateTime(2023, 1, 1), new DateTime(2023, 12, 31) }, + { "key1", "key2" }, + { Guid.NewGuid(), Guid.NewGuid() }, + { new DateOnly(2023, 1, 1), new DateOnly(2023, 12, 31) }, + { new TimeOnly(12, 34, 56), new TimeOnly(23, 45, 56) }, + }; + + [Theory] + [MemberData(nameof(ValidKeyTypesData))] + public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_SupportsDifferentKeyTypes( + object componentKey1, + object componentKey2) + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, componentKey1), (component2, componentKey2)], parentComponent); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the component + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + // The key will be a hash computed from the parent component type, component type, and property name + Assert.NotEmpty(store.State); + + // To verify the actual content, we need to create a new state and restore it + var newState = new PersistentComponentState(new Dictionary(), []); + newState.InitializeExistingState(store.State); + + // The key used for storing the property value is computed by the SupplyParameterFromPersistentComponentStateValueProvider + var key1 = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue)); + Assert.Equal("testValue1", retrievedValue); + + var key2 = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(componentState2, cascadingParameterInfo.PropertyName); + Assert.True(newState.TryTakeFromJson(key2, out retrievedValue)); + Assert.Equal("testValue2", retrievedValue); + } + + [Fact] + public async Task PersistenceFails_IfMultipleComponentsOfSameType_TryToPersistDataWithoutParentComponents() + { + // Arrange + var (logger, sink) = CreateTestLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], null); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the components + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.Empty(store.State); + Assert.Contains(sink.Writes, w => w is { LogLevel: LogLevel.Error } && w.EventId == new EventId(1000, "PersistenceCallbackError")); + } + + private static (TestLogger logger, TestSink testLoggerSink) CreateTestLogger() + { + var testLoggerSink = new TestSink(); + var testLoggerFactory = new TestLoggerFactory(testLoggerSink, enabled: true); + var logger = new TestLogger(testLoggerFactory); + return (logger, testLoggerSink); + } + + [Fact] + public async Task PersistentceFails_IfMultipleComponentsOfSameType_TryToPersistDataWithParentComponentOfSameType() + { + // Arrange + var (logger, sink) = CreateTestLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], parentComponent); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the components + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.Empty(store.State); + Assert.Contains(sink.Writes, w => w is { LogLevel: LogLevel.Error } && w.EventId == new EventId(1000, "PersistenceCallbackError")); + } + + [Fact] + public async Task PersistenceFails_MultipleComponentsUseTheSameKey() + { + // Arrange + var (logger, sink) = CreateTestLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, 1), (component2, 1)], parentComponent); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the components + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.Empty(store.State); + Assert.Contains(sink.Writes, w => w is { LogLevel: LogLevel.Error } && w.EventId == new EventId(1000, "PersistenceCallbackError")); + } + + public static TheoryData InvalidKeyTypesData => new TheoryData + { + { new object(), new object() }, + { new TestComponent(), new TestComponent() } + }; + + [Theory] + [MemberData(nameof(InvalidKeyTypesData))] + public async Task PersistenceFails_MultipleComponentsUseInvalidKeyTypes(object componentKeyType1, object componentKeyType2) + { + // Arrange + var (logger, sink) = CreateTestLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + new ServiceCollection().BuildServiceProvider()); + + var renderer = new TestRenderer(); + var parentComponent = new ParentComponent(); + var component1 = new TestComponent { State = "testValue1" }; + var component2 = new TestComponent { State = "testValue2" }; + var componentStates = CreateComponentState(renderer, [(component1, componentKeyType1), (component2, componentKeyType2)], parentComponent); + var componentState1 = componentStates.First(); + var componentState2 = componentStates.Last(); + + // Create the provider and subscribe the components + var provider = new SupplyParameterFromPersistentComponentStateValueProvider(persistenceManager.State); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.Empty(store.State); + Assert.Contains(sink.Writes, w => w is { LogLevel: LogLevel.Error } && w.EventId == new EventId(1000, "PersistenceCallbackError")); + } + + private static void InitializeState(PersistentComponentState state, List<(ComponentState componentState, string propertyName, string value)> items) + { + var dictionary = new Dictionary(); + foreach (var item in items) + { + var key = SupplyParameterFromPersistentComponentStateValueProvider.ComputeKey(item.componentState, item.propertyName); + dictionary[key] = JsonSerializer.SerializeToUtf8Bytes(item.value, JsonSerializerOptions.Web); + } + state.InitializeExistingState(dictionary); + } + + private static CascadingParameterInfo CreateCascadingParameterInfo(string propertyName, Type propertyType) + { + return new CascadingParameterInfo( + new SupplyParameterFromPersistentComponentStateAttribute(), + propertyName, + propertyType); + } + + private static List CreateComponentState( + TestRenderer renderer, + List<(TestComponent, object)> components, + ParentComponent parentComponent = null) + { + var i = 1; + var parentComponentState = parentComponent != null ? new ComponentState(renderer, i++, parentComponent, null) : null; + var currentRenderTree = parentComponentState?.CurrentRenderTree; + var result = new List(); + foreach (var (component, key) in components) + { + var componentState = new ComponentState(renderer, i++, component, parentComponentState); + if (currentRenderTree != null && key != null) + { + currentRenderTree.OpenComponent(0); + var frames = currentRenderTree.GetFrames(); + frames.Array[frames.Count - 1].ComponentStateField = componentState; + if (key != null) + { + currentRenderTree.SetKey(key); + } + currentRenderTree.CloseComponent(); + } + + result.Add(componentState); + } + + return result; + } + + private class TestRenderer() : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) + { + public override Dispatcher Dispatcher => Dispatcher.CreateDefault(); + + protected override void HandleException(Exception exception) => throw new NotImplementedException(); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => throw new NotImplementedException(); + } + + private class TestComponent : IComponent + { + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class TestStore(Dictionary initialState) : IPersistentComponentStateStore + { + public IDictionary State { get; set; } = initialState; + + public Task> GetPersistedStateAsync() + { + return Task.FromResult(State); + } + + public Task PersistStateAsync(IReadOnlyDictionary state) + { + // We copy the data here because it's no longer available after this call completes. + State = state.ToDictionary(k => k.Key, v => v.Value); + return Task.CompletedTask; + } + } + + private class ParentComponent : IComponent + { + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } +} From f36c7face712378261501d3f4e9d872be8f98462 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 7 Mar 2025 11:37:40 +0100 Subject: [PATCH 17/37] Add AddSupplyValueFromPersistentComponentStateProvider to wasm --- .../WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 53a30524922d..837fdd615880 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -305,6 +305,7 @@ internal void InitializeDefaultServices() Services.AddSingleton(_ => _rootComponentCache ?? new()); Services.AddSingleton(); Services.AddSingleton(sp => sp.GetRequiredService().State); + Services.AddSupplyValueFromPersistentComponentStateProvider(); Services.AddSingleton(); Services.AddSingleton(); Services.AddLogging(builder => From 16fe34ff50f830cd0ec53f30cff9fa257fb069b6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 7 Mar 2025 13:27:06 +0100 Subject: [PATCH 18/37] Cleanup and fix unit tests in release --- ...omPersistentComponentStateValueProvider.cs | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index d772b0656de0..c8d722b7a291 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -112,63 +112,51 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta try { Span keyBuffer = stackalloc byte[1024]; + var currentBuffer = keyBuffer; preKey.CopyTo(keyBuffer); + currentBuffer = currentBuffer[preKey.Length..]; if (key is IUtf8SpanFormattable spanFormattable) { var wroteKey = false; while (!wroteKey) { - wroteKey = spanFormattable.TryFormat(keyBuffer[preKey.Length..], out var written, "", CultureInfo.InvariantCulture); + wroteKey = spanFormattable.TryFormat(currentBuffer, out var written, "", CultureInfo.InvariantCulture); if (!wroteKey) { // It is really unlikely that we will enter here, but we need to handle this case Debug.Assert(written == 0); - GrowBuffer(preKey, ref pool, ref keyBuffer); + GrowBuffer(ref pool, ref keyBuffer); } else { - keyBuffer = keyBuffer[..(preKey.Length + written)]; + currentBuffer = currentBuffer[..written]; } } } - else if (key is IFormattable formattable) + else { - var keyString = formattable.ToString("", CultureInfo.InvariantCulture); + var keySpan = ResolveKeySpan(key); var wroteKey = false; while (!wroteKey) { - wroteKey = Encoding.UTF8.TryGetBytes(keyString, keyBuffer[preKey.Length..], out var written); - if (!wroteKey) - { - Debug.Assert(written == 0); - GrowBuffer(preKey, ref pool, ref keyBuffer); - } - else - { - keyBuffer = keyBuffer[..(preKey.Length + written)]; - } - } - } - else if (key is IConvertible convertible) - { - var keyString = convertible.ToString(CultureInfo.InvariantCulture); - var wroteKey = false; - while (!wroteKey) - { - wroteKey = Encoding.UTF8.TryGetBytes(keyString, keyBuffer[preKey.Length..], out var written); + wroteKey = Encoding.UTF8.TryGetBytes(keySpan, currentBuffer, out var written); if (!wroteKey) { + // It is really unlikely that we will enter here, but we need to handle this case Debug.Assert(written == 0); - GrowBuffer(preKey, ref pool, ref keyBuffer); + GrowBuffer(ref pool, ref keyBuffer); } else { - keyBuffer = keyBuffer[..(preKey.Length + written)]; + currentBuffer = currentBuffer[..written]; } } } - Debug.Assert(SHA256.TryHashData(keyBuffer, keyHash, out _)); + keyBuffer = keyBuffer[..(keyBuffer.Length - currentBuffer.Length)]; + + var hashSucceeded = SHA256.TryHashData(keyBuffer, keyHash, out _); + Debug.Assert(hashSucceeded); return Convert.ToBase64String(keyHash); } finally @@ -180,10 +168,25 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta } } - private static void GrowBuffer(byte[] preKey, ref byte[]? pool, ref Span keyBuffer) + private static ReadOnlySpan ResolveKeySpan(object? key) + { + if (key is IFormattable formattable) + { + var keyString = formattable.ToString("", CultureInfo.InvariantCulture); + return keyString.AsSpan(); + } + else if (key is IConvertible convertible) + { + var keyString = convertible.ToString(CultureInfo.InvariantCulture); + return keyString.AsSpan(); + } + return default; + } + + private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer) { var newPool = pool == null ? ArrayPool.Shared.Rent(2048) : ArrayPool.Shared.Rent(pool.Length * 2); - keyBuffer[0..preKey.Length].CopyTo(newPool); + keyBuffer.CopyTo(newPool); keyBuffer = newPool; if (pool != null) { From 870522133900de1d8d5312b8cc5343c4050d3108 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 7 Mar 2025 18:03:31 +0100 Subject: [PATCH 19/37] Fix trimming --- .../Components/src/PersistentState/PersistentServicesRegistry.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index 86d375a66a3e..09fbb164a634 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -92,6 +92,7 @@ private static void PersistInstanceState(object instance, Type type, PersistentC } [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [DynamicDependency(LinkerFlags.JsonSerialized, typeof(PersistentComponentRegistration))] internal void Restore(PersistentComponentState state) { if (state.TryTakeFromJson(_registryKey, out var registry) && registry != null) From 5ed728020f6e2b2bb40de15853821bfb1e6a3a4d Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 7 Mar 2025 20:06:40 +0100 Subject: [PATCH 20/37] Add E2E tests --- .../ServerRenderingTests/InteractivityTest.cs | 93 +++++++++++++++++++ .../Pages/PersistMultipleServerState.razor | 45 +++++++++ .../Pages/PersistStateComponents.razor | 33 +++++-- .../DeclarativePersistStateComponent.razor | 25 +++++ 4 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor create mode 100644 src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index ec3fdc9fae3b..f3bd78c5bc1a 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1053,6 +1053,15 @@ public void CanPersistPrerenderedState_Server() Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text); } + [Fact] + public void CanPersistPrerenderedStateDeclaratively_Server() + { + Navigate($"{ServerPathBase}/persist-state?server=true&declarative=true"); + + Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text); + } + [Fact] public void CanPersistPrerenderedState_WebAssembly() { @@ -1062,6 +1071,15 @@ public void CanPersistPrerenderedState_WebAssembly() Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm")).Text); } + [Fact] + public void CanPersistPrerenderedStateDeclaratively_WebAssembly() + { + Navigate($"{ServerPathBase}/persist-state?wasm=true&declarative=true"); + + Browser.Equal("restored", () => Browser.FindElement(By.Id("wasm")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm")).Text); + } + [Fact] public void CanPersistPrerenderedState_Auto_PersistsOnWebAssembly() { @@ -1071,6 +1089,16 @@ public void CanPersistPrerenderedState_Auto_PersistsOnWebAssembly() Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } + + [Fact] + public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnWebAssembly() + { + Navigate($"{ServerPathBase}/persist-state?auto=true&declarative=true"); + + Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto")).Text); + } + [Fact] public void CanPersistPrerenderedState_Auto_PersistsOnServer() { @@ -1084,6 +1112,19 @@ public void CanPersistPrerenderedState_Auto_PersistsOnServer() Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } + [Fact] + public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnServer() + { + Navigate(ServerPathBase); + Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); + BlockWebAssemblyResourceLoad(); + + Navigate($"{ServerPathBase}/persist-state?auto=true&declarative=true"); + + Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto")).Text); + } + [Fact] public void CanPersistState_AllRenderModesAtTheSameTime() { @@ -1272,4 +1313,56 @@ private void ClearBrowserLogs() { ((IJavaScriptExecutor)Browser).ExecuteScript("console.clear()"); } + + [Fact] + public void CanPersistMultiplePrerenderedStateDeclaratively_Server() + { + Navigate($"{ServerPathBase}/persist-multiple-state-declaratively?server=true"); + + Browser.Equal("restored 1", () => Browser.FindElement(By.Id("server-1")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server-1")).Text); + + Browser.Equal("restored 2", () => Browser.FindElement(By.Id("server-2")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server-2")).Text); + } + + [Fact] + public void CanPersistMultiplePrerenderedStateDeclaratively_WebAssembly() + { + Navigate($"{ServerPathBase}/persist-multiple-state-declaratively?wasm=true"); + + Browser.Equal("restored 1", () => Browser.FindElement(By.Id("wasm-1")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm-1")).Text); + + Browser.Equal("restored 2", () => Browser.FindElement(By.Id("wasm-2")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm-2")).Text); + } + + [Fact] + public void CanPersistMultiplePrerenderedStateDeclaratively_Auto_PersistsOnServer() + { + Navigate(ServerPathBase); + Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); + BlockWebAssemblyResourceLoad(); + + Navigate($"{ServerPathBase}/persist-multiple-state-declaratively?auto=true"); + + Browser.Equal("restored 1", () => Browser.FindElement(By.Id("auto-1")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto-1")).Text); + + Browser.Equal("restored 2", () => Browser.FindElement(By.Id("auto-2")).Text); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto-2")).Text); + } + + [Fact] + public void CanPersistMultiplePrerenderedStateDeclaratively_Auto_PersistsOnWebAssembly() + { + Navigate($"{ServerPathBase}/persist-multiple-state-declaratively?auto=true"); + + Browser.Equal("restored 1", () => Browser.FindElement(By.Id("auto-1")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto-1")).Text); + + Browser.Equal("restored 2", () => Browser.FindElement(By.Id("auto-2")).Text); + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto-2")).Text); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor new file mode 100644 index 000000000000..9c982d14cae3 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor @@ -0,0 +1,45 @@ +@page "/persist-multiple-state-declaratively" +@using Microsoft.AspNetCore.Components.Web + +

Persist multiple State Components declaratively

+ +@if (Server.GetValueOrDefault()) +{ + Server Persist State Component 1 + +
+ Server Persist State Component 2 + +
+} + +@if (WebAssembly.GetValueOrDefault()) +{ + WebAssembly Persist State Component 1 + +
+ WebAssembly Persist State Component 2 + +
+} + +@if (Auto.GetValueOrDefault()) +{ + Auto Persist State Component 1 + +
+ Auto Persist State Component 2 + +
+} + +@code { + [Parameter, SupplyParameterFromQuery(Name = "server")] + public bool? Server { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "wasm")] + public bool? WebAssembly { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "auto")] + public bool? Auto { get; set; } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistStateComponents.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistStateComponents.razor index 990fdce136ec..806ea1e75409 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistStateComponents.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistStateComponents.razor @@ -5,20 +5,38 @@ @if (Server.GetValueOrDefault()) { Server Persist State Component - -
+ @if (!Declarative) + { + +
+ } else { + +
+ } } @if (WebAssembly.GetValueOrDefault()) { WebAssembly Persist State Component - -
+ @if (!Declarative) + { + +
+ } else { + +
+ } } @if (Auto.GetValueOrDefault()) { Auto Persist State Component - -
+ @if (!Declarative) + { + +
+ } else { + +
+ } } @code { @@ -30,4 +48,7 @@ [Parameter, SupplyParameterFromQuery(Name = "auto")] public bool? Auto { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "declarative")] + public bool Declarative { get; set; } } diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor new file mode 100644 index 000000000000..78c17def22ce --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -0,0 +1,25 @@ +

Application state is @Value

+

Render mode: @_renderMode

+ +@code { + [Parameter, EditorRequired] + public string InitialValue { get; set; } = ""; + + [Parameter, EditorRequired] + public string KeyName { get; set; } = ""; + + [SupplyParameterFromPersistentComponentState] + public string Value { get; set; } + + private string _renderMode = "SSR"; + + protected override void OnInitialized() + { + if (OperatingSystem.IsBrowser()) + { + throw new InvalidOperationException($"{Value ?? ("null")} - {InitialValue}"); + } + Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored"; + _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server"; + } +} From 6ba2eb4ff13bdb245b96f3136e2ad9d05226ce50 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 14:42:50 +0100 Subject: [PATCH 21/37] More E2E tests --- .../Pages/PersistMultipleServerState.razor | 12 ++++++------ .../DeclarativePersistStateComponent.razor | 4 ---- .../DeclarativePersistStateComponentWrapper.razor | 12 ++++++++++++ 3 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponentWrapper.razor diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor index 9c982d14cae3..53206e588a22 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistMultipleServerState.razor @@ -6,30 +6,30 @@ @if (Server.GetValueOrDefault()) { Server Persist State Component 1 - +
Server Persist State Component 2 - +
} @if (WebAssembly.GetValueOrDefault()) { WebAssembly Persist State Component 1 - +
WebAssembly Persist State Component 2 - +
} @if (Auto.GetValueOrDefault()) { Auto Persist State Component 1 - +
Auto Persist State Component 2 - +
} diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor index 78c17def22ce..bbdbcc43dccd 100644 --- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -15,10 +15,6 @@ protected override void OnInitialized() { - if (OperatingSystem.IsBrowser()) - { - throw new InvalidOperationException($"{Value ?? ("null")} - {InitialValue}"); - } Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored"; _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server"; } diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponentWrapper.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponentWrapper.razor new file mode 100644 index 000000000000..82608670ff64 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponentWrapper.razor @@ -0,0 +1,12 @@ + + +@code { + [Parameter, EditorRequired] + public string InitialValue { get; set; } = ""; + + [Parameter, EditorRequired] + public string KeyName { get; set; } = ""; + + [Parameter, EditorRequired] + public object KeyDirective { get; set; } +} From 9fc413814c337bb8c27dec63cecc895493621ec2 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 15:04:38 +0100 Subject: [PATCH 22/37] Fix build --- .../test/E2ETest/ServerRenderingTests/InteractivityTest.cs | 1 - src/submodules/googletest | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index f3bd78c5bc1a..0db26f9c4f55 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1089,7 +1089,6 @@ public void CanPersistPrerenderedState_Auto_PersistsOnWebAssembly() Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } - [Fact] public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnWebAssembly() { diff --git a/src/submodules/googletest b/src/submodules/googletest index 72189081cae8..2b6b042a7744 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit 72189081cae8b729422860b195bf2cad625b7eb4 +Subproject commit 2b6b042a77446ff322cd7522ca068d9f2a21c1d1 From 94c641262e700845d9329ecbdca58a6bbd7a7dee Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 16:04:43 +0100 Subject: [PATCH 23/37] Persitent services E2E tests --- .../ServerRenderingTests/InteractivityTest.cs | 35 +++++++++++++++++++ .../RazorComponentEndpointsStartup.cs | 11 ++++++ .../PersistServicesStateComponents.razor | 24 +++++++++++++ .../Components.WasmMinimal/Program.cs | 10 ++++++ .../PersistServicesState.razor | 32 +++++++++++++++++ .../Services/InteractiveAutoService.cs | 15 ++++++++ .../Services/InteractiveServerService.cs | 14 ++++++++ .../Services/InteractiveWebAssemblyService.cs | 15 ++++++++ 8 files changed, 156 insertions(+) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistServicesStateComponents.razor create mode 100644 src/Components/test/testassets/TestContentPackage/PersistServicesState.razor create mode 100644 src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs create mode 100644 src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs create mode 100644 src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 0db26f9c4f55..1aee9a2a03cc 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1111,6 +1111,41 @@ public void CanPersistPrerenderedState_Auto_PersistsOnServer() Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto")).Text); } + [Theory] + [InlineData("server", "Server state", "Auto state", "not restored")] + [InlineData("auto", "Server state", "Auto state", "not restored")] + public void CanPersistPrerenderedState_ServicesState_PersistsOnServer(string mode, string expectedServerState, string expectedAutoState, string expectedWebAssemblyState) + { + Navigate(ServerPathBase); + Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + + Navigate($"{ServerPathBase}/persist-services-state?mode={mode}"); + Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal(expectedServerState, () => Browser.FindElement(By.Id("server-state")).Text); + Browser.Equal(expectedAutoState, () => Browser.FindElement(By.Id("auto-state")).Text); + Browser.Equal(expectedWebAssemblyState, () => Browser.FindElement(By.Id("wasm-state")).Text); + } + + [Theory] + [InlineData("auto", "not restored", "Auto state", "WebAssembly state")] + [InlineData("wasm", "not restored", "Auto state", "WebAssembly state")] + public void CanPersistPrerenderedState_ServicesState_PersistsOnWasm(string mode, string expectedServerState, string expectedAutoState, string expectedWebAssemblyState) + { + Navigate(ServerPathBase); + Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text); + + Navigate($"{ServerPathBase}/persist-services-state?mode={mode}"); + + Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal(expectedServerState, () => Browser.FindElement(By.Id("server-state")).Text); + Browser.Equal(expectedAutoState, () => Browser.FindElement(By.Id("auto-state")).Text); + Browser.Equal(expectedWebAssemblyState, () => Browser.FindElement(By.Id("wasm-state")).Text); + } + [Fact] public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnServer() { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 943ee32d635c..ee075a6e86ef 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -8,9 +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.Services; namespace TestServer; @@ -40,6 +43,14 @@ public void ConfigureServices(IServiceCollection services) options.SerializeAllClaims = serializeAllClaims; }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddPersistentService(RenderMode.InteractiveServer); + services.AddPersistentService(RenderMode.InteractiveAuto); + services.AddPersistentService(RenderMode.InteractiveWebAssembly); + services.AddHttpContextAccessor(); services.AddSingleton(); services.AddCascadingAuthenticationState(); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistServicesStateComponents.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistServicesStateComponents.razor new file mode 100644 index 000000000000..6f16bd9e13c0 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistServicesStateComponents.razor @@ -0,0 +1,24 @@ +@page "/persist-services-state" +@using Microsoft.AspNetCore.Components.Web + +

Persist Services State Components

+ + + +@code { + private IComponentRenderMode _renderMode; + + [Parameter, SupplyParameterFromQuery(Name = "mode")] + public string Mode { get; set; } + + protected override void OnInitialized() + { + _renderMode = Mode switch + { + "server" => RenderMode.InteractiveServer, + "wasm" => RenderMode.InteractiveWebAssembly, + "auto" => RenderMode.InteractiveAuto, + _ => throw new InvalidOperationException($"Invalid mode: {Mode}") + }; + } +} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index 5074e4a0a068..e3e478472610 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -4,10 +4,20 @@ 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.Services; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); +builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); +builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); + builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthenticationStateDeserialization(options => diff --git a/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor b/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor new file mode 100644 index 000000000000..fb32e52f36a8 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor @@ -0,0 +1,32 @@ +@using TestContentPackage.Services + +@inject InteractiveServerService InteractiveServerState +@inject InteractiveWebAssemblyService InteractiveWebAssemblyState +@inject InteractiveAutoService InteractiveAutoState + +

Interactive server state is @InteractiveServerState.State

+

Interactive webassembly state is @InteractiveWebAssemblyState.State

+

Interactive auto state is @InteractiveAutoState.State

+ +

Render mode: @_renderMode

+ +@code { + + private string _renderMode = ""; + + protected override void OnInitialized() + { + _renderMode = RendererInfo.Name; + if (!RendererInfo.IsInteractive) + { + InteractiveServerState.State = "Server state"; + InteractiveWebAssemblyState.State = "WebAssembly state"; + InteractiveAutoState.State = "Auto state"; + }else + { + InteractiveServerState.State ??= "not restored"; + InteractiveWebAssemblyState.State ??= "not restored"; + InteractiveAutoState.State ??= "not restored"; + } + } +} diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs new file mode 100644 index 000000000000..467aa416504f --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.Components; + +namespace TestContentPackage.Services; + +public class InteractiveAutoService +{ + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } +} diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs new file mode 100644 index 000000000000..906eb84d5f0c --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.Components; + +namespace TestContentPackage.Services; +public class InteractiveServerService +{ + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } +} diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs new file mode 100644 index 000000000000..f73d03e2db11 --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.Components; + +namespace TestContentPackage.Services; + +public class InteractiveWebAssemblyService +{ + [SupplyParameterFromPersistentComponentState] + public string State { get; set; } +} From c64f6051a994f0cbfe50b7f96747035829bca077 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 19:52:30 +0100 Subject: [PATCH 24/37] Fix the logic around buffering for large keys --- ...eterFromPersistentComponentStateValueProvider.cs | 13 ++++++++----- ...romPersistentComponentStateValueProviderTests.cs | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index c8d722b7a291..ddac1160396a 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -114,12 +114,12 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta Span keyBuffer = stackalloc byte[1024]; var currentBuffer = keyBuffer; preKey.CopyTo(keyBuffer); - currentBuffer = currentBuffer[preKey.Length..]; if (key is IUtf8SpanFormattable spanFormattable) { var wroteKey = false; while (!wroteKey) { + currentBuffer = keyBuffer[preKey.Length..]; wroteKey = spanFormattable.TryFormat(currentBuffer, out var written, "", CultureInfo.InvariantCulture); if (!wroteKey) { @@ -139,12 +139,15 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta var wroteKey = false; while (!wroteKey) { + currentBuffer = keyBuffer[preKey.Length..]; wroteKey = Encoding.UTF8.TryGetBytes(keySpan, currentBuffer, out var written); if (!wroteKey) { // It is really unlikely that we will enter here, but we need to handle this case Debug.Assert(written == 0); - GrowBuffer(ref pool, ref keyBuffer); + // Since this is utf-8, grab a buffer the size of the key * 4 + the preKey size + // this guarantees we have enough space to encode the key + GrowBuffer(ref pool, ref keyBuffer, keySpan.Length * 4 + preKey.Length); } else { @@ -153,7 +156,7 @@ private static string ComputeFinalKey(byte[] preKey, ComponentState componentSta } } - keyBuffer = keyBuffer[..(keyBuffer.Length - currentBuffer.Length)]; + keyBuffer = keyBuffer[..(preKey.Length + currentBuffer.Length)]; var hashSucceeded = SHA256.TryHashData(keyBuffer, keyHash, out _); Debug.Assert(hashSucceeded); @@ -183,9 +186,9 @@ private static ReadOnlySpan ResolveKeySpan(object? key) return default; } - private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer) + private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer, int? size = null) { - var newPool = pool == null ? ArrayPool.Shared.Rent(2048) : ArrayPool.Shared.Rent(pool.Length * 2); + var newPool = pool == null ? ArrayPool.Shared.Rent(size ?? 2048) : ArrayPool.Shared.Rent(pool.Length * 2); keyBuffer.CopyTo(newPool); keyBuffer = newPool; if (pool != null) diff --git a/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs index c480c431be0f..3baeb13157f6 100644 --- a/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs +++ b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs @@ -229,6 +229,8 @@ public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_WhenParent { 123456.789m, -123456.789m }, { new DateTime(2023, 1, 1), new DateTime(2023, 12, 31) }, { "key1", "key2" }, + // Include a very long key to validate logic around growing buffers + { new string('a', 10000), new string('b', 10000) }, { Guid.NewGuid(), Guid.NewGuid() }, { new DateOnly(2023, 1, 1), new DateOnly(2023, 12, 31) }, { new TimeOnly(12, 34, 56), new TimeOnly(23, 45, 56) }, From ef6b70bc81551a54fd2c31fc5bc73878425b90f4 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 20:01:51 +0100 Subject: [PATCH 25/37] Cache reflection for reading component properties --- ...omPersistentComponentStateValueProvider.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index ddac1160396a..78c422cdf96b 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components; @@ -15,6 +16,8 @@ namespace Microsoft.AspNetCore.Components; internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(PersistentComponentState state) : ICascadingValueSupplier { private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new(); + private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); + private readonly Dictionary _subscriptions = []; public bool IsFixed => false; @@ -61,12 +64,31 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param _subscriptions[subscriber] = state.RegisterOnPersisting(() => { var storageKey = ComputeKey(subscriber, propertyName); - var property = subscriber.Component.GetType().GetProperty(propertyName)!.GetValue(subscriber.Component)!; + var propertyGetter = ResolvePropertyGetter(subscriber.Component.GetType(), propertyName); + var property = propertyGetter.GetValue(subscriber.Component); + if (property == null) + { + return Task.CompletedTask; + } state.PersistAsJson(storageKey, property, propertyType); return Task.CompletedTask; }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); } + private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) + { + return _propertyGetterCache.GetOrAdd((type, propertyName), (key) => + { + var (type, propertyName) = key; + var propertyInfo = type.GetProperty(propertyName); + if (propertyInfo == null) + { + throw new InvalidOperationException($"Property {propertyName} not found on type {type.FullName}"); + } + return new PropertyGetter(type, propertyInfo); + }); + } + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { if (_subscriptions.TryGetValue(subscriber, out var subscription)) From d6d276f9531debdab5fc642f9f125e46f368033f Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 20:33:01 +0100 Subject: [PATCH 26/37] Fix linker flags --- ...SupplyParameterFromPersistentComponentStateValueProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 78c422cdf96b..611b0b693190 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -10,6 +10,7 @@ using System.Text; using Microsoft.AspNetCore.Components.Reflection; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Internal; namespace Microsoft.AspNetCore.Components; @@ -75,7 +76,7 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); } - private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) + private static PropertyGetter ResolvePropertyGetter([DynamicallyAccessedMembers(LinkerFlags.Component)] Type type, string propertyName) { return _propertyGetterCache.GetOrAdd((type, propertyName), (key) => { From 2e91b14abefb535a6798ad48b5aea9a8d5750dd3 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 20:42:34 +0100 Subject: [PATCH 27/37] Make the linker happy --- ...omPersistentComponentStateValueProvider.cs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 611b0b693190..993cfa20a935 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Reflection; using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Components.Reflection; @@ -76,18 +77,23 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); } - private static PropertyGetter ResolvePropertyGetter([DynamicallyAccessedMembers(LinkerFlags.Component)] Type type, string propertyName) + private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) { - return _propertyGetterCache.GetOrAdd((type, propertyName), (key) => + return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory); + } + + private static PropertyGetter PropertyGetterFactory((Type type, string propertyName) key) + { + var (type, propertyName) = key; + var propertyInfo = GetPropertyInfo(type, propertyName); + if (propertyInfo == null) { - var (type, propertyName) = key; - var propertyInfo = type.GetProperty(propertyName); - if (propertyInfo == null) - { - throw new InvalidOperationException($"Property {propertyName} not found on type {type.FullName}"); - } - return new PropertyGetter(type, propertyInfo); - }); + throw new InvalidOperationException($"Property {propertyName} not found on type {type.FullName}"); + } + return new PropertyGetter(type, propertyInfo); + + static PropertyInfo? GetPropertyInfo([DynamicallyAccessedMembers(LinkerFlags.Component)] Type type, string propertyName) + => type.GetProperty(propertyName); } public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) From 1784ad79bb8cafbdf12cd31fa48cd6a9e44178d7 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 10 Mar 2025 20:52:35 +0100 Subject: [PATCH 28/37] Make the linker happy? --- ...pplyParameterFromPersistentComponentStateValueProvider.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 993cfa20a935..f69fff8cde64 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -82,6 +82,11 @@ private static PropertyGetter ResolvePropertyGetter(Type type, string propertyNa return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory); } + [UnconditionalSuppressMessage( + "Trimming", + "IL2077:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The source field does not have matching annotations.", + Justification = "Properties of rendered components are preserved through other means and won't get trimmed.")] + private static PropertyGetter PropertyGetterFactory((Type type, string propertyName) key) { var (type, propertyName) = key; From 1444089a041015dcd1386f40ea1ee8aaeb92d945 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 11 Mar 2025 12:06:41 +0100 Subject: [PATCH 29/37] Undo submodule changes --- src/submodules/googletest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/submodules/googletest b/src/submodules/googletest index 2b6b042a7744..72189081cae8 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit 2b6b042a77446ff322cd7522ca068d9f2a21c1d1 +Subproject commit 72189081cae8b729422860b195bf2cad625b7eb4 From 23e00e35ffbef5c3c8e3279e9055b7d82b8e0983 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 11 Mar 2025 12:19:20 +0100 Subject: [PATCH 30/37] Hash the prekey to avoid keeping a reference to a larger set ofbytes --- .../SupplyParameterFromPersistentComponentStateValueProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index f69fff8cde64..daa15941082d 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -264,7 +264,7 @@ private static string GetParentComponentType(ComponentState componentState) => componentState.LogicalParentComponentState == null ? "" : GetComponentType(componentState.LogicalParentComponentState); private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) => - Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName)); + SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName))); private static bool IsSerializableKey(object key) { From 854370a241598be56ea8b99cd712e6a53d8ec83d Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 11 Mar 2025 12:27:46 +0100 Subject: [PATCH 31/37] Update trimming Justificaitons --- .../src/PersistentState/PersistentServicesRegistry.cs | 11 ++++++++--- ...ameterFromPersistentComponentStateValueProvider.cs | 4 +--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index 09fbb164a634..b3c6a86c6463 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -36,7 +36,10 @@ public PersistentServicesRegistry(IServiceProvider serviceProvider) internal IReadOnlyList Registrations => _registrations; - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", + Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")] internal void RegisterForPersistence(PersistentComponentState state) { if (_subscriptions.Count != 0) @@ -91,7 +94,9 @@ private static void PersistInstanceState(object instance, Type type, PersistentC } } - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [UnconditionalSuppressMessage("Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", + Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")] [DynamicDependency(LinkerFlags.JsonSerialized, typeof(PersistentComponentRegistration))] internal void Restore(PersistentComponentState state) { @@ -103,7 +108,7 @@ internal void Restore(PersistentComponentState state) RestoreRegistrationsIfAvailable(state); } - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")] private void RestoreRegistrationsIfAvailable(PersistentComponentState state) { foreach (var registration in _registrations) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index daa15941082d..d1d93bc07745 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -56,9 +56,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } - [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] + [UnconditionalSuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "OpenComponent already has the right set of attributes")] [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "OpenComponent already has the right set of attributes")] public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { var propertyName = parameterInfo.PropertyName; From afabecd4888fe8d065bac80a69c91241955e9760 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 12 Mar 2025 19:43:56 +0100 Subject: [PATCH 32/37] remove default method implementation --- src/Components/Components/src/CascadingValue.cs | 2 +- src/Components/Components/src/CascadingValueSource.cs | 2 +- .../Components/src/ICascadingValueSupplier.cs | 4 +--- .../Routing/SupplyParameterFromQueryValueProvider.cs | 2 +- ...ameterFromPersistentComponentStateValueProvider.cs | 11 ----------- .../Components/test/CascadingParameterStateTest.cs | 4 ++-- .../Components/test/CascadingParameterTest.cs | 4 ++-- .../Components/test/ParameterViewTest.Assignment.cs | 2 +- src/Components/Components/test/ParameterViewTest.cs | 2 +- src/Components/Web/src/Forms/Editor.cs | 2 +- .../Web/src/Forms/Mapping/FormMappingScope.cs | 4 ++-- .../Mapping/SupplyParameterFromFormValueProvider.cs | 2 +- .../HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs | 2 +- 13 files changed, 15 insertions(+), 28 deletions(-) diff --git a/src/Components/Components/src/CascadingValue.cs b/src/Components/Components/src/CascadingValue.cs index a040894ac57c..d89320a53566 100644 --- a/src/Components/Components/src/CascadingValue.cs +++ b/src/Components/Components/src/CascadingValue.cs @@ -140,7 +140,7 @@ bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterI || string.Equals(requestedName, Name, StringComparison.OrdinalIgnoreCase); // Also match on name } - object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { return Value; } diff --git a/src/Components/Components/src/CascadingValueSource.cs b/src/Components/Components/src/CascadingValueSource.cs index 3645b17bed39..eb17138786c6 100644 --- a/src/Components/Components/src/CascadingValueSource.cs +++ b/src/Components/Components/src/CascadingValueSource.cs @@ -149,7 +149,7 @@ bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterI || string.Equals(requestedName, _name, StringComparison.OrdinalIgnoreCase); // Also match on name } - object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { if (_initialValueFactory is not null) { diff --git a/src/Components/Components/src/ICascadingValueSupplier.cs b/src/Components/Components/src/ICascadingValueSupplier.cs index dd1765bfc624..c61362537062 100644 --- a/src/Components/Components/src/ICascadingValueSupplier.cs +++ b/src/Components/Components/src/ICascadingValueSupplier.cs @@ -11,9 +11,7 @@ internal interface ICascadingValueSupplier bool CanSupplyValue(in CascadingParameterInfo parameterInfo); - object? GetCurrentValue(in CascadingParameterInfo parameterInfo); - - object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) => GetCurrentValue(parameterInfo); + object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo); void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); diff --git a/src/Components/Components/src/Routing/SupplyParameterFromQueryValueProvider.cs b/src/Components/Components/src/Routing/SupplyParameterFromQueryValueProvider.cs index 12422f013246..9e1968a48c3b 100644 --- a/src/Components/Components/src/Routing/SupplyParameterFromQueryValueProvider.cs +++ b/src/Components/Components/src/Routing/SupplyParameterFromQueryValueProvider.cs @@ -23,7 +23,7 @@ internal sealed class SupplyParameterFromQueryValueProvider(NavigationManager na public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterFromQueryAttribute; - public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { TryUpdateUri(); diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index d1d93bc07745..c2a3183e0906 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -29,17 +29,6 @@ internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(P public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterFromPersistentComponentStateAttribute; - [UnconditionalSuppressMessage( - "ReflectionAnalysis", - "IL2026:RequiresUnreferencedCode message", - Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] - [UnconditionalSuppressMessage( - "Trimming", - "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", - Justification = "JSON serialization and deserialization might require types that cannot be statically analyzed.")] - public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) => - throw new InvalidOperationException("Using this provider requires a key."); - [UnconditionalSuppressMessage( "ReflectionAnalysis", "IL2026:RequiresUnreferencedCode message", diff --git a/src/Components/Components/test/CascadingParameterStateTest.cs b/src/Components/Components/test/CascadingParameterStateTest.cs index e055b9c70801..42e361bf7ae0 100644 --- a/src/Components/Components/test/CascadingParameterStateTest.cs +++ b/src/Components/Components/test/CascadingParameterStateTest.cs @@ -352,7 +352,7 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() { Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); - Assert.Null(match.ValueSupplier.GetCurrentValue(match.ParameterInfo)); + Assert.Null(match.ValueSupplier.GetCurrentValue(null, match.ParameterInfo)); }); } @@ -486,7 +486,7 @@ class SupplyParameterWithSingleDeliveryComponent(bool isFixed) : ComponentBase, public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SupplyParameterWithSingleDeliveryAttribute; - public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object GetCurrentValue(object key, in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) diff --git a/src/Components/Components/test/CascadingParameterTest.cs b/src/Components/Components/test/CascadingParameterTest.cs index 411ed14a5e07..a083b43091d6 100644 --- a/src/Components/Components/test/CascadingParameterTest.cs +++ b/src/Components/Components/test/CascadingParameterTest.cs @@ -885,7 +885,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.Attribute is SingleDeliveryCascadingParameterAttribute; - public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object GetCurrentValue(object key, in CascadingParameterInfo parameterInfo) => new SingleDeliveryValue(Text); public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) @@ -1053,7 +1053,7 @@ bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterI return true; } - object ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo cascadingParameterState) + object ICascadingValueSupplier.GetCurrentValue(object key, in CascadingParameterInfo cascadingParameterState) { return Value; } diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index 01111a9d45a3..827f0e4694ae 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -790,7 +790,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) throw new NotImplementedException(); } - public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object GetCurrentValue(object key, in CascadingParameterInfo parameterInfo) { return _value; } diff --git a/src/Components/Components/test/ParameterViewTest.cs b/src/Components/Components/test/ParameterViewTest.cs index e4a358d491cc..0d5a2a46a3b6 100644 --- a/src/Components/Components/test/ParameterViewTest.cs +++ b/src/Components/Components/test/ParameterViewTest.cs @@ -609,7 +609,7 @@ public TestCascadingValue(object value) public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); - public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object GetCurrentValue(object key, in CascadingParameterInfo parameterInfo) => _value; public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) diff --git a/src/Components/Web/src/Forms/Editor.cs b/src/Components/Web/src/Forms/Editor.cs index d73c1641a1c0..f52aa4668354 100644 --- a/src/Components/Web/src/Forms/Editor.cs +++ b/src/Components/Web/src/Forms/Editor.cs @@ -56,7 +56,7 @@ protected override void OnParametersSet() bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) => parameterInfo.PropertyType == typeof(HtmlFieldPrefix); - object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { return ((ICascadingValueSupplier)this).CanSupplyValue(parameterInfo) ? _value : null; } diff --git a/src/Components/Web/src/Forms/Mapping/FormMappingScope.cs b/src/Components/Web/src/Forms/Mapping/FormMappingScope.cs index 53b31b3c2ade..bed3086eaa1a 100644 --- a/src/Components/Web/src/Forms/Mapping/FormMappingScope.cs +++ b/src/Components/Web/src/Forms/Mapping/FormMappingScope.cs @@ -83,8 +83,8 @@ bool ICascadingValueSupplier.IsFixed bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) => _cascadingValueSupplier!.CanSupplyValue(parameterInfo); - object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) - => _cascadingValueSupplier!.GetCurrentValue(in parameterInfo); + object? ICascadingValueSupplier.GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) + => _cascadingValueSupplier!.GetCurrentValue(key, in parameterInfo); void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) => throw new NotSupportedException(); diff --git a/src/Components/Web/src/Forms/Mapping/SupplyParameterFromFormValueProvider.cs b/src/Components/Web/src/Forms/Mapping/SupplyParameterFromFormValueProvider.cs index 7346db4ba896..803341187816 100644 --- a/src/Components/Web/src/Forms/Mapping/SupplyParameterFromFormValueProvider.cs +++ b/src/Components/Web/src/Forms/Mapping/SupplyParameterFromFormValueProvider.cs @@ -46,7 +46,7 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) return false; } - public object? GetCurrentValue(in CascadingParameterInfo parameterInfo) + public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { // We supply a FormMappingContext if (parameterInfo.Attribute is CascadingParameterAttribute && parameterInfo.PropertyType == typeof(FormMappingContext)) diff --git a/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs b/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs index 9cbc12e2aecd..c48b1d798b15 100644 --- a/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs +++ b/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs @@ -252,7 +252,7 @@ protected bool TryCreateScopeQualifiedEventName(int componentId, string assigned componentState.Renderer, componentState); - return (FormMappingContext?)supplier?.GetCurrentValue(_findFormMappingContext); + return (FormMappingContext?)supplier?.GetCurrentValue(null, _findFormMappingContext); } private static bool TryFindEnclosingElementFrame(ArrayRange frames, int frameIndex, out int result) From 2ef98cd9a191d347e9a68e4090eb22392f86c022 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 12 Mar 2025 20:05:11 +0100 Subject: [PATCH 33/37] Unify RootComponentTypeCache and PersistentServiceTypeCache --- .../Microsoft.AspNetCore.Components.csproj | 1 + ...n.cs => IPersistentServiceRegistration.cs} | 2 +- ...on.cs => PersistentServiceRegistration.cs} | 2 +- .../PersistentServiceTypeCache.cs | 82 ------------------- .../PersistentServicesRegistry.cs | 20 ++--- .../Components/src/RenderTree/Renderer.cs | 8 +- ...tateProviderServiceCollectionExtensions.cs | 2 +- .../ComponentStatePersistenceManagerTest.cs | 2 +- .../PersistentServicesRegistryTest.cs | 10 +-- .../Circuits/ServerComponentDeserializer.cs | 10 +-- .../ComponentServiceCollectionExtensions.cs | 2 +- ...rosoft.AspNetCore.Components.Server.csproj | 2 +- .../Server/test/Circuits/CircuitHostTest.cs | 2 +- .../test/Circuits/RemoteRendererTest.cs | 2 +- .../ServerComponentDeserializerTest.cs | 2 +- ...ComponentTypeCache.cs => RootTypeCache.cs} | 8 +- .../src/JSComponents/JSComponentInterop.cs | 2 +- .../src/Hosting/WebAssemblyHostBuilder.cs | 6 +- ...t.AspNetCore.Components.WebAssembly.csproj | 4 +- .../Services/DefaultWebAssemblyJSRuntime.cs | 4 +- 20 files changed, 48 insertions(+), 125 deletions(-) rename src/Components/Components/src/PersistentState/{IPersistentComponentRegistration.cs => IPersistentServiceRegistration.cs} (88%) rename src/Components/Components/src/PersistentState/{PersistentComponentRegistration.cs => PersistentServiceRegistration.cs} (80%) delete mode 100644 src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs rename src/Components/Shared/src/{RootComponentTypeCache.cs => RootTypeCache.cs} (93%) diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 9ebdbfc3778f..0f6a86511ac0 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/IPersistentServiceRegistration.cs similarity index 88% rename from src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs rename to src/Components/Components/src/PersistentState/IPersistentServiceRegistration.cs index 75db4bd20884..28aa98fb829b 100644 --- a/src/Components/Components/src/PersistentState/IPersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/IPersistentServiceRegistration.cs @@ -4,7 +4,7 @@ namespace Microsoft.AspNetCore.Components; // Represents a component that is registered for state persistence. -internal interface IPersistentComponentRegistration +internal interface IPersistentServiceRegistration { public string Assembly { get; } public string FullTypeName { get; } diff --git a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs b/src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs similarity index 80% rename from src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs rename to src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs index b381d8fe8119..0ece0d2a1d26 100644 --- a/src/Components/Components/src/PersistentState/PersistentComponentRegistration.cs +++ b/src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -internal sealed class PersistentComponentRegistration(IComponentRenderMode componentRenderMode) : IPersistentComponentRegistration +internal sealed class PersistentServiceRegistration(IComponentRenderMode componentRenderMode) : IPersistentServiceRegistration { public string Assembly => typeof(TService).Assembly.GetName().Name!; public string FullTypeName => typeof(TService).FullName!; diff --git a/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs b/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs deleted file mode 100644 index 434fa6a5adc4..000000000000 --- a/src/Components/Components/src/PersistentState/PersistentServiceTypeCache.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace Microsoft.AspNetCore.Components; - -// A cache for registered persistent services. This is similar to the RootComponentTypeCache. -internal sealed class PersistentServiceTypeCache -{ - private readonly ConcurrentDictionary _typeToKeyLookUp = new(); - - public Type? GetPersistentService(string assembly, string type) - { - var key = new Key(assembly, type); - if (_typeToKeyLookUp.TryGetValue(key, out var resolvedType)) - { - return resolvedType; - } - else - { - return _typeToKeyLookUp.GetOrAdd(key, ResolveType, AppDomain.CurrentDomain.GetAssemblies()); - } - } - - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Types in this cache are added by calling `AddPersistentService` and are expected to be preserved.")] - private static Type? ResolveType(Key key, Assembly[] assemblies) - { - Assembly? assembly = null; - for (var i = 0; i < assemblies.Length; i++) - { - var current = assemblies[i]; - if (current.GetName().Name == key.Assembly) - { - assembly = current; - break; - } - } - - if (assembly == null) - { - // It might be that the assembly is not loaded yet, this can happen if the root component is defined in a - // different assembly than the app and there is no reference from the app assembly to any type in the class - // library that has been used yet. - // In this case, try and load the assembly and look up the type again. - // We only need to do this in the browser because its a different process, in the server the assembly will already - // be loaded. - if (OperatingSystem.IsBrowser()) - { - try - { - assembly = Assembly.Load(key.Assembly); - } - catch - { - // It's fine to ignore the exception, since we'll return null below. - } - } - } - - return assembly?.GetType(key.Type, throwOnError: false, ignoreCase: false); - } - - private readonly struct Key : IEquatable - { - public Key(string assembly, string type) => - (Assembly, Type) = (assembly, type); - - public string Assembly { get; } - - public string Type { get; } - - public override bool Equals(object? obj) => obj is Key key && Equals(key); - - public bool Equals(Key other) => string.Equals(Assembly, other.Assembly, StringComparison.Ordinal) && - string.Equals(Type, other.Type, StringComparison.Ordinal); - - public override int GetHashCode() => HashCode.Combine(Assembly, Type); - } -} diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index b3c6a86c6463..cada8a256678 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -17,24 +17,23 @@ namespace Microsoft.AspNetCore.Components.Infrastructure; internal sealed class PersistentServicesRegistry { private static readonly string _registryKey = typeof(PersistentServicesRegistry).FullName!; + private static readonly RootTypeCache _persistentServiceTypeCache = new RootTypeCache(); private readonly IServiceProvider _serviceProvider; - private readonly PersistentServiceTypeCache _persistentServiceTypeCache; - private IPersistentComponentRegistration[] _registrations; + private IPersistentServiceRegistration[] _registrations; private List _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); public PersistentServicesRegistry(IServiceProvider serviceProvider) { - var registrations = serviceProvider.GetRequiredService>(); + var registrations = serviceProvider.GetRequiredService>(); _serviceProvider = serviceProvider; - _persistentServiceTypeCache = new PersistentServiceTypeCache(); _registrations = ResolveRegistrations(registrations); } internal IComponentRenderMode? RenderMode { get; set; } - internal IReadOnlyList Registrations => _registrations; + internal IReadOnlyList Registrations => _registrations; [UnconditionalSuppressMessage( "Trimming", @@ -97,10 +96,10 @@ private static void PersistInstanceState(object instance, Type type, PersistentC [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Types registered for persistence are preserved in the API call to register them and typically live in assemblies that aren't trimmed.")] - [DynamicDependency(LinkerFlags.JsonSerialized, typeof(PersistentComponentRegistration))] + [DynamicDependency(LinkerFlags.JsonSerialized, typeof(PersistentServiceRegistration))] internal void Restore(PersistentComponentState state) { - if (state.TryTakeFromJson(_registryKey, out var registry) && registry != null) + if (state.TryTakeFromJson(_registryKey, out var registry) && registry != null) { _registrations = ResolveRegistrations(_registrations.Concat(registry)); } @@ -141,9 +140,10 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC } } - private static IPersistentComponentRegistration[] ResolveRegistrations(IEnumerable registrations) => [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; + private static IPersistentServiceRegistration[] ResolveRegistrations(IEnumerable registrations) => [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; - private Type? ResolveType(string assembly, string fullTypeName) => _persistentServiceTypeCache.GetPersistentService(assembly, fullTypeName); + private static Type? ResolveType(string assembly, string fullTypeName) => + _persistentServiceTypeCache.GetRootType(assembly, fullTypeName); private sealed class PropertiesAccessor { @@ -217,7 +217,7 @@ internal static IEnumerable GetCandidateBindableProperties( } [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] - private class PersistentComponentRegistration : IPersistentComponentRegistration + private class PersistentServiceRegistration : IPersistentServiceRegistration { public string Assembly { get; set; } = ""; diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 543dcd48c047..e6c3c240c538 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -316,7 +316,7 @@ protected internal void RemoveRootComponent(int componentId) ///
/// The root component ID. /// The type of the component. - internal Type GetRootComponentType(int componentId) + internal Type GetRootTypeType(int componentId) => GetRequiredRootComponentState(componentId).Component.GetType(); /// @@ -1130,9 +1130,9 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er /// if this method is being invoked by , otherwise . protected virtual void Dispose(bool disposing) { - // Unlike other Renderer APIs, we need Dispose to be thread-safe - // (and not require being called only from the sync context) - // because other classes many need to dispose a Renderer during their own Dispose (rather than DisposeAsync) + // Unlike other Renderer APIs, we need Dispose to be thread-safe + // (and not require being called only from the sync context) + // because other classes many need to dispose a Renderer during their own Dispose (rather than DisposeAsync) // and we don't want to force that other code to deal with calling InvokeAsync from a synchronous method. lock (_lockObject) { diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index 70adf5adc968..f7811c7842c0 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -46,7 +46,7 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi // We look for the type inside the assembly. // We resolve the service from the DI container. // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. - services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentComponentRegistration(componentRenderMode))); + services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentServiceRegistration(componentRenderMode))); return services; } diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 730ba3c95ce8..d35b70720c57 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -422,7 +422,7 @@ private class TestRenderMode : IComponentRenderMode { } - private class PersistentService : IPersistentComponentRegistration + private class PersistentService : IPersistentServiceRegistration { public string Assembly { get; set; } diff --git a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs index f12c8c5b9b9b..f28c313276da 100644 --- a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -230,7 +230,7 @@ public async Task PersistStateAsync_DoesNotThrow_WhenTypeCantBeFoundForPersisted // Arrange var componentRenderMode = new TestRenderMode(); var serviceProviderOne = new ServiceCollection() - .AddSingleton(new TestPersistentRegistration { Assembly = "FakeAssembly", FullTypeName = "FakeType" }) + .AddSingleton(new TestPersistentRegistration { Assembly = "FakeAssembly", FullTypeName = "FakeType" }) .BuildServiceProvider(); var serviceProviderTwo = new ServiceCollection() @@ -260,9 +260,9 @@ public void ResolveRegistrations_RemovesDuplicateRegistrations() { // Arrange var serviceProvider = new ServiceCollection() - .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) - .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) // Duplicate - .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly2", FullTypeName = "Type2" }) + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly1", FullTypeName = "Type1" }) // Duplicate + .AddSingleton(new TestPersistentRegistration { Assembly = "Assembly2", FullTypeName = "Type2" }) .BuildServiceProvider(); var registry = new PersistentServicesRegistry(serviceProvider); @@ -490,7 +490,7 @@ private class DerivedService : BaseServiceWithProperty { } - private class TestPersistentRegistration : IPersistentComponentRegistration + private class TestPersistentRegistration : IPersistentServiceRegistration { public string Assembly { get; set; } public string FullTypeName { get; set; } diff --git a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs index 44053eee9ae9..ca292c529da8 100644 --- a/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/ServerComponentDeserializer.cs @@ -59,7 +59,7 @@ internal sealed partial class ServerComponentDeserializer : IServerComponentDese { private readonly IDataProtector _dataProtector; private readonly ILogger _logger; - private readonly RootComponentTypeCache _rootComponentTypeCache; + private readonly RootTypeCache _RootTypeCache; private readonly ComponentParameterDeserializer _parametersDeserializer; // The following fields are only used in TryDeserializeSingleComponentDescriptor. @@ -72,7 +72,7 @@ internal sealed partial class ServerComponentDeserializer : IServerComponentDese public ServerComponentDeserializer( IDataProtectionProvider dataProtectionProvider, ILogger logger, - RootComponentTypeCache rootComponentTypeCache, + RootTypeCache RootTypeCache, ComponentParameterDeserializer parametersDeserializer) { // When we protect the data we use a time-limited data protector with the @@ -87,7 +87,7 @@ public ServerComponentDeserializer( .ToTimeLimitedDataProtector(); _logger = logger; - _rootComponentTypeCache = rootComponentTypeCache; + _RootTypeCache = RootTypeCache; _parametersDeserializer = parametersDeserializer; } @@ -206,8 +206,8 @@ public bool TryDeserializeWebRootComponentDescriptor(ComponentMarker record, [No private bool TryDeserializeComponentTypeAndParameters(ServerComponent serverComponent, [NotNullWhen(true)] out Type? componentType, out ParameterView parameters) { parameters = default; - componentType = _rootComponentTypeCache - .GetRootComponent(serverComponent.AssemblyName, serverComponent.TypeName); + componentType = _RootTypeCache + .GetRootType(serverComponent.AssemblyName, serverComponent.TypeName); if (componentType == null) { diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index 66b77af81143..1718c955e7e6 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -62,7 +62,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti // Components entrypoints, this lot is the same and repeated registrations are a no-op. services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 92bd9830630f..2978116fc5c7 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -48,7 +48,7 @@ - + diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index b1d2e5fe0a4d..47d7e7cf5a76 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -636,7 +636,7 @@ private ProtectedPrerenderComponentApplicationStore CreateStore() private ServerComponentDeserializer CreateDeserializer() { - return new ServerComponentDeserializer(_ephemeralDataProtectionProvider, NullLogger.Instance, new RootComponentTypeCache(), new ComponentParameterDeserializer(NullLogger.Instance, new ComponentParametersTypeCache())); + return new ServerComponentDeserializer(_ephemeralDataProtectionProvider, NullLogger.Instance, new RootTypeCache(), new ComponentParameterDeserializer(NullLogger.Instance, new ComponentParametersTypeCache())); } private static TestRemoteRenderer GetRemoteRenderer() diff --git a/src/Components/Server/test/Circuits/RemoteRendererTest.cs b/src/Components/Server/test/Circuits/RemoteRendererTest.cs index a2db200c8f0e..e8b2a36f29ad 100644 --- a/src/Components/Server/test/Circuits/RemoteRendererTest.cs +++ b/src/Components/Server/test/Circuits/RemoteRendererTest.cs @@ -646,7 +646,7 @@ private TestRemoteRenderer GetRemoteRenderer(IServiceProvider serviceProvider, C var serverComponentDeserializer = new ServerComponentDeserializer( _ephemeralDataProtectionProvider, NullLogger.Instance, - new RootComponentTypeCache(), + new RootTypeCache(), new ComponentParameterDeserializer( NullLogger.Instance, new ComponentParametersTypeCache())); diff --git a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs index b07bdb5e6b1c..0d04a3135dfe 100644 --- a/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs +++ b/src/Components/Server/test/Circuits/ServerComponentDeserializerTest.cs @@ -436,7 +436,7 @@ private ServerComponentDeserializer CreateServerComponentDeserializer() return new ServerComponentDeserializer( _ephemeralDataProtectionProvider, NullLogger.Instance, - new RootComponentTypeCache(), + new RootTypeCache(), new ComponentParameterDeserializer(NullLogger.Instance, new ComponentParametersTypeCache())); } diff --git a/src/Components/Shared/src/RootComponentTypeCache.cs b/src/Components/Shared/src/RootTypeCache.cs similarity index 93% rename from src/Components/Shared/src/RootComponentTypeCache.cs rename to src/Components/Shared/src/RootTypeCache.cs index ce4ceba5104e..9c41b8cd1533 100644 --- a/src/Components/Shared/src/RootComponentTypeCache.cs +++ b/src/Components/Shared/src/RootTypeCache.cs @@ -5,14 +5,18 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; +#if COMPONENTS +namespace Microsoft.AspNetCore.Components.Infrastructure; +#else namespace Microsoft.AspNetCore.Components; +#endif // A cache for root component types -internal sealed class RootComponentTypeCache +internal sealed class RootTypeCache { private readonly ConcurrentDictionary _typeToKeyLookUp = new(); - public Type? GetRootComponent(string assembly, string type) + public Type? GetRootType(string assembly, string type) { var key = new Key(assembly, type); if (_typeToKeyLookUp.TryGetValue(key, out var resolvedType)) diff --git a/src/Components/Web/src/JSComponents/JSComponentInterop.cs b/src/Components/Web/src/JSComponents/JSComponentInterop.cs index 5596a8c4bc7a..9df1c3288f71 100644 --- a/src/Components/Web/src/JSComponents/JSComponentInterop.cs +++ b/src/Components/Web/src/JSComponents/JSComponentInterop.cs @@ -87,7 +87,7 @@ protected internal void SetRootComponentParameters(int componentId, int paramete throw new ArgumentOutOfRangeException($"{nameof(parameterCount)} must be between 0 and {MaxParameters}."); } - var componentType = Renderer.GetRootComponentType(componentId); + var componentType = Renderer.GetRootTypeType(componentId); var parameterViewBuilder = new ParameterViewBuilder(parameterCount); var parametersJsonEnumerator = parametersJson.EnumerateObject(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 837fdd615880..194b95f209e5 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -28,7 +28,7 @@ public sealed class WebAssemblyHostBuilder { private readonly IInternalJSImportMethods _jsMethods; private Func _createServiceProvider; - private RootComponentTypeCache? _rootComponentCache; + private RootTypeCache? _rootComponentCache; private string? _persistedState; /// @@ -136,11 +136,11 @@ private void InitializeRegisteredRootComponents() registeredComponents[i].PrerenderId = i.ToString(CultureInfo.InvariantCulture); } - _rootComponentCache = new RootComponentTypeCache(); + _rootComponentCache = new RootTypeCache(); var componentDeserializer = WebAssemblyComponentParameterDeserializer.Instance; foreach (var registeredComponent in registeredComponents) { - var componentType = _rootComponentCache.GetRootComponent(registeredComponent.Assembly!, registeredComponent.TypeName!); + var componentType = _rootComponentCache.GetRootType(registeredComponent.Assembly!, registeredComponent.TypeName!); if (componentType is null) { throw new InvalidOperationException( diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index d4ac85867aac..f0418fd10354 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -33,7 +33,7 @@ - + @@ -58,7 +58,7 @@ - + diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs index 6d0ce0b78110..57850a705ebb 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs @@ -20,7 +20,7 @@ internal sealed partial class DefaultWebAssemblyJSRuntime : WebAssemblyJSRuntime { public static readonly DefaultWebAssemblyJSRuntime Instance = new(); - private readonly RootComponentTypeCache _rootComponentCache = new(); + private readonly RootTypeCache _rootComponentCache = new(); public ElementReferenceContext ElementReferenceContext { get; } @@ -130,7 +130,7 @@ internal static RootComponentOperationBatch DeserializeOperations(string operati throw new InvalidOperationException($"The component operation of type '{operation.Type}' requires a '{nameof(operation.Marker)}' to be specified."); } - var componentType = Instance._rootComponentCache.GetRootComponent(operation.Marker!.Value.Assembly!, operation.Marker.Value.TypeName!) + var componentType = Instance._rootComponentCache.GetRootType(operation.Marker!.Value.Assembly!, operation.Marker.Value.TypeName!) ?? throw new InvalidOperationException($"Root component type '{operation.Marker.Value.TypeName}' could not be found in the assembly '{operation.Marker.Value.Assembly}'."); var parameters = DeserializeComponentParameters(operation.Marker.Value); operation.Descriptor = new(componentType, parameters); From 3717242d03cc03245f4d96097af92f43b926d131 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 12 Mar 2025 20:41:20 +0100 Subject: [PATCH 34/37] Rework part of the public APIs --- .../Components/src/PublicAPI.Unshipped.txt | 4 ++ ...mponentStateServiceCollectionExtensions.cs | 42 +++++++++++++++++++ ...tateProviderServiceCollectionExtensions.cs | 33 +-------------- ...omPersistentComponentStateValueProvider.cs | 4 +- .../ComponentStatePersistenceManagerTest.cs | 10 +++++ .../PersistentServicesRegistryTest.cs | 12 ++++++ ...mponentsRazorComponentBuilderExtensions.cs | 35 ++++++++++++++++ ...orComponentsServiceCollectionExtensions.cs | 2 +- .../Endpoints/src/PublicAPI.Unshipped.txt | 2 + .../EndpointHtmlRenderer.PrerenderingState.cs | 1 + .../src/Hosting/WebAssemblyHostBuilder.cs | 2 +- .../RazorComponentEndpointsStartup.cs | 10 +++-- .../Components.WasmMinimal/Program.cs | 4 -- 13 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 src/Components/Components/src/RegisterPersistentComponentStateServiceCollectionExtensions.cs create mode 100644 src/Components/Endpoints/src/DependencyInjection/RazorComponentsRazorComponentBuilderExtensions.cs diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index d232c30f62fe..2cb1c27e8a06 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,8 +1,12 @@ #nullable enable Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void +Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions +Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions +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.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddPersistentService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/src/RegisterPersistentComponentStateServiceCollectionExtensions.cs b/src/Components/Components/src/RegisterPersistentComponentStateServiceCollectionExtensions.cs new file mode 100644 index 000000000000..0e99cccbd123 --- /dev/null +++ b/src/Components/Components/src/RegisterPersistentComponentStateServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using static Microsoft.AspNetCore.Internal.LinkerFlags; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +/// +/// Infrastructure APIs for registering services that persist state. +/// +public static class RegisterPersistentComponentStateServiceCollectionExtensions +{ + /// + /// Saves state when the application is persisting state and restores it at the appropriate time automatically. + /// + /// + /// Only public properties annotated with are persisted and restored. + /// + /// The service type to register for persistence. + /// The . + /// The to register the service for. + /// The . + public static IServiceCollection AddPersistentServiceRegistration<[DynamicallyAccessedMembers(JsonSerialized)] TService>( + IServiceCollection services, + IComponentRenderMode componentRenderMode) + { + // This method does something very similar to what we do when we register root components, except in this case we are registering services. + // We collect a list of all the registrations on during static rendering mode and push those registrations to interactive mode. + // When the interactive mode starts, we retrieve the registry, and this triggers the process for restoring all the states for all the services. + // The process for retrieving the services is the same as we do for root components. + // We look for the assembly in the current list of loaded assemblies. + // We look for the type inside the assembly. + // We resolve the service from the DI container. + // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. + services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentServiceRegistration(componentRenderMode))); + + return services; + } +} diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs index f7811c7842c0..6a34a6938683 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -1,12 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection.Extensions; -using static Microsoft.AspNetCore.Internal.LinkerFlags; -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.Extensions.DependencyInjection; /// /// Enables component parameters to be supplied from with . @@ -23,31 +21,4 @@ public static IServiceCollection AddSupplyValueFromPersistentComponentStateProvi services.TryAddEnumerable(ServiceDescriptor.Scoped()); return services; } - - /// - /// Saves state when the application is persisting state and restores it at the appropriate time automatically. - /// - /// - /// Only public properties annotated with are persisted and restored. - /// - /// The service type to register for persistence. - /// The . - /// The to register the service for. - /// The . - public static IServiceCollection AddPersistentService<[DynamicallyAccessedMembers(JsonSerialized)] TService>( - this IServiceCollection services, - IComponentRenderMode componentRenderMode) - { - // This method does something very similar to what we do when we register root components, except in this case we are registering services. - // We collect a list of all the registrations on during static rendering mode and push those registrations to interactive mode. - // When the interactive mode starts, we retrieve the registry, and this triggers the process for restoring all the states for all the services. - // The process for retrieving the services is the same as we do for root components. - // We look for the assembly in the current list of loaded assemblies. - // We look for the type inside the assembly. - // We resolve the service from the DI container. - // We loop through the properties in the type and try to restore the properties that have SupplyParameterFromPersistentComponentState on them. - services.TryAddEnumerable(ServiceDescriptor.Singleton(new PersistentServiceRegistration(componentRenderMode))); - - return services; - } } diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index c2a3183e0906..6de96ededdce 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -221,7 +221,7 @@ private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer, int? private static object? GetSerializableKey(ComponentState componentState) { - if (componentState.LogicalParentComponentState is not { } parentComponentState) + if (componentState.ParentComponentState is not { } parentComponentState) { return null; } @@ -248,7 +248,7 @@ private static void GrowBuffer(ref byte[]? pool, ref Span keyBuffer, int? private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!; private static string GetParentComponentType(ComponentState componentState) => - componentState.LogicalParentComponentState == null ? "" : GetComponentType(componentState.LogicalParentComponentState); + componentState.ParentComponentState == null ? "" : GetComponentType(componentState.ParentComponentState); private static byte[] KeyFactory((string parentComponentType, string componentType, string propertyName) parts) => SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(".", parts.parentComponentType, parts.componentType, parts.propertyName))); diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index d35b70720c57..4e5708c10f4d 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -430,5 +430,15 @@ private class PersistentService : IPersistentServiceRegistration public IComponentRenderMode GetRenderModeOrDefault() => null; } +} +static file class ComponentStatePersistenceManagerExtensions +{ + public static IServiceCollection AddPersistentService(this IServiceCollection services, IComponentRenderMode renderMode) + { + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration( + services, + renderMode); + return services; + } } diff --git a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs index f28c313276da..b5712ecd2e41 100644 --- a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -498,3 +498,15 @@ private class TestPersistentRegistration : IPersistentServiceRegistration public IComponentRenderMode GetRenderModeOrDefault() => null; } } + + +static file class ComponentStatePersistenceManagerExtensions +{ + public static IServiceCollection AddPersistentService(this IServiceCollection services, IComponentRenderMode renderMode) + { + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration( + services, + renderMode); + return services; + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsRazorComponentBuilderExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsRazorComponentBuilderExtensions.cs new file mode 100644 index 000000000000..7c927d0f721e --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsRazorComponentBuilderExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Internal; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides methods for configuring a Razor components based application. +/// +public static class RazorComponentsRazorComponentBuilderExtensions +{ + /// + /// Registers a persistent service with the specified render mode in the Razor components builder. + /// + /// The service to be registered for state management. + /// The . + /// The that determines to which render modes the state should be persisted. + /// The . + public static IRazorComponentsBuilder RegisterPersistentService<[DynamicallyAccessedMembers(LinkerFlags.JsonSerialized)] TPersistentService>( + this IRazorComponentsBuilder builder, + IComponentRenderMode renderMode) + { + ArgumentNullException.ThrowIfNull(builder); + + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration( + builder.Services, + renderMode); + + return builder; + } +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index f974b3ae0d7e..302dec7dcb16 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -80,7 +80,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.AddSupplyValueFromFormProvider(); services.TryAddScoped(); services.TryAddScoped(sp => (EndpointAntiforgeryStateProvider)sp.GetRequiredService()); - services.AddPersistentService(RenderMode.InteractiveAuto); + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveAuto); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..c84c2fdd7ae5 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions +static Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions.RegisterPersistentService(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs index c5547e284082..161967076ec5 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs @@ -134,6 +134,7 @@ public async ValueTask PrerenderPersistedStateAsync(HttpContext ht } var manager = _httpContext.RequestServices.GetRequiredService(); + // Now given the mode, we obtain a particular store for that mode // and persist the state and return the HTML content switch (serializationMode) diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 194b95f209e5..218936a9c1d8 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -313,7 +313,7 @@ internal void InitializeDefaultServices() builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance)); }); Services.AddSingleton(); - Services.AddPersistentService(RenderMode.InteractiveWebAssembly); + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Services, RenderMode.InteractiveWebAssembly); Services.AddSupplyValueFromQueryProvider(); } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index ee075a6e86ef..9615dcf58df0 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -8,7 +8,6 @@ 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; @@ -35,6 +34,9 @@ public void ConfigureServices(IServiceCollection services) options.MaxFormMappingRecursionDepth = 5; options.MaxFormMappingCollectionSize = 100; }) + .RegisterPersistentService(RenderMode.InteractiveServer) + .RegisterPersistentService(RenderMode.InteractiveAuto) + .RegisterPersistentService(RenderMode.InteractiveWebAssembly) .AddInteractiveWebAssemblyComponents() .AddInteractiveServerComponents() .AddAuthenticationStateSerialization(options => @@ -47,9 +49,9 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); - services.AddPersistentService(RenderMode.InteractiveServer); - services.AddPersistentService(RenderMode.InteractiveAuto); - services.AddPersistentService(RenderMode.InteractiveWebAssembly); + + + services.AddHttpContextAccessor(); services.AddSingleton(); diff --git a/src/Components/test/testassets/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index e3e478472610..88a28726961b 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -4,7 +4,6 @@ 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.Services; @@ -14,9 +13,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); -builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); -builder.Services.AddPersistentService(RenderMode.InteractiveWebAssembly); builder.Services.AddCascadingAuthenticationState(); From 44d92fc894c5f7a0d0b8939e5fa0cdfac930c3e5 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 12 Mar 2025 20:46:00 +0100 Subject: [PATCH 35/37] Update public API baselines --- src/Components/Components/src/PublicAPI.Unshipped.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 2cb1c27e8a06..a29a6819ac5a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -4,9 +4,6 @@ Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager. Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void -Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions 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.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddPersistentService(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! From adc065e903ac267198d7694f1d8f51563954ff82 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 12 Mar 2025 20:53:15 +0100 Subject: [PATCH 36/37] Fix build --- .../test/PersistentState/PersistentServicesRegistryTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs index b5712ecd2e41..98e0f3081b73 100644 --- a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -499,7 +499,6 @@ private class TestPersistentRegistration : IPersistentServiceRegistration } } - static file class ComponentStatePersistenceManagerExtensions { public static IServiceCollection AddPersistentService(this IServiceCollection services, IComponentRenderMode renderMode) From 3478be94958b2e8a2559bc8f7bb7ef532ac94d30 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 13 Mar 2025 18:32:34 +0100 Subject: [PATCH 37/37] Added support for DateTimeOffset keys --- .../SupplyParameterFromPersistentComponentStateValueProvider.cs | 1 + ...plyParameterFromPersistentComponentStateValueProviderTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index 6de96ededdce..4168bc8dedf0 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -262,6 +262,7 @@ private static bool IsSerializableKey(object key) var keyType = key.GetType(); var result = Type.GetTypeCode(keyType) != TypeCode.Object || keyType == typeof(Guid) + || keyType == typeof(DateTimeOffset) || keyType == typeof(DateOnly) || keyType == typeof(TimeOnly); diff --git a/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs index 3baeb13157f6..22fbbba1dda5 100644 --- a/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs +++ b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs @@ -228,6 +228,7 @@ public async Task PersistAsync_CanPersistMultipleComponentsOfSameType_WhenParent { Math.PI, -Math.PI }, { 123456.789m, -123456.789m }, { new DateTime(2023, 1, 1), new DateTime(2023, 12, 31) }, + { new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.FromSeconds(0)), new DateTimeOffset(2023, 12, 31, 0, 0, 0, TimeSpan.FromSeconds(0)) }, { "key1", "key2" }, // Include a very long key to validate logic around growing buffers { new string('a', 10000), new string('b', 10000) },