diff --git a/AspNetCore.sln b/AspNetCore.sln index 09105a846cb0..b572530c7f5b 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,15 @@ 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} + {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} @@ -11777,6 +11790,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} 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/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 c535d9cfda16..c61362537062 100644 --- a/src/Components/Components/src/ICascadingValueSupplier.cs +++ b/src/Components/Components/src/ICascadingValueSupplier.cs @@ -11,7 +11,7 @@ internal interface ICascadingValueSupplier bool CanSupplyValue(in CascadingParameterInfo parameterInfo); - object? GetCurrentValue(in CascadingParameterInfo parameterInfo); + object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo); void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); 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/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..a3dd2fdddc81 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 while persisting state is not allowed."); + } + var persistenceCallback = new PersistComponentStateRegistration(callback, renderMode); _registeredCallbacks.Add(persistenceCallback); @@ -87,6 +92,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 +137,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/Infrastructure/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs similarity index 63% rename from src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs rename to src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index e1b4fdf605ec..72c1ca666411 100644 --- a/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -15,17 +15,35 @@ 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 = new PersistentServicesRegistry(serviceProvider); + } + + // For testing purposes only + internal PersistentServicesRegistry? ServicesRegistry => _servicesRegistry; + + // For testing purposes only + internal List RegisteredCallbacks => _registeredCallbacks; + /// /// Gets the associated with the . /// @@ -40,6 +58,7 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store) { var data = await store.GetPersistedStateAsync(); State.InitializeExistingState(data); + _servicesRegistry?.Restore(State); } /// @@ -59,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) @@ -72,24 +94,53 @@ 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; + } + } + + /// + /// 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) @@ -125,11 +176,17 @@ private void InferRenderModes(Renderer renderer) } } - internal Task PauseAsync(IPersistentComponentStateStore store) + internal Task TryPauseAsync(IPersistentComponentStateStore store) { - List? pendingCallbackTasks = null; + List>? pendingCallbackTasks = null; - for (var i = 0; i < _registeredCallbacks.Count; i++) + // 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]; @@ -142,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 { @@ -176,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/PersistentState/IPersistentServiceRegistration.cs b/src/Components/Components/src/PersistentState/IPersistentServiceRegistration.cs new file mode 100644 index 000000000000..28aa98fb829b --- /dev/null +++ b/src/Components/Components/src/PersistentState/IPersistentServiceRegistration.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; + +// Represents a component that is registered for state persistence. +internal interface IPersistentServiceRegistration +{ + public string Assembly { get; } + public string FullTypeName { get; } + + public IComponentRenderMode? GetRenderModeOrDefault(); +} diff --git a/src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs b/src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs new file mode 100644 index 000000000000..0ece0d2a1d26 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentServiceRegistration.cs @@ -0,0 +1,17 @@ +// 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 sealed class PersistentServiceRegistration(IComponentRenderMode componentRenderMode) : IPersistentServiceRegistration +{ + 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/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs new file mode 100644 index 000000000000..cada8a256678 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -0,0 +1,230 @@ +// 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.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 IPersistentServiceRegistration[] _registrations; + private List _subscriptions = []; + private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); + + public PersistentServicesRegistry(IServiceProvider serviceProvider) + { + var registrations = serviceProvider.GetRequiredService>(); + _serviceProvider = serviceProvider; + _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 = "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) + { + 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 renderMode = registration.GetRenderModeOrDefault(); + + var instance = _serviceProvider.GetRequiredService(type); + subscriptions.Add(state.RegisterOnPersisting(() => + { + PersistInstanceState(instance, type, state); + return Task.CompletedTask; + }, renderMode)); + } + + if (RenderMode != null) + { + subscriptions.Add(state.RegisterOnPersisting(() => + { + state.PersistAsJson(_registryKey, _registrations); + return Task.CompletedTask; + }, RenderMode)); + } + + _subscriptions = subscriptions; + } + + [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 (runtimeType, 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); + } + } + } + + [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(PersistentServiceRegistration))] + internal void Restore(PersistentComponentState state) + { + if (state.TryTakeFromJson(_registryKey, out var registry) && registry != null) + { + _registrations = ResolveRegistrations(_registrations.Concat(registry)); + } + + RestoreRegistrationsIfAvailable(state); + } + + [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) + { + 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 (runtimeType, 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!); + } + } + } + + private static IPersistentServiceRegistration[] ResolveRegistrations(IEnumerable registrations) => [.. registrations.DistinctBy(r => (r.Assembly, r.FullTypeName)).OrderBy(r => r.Assembly).ThenBy(r => r.FullTypeName)]; + + private static Type? ResolveType(string assembly, string fullTypeName) => + _persistentServiceTypeCache.GetRootType(assembly, fullTypeName); + + 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) => + _underlyingAccessors.TryGetValue(key, out var result) ? result : default; + } + + [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] + private class PersistentServiceRegistration : IPersistentServiceRegistration + { + public string Assembly { get; set; } = ""; + + public string FullTypeName { get; set; } = ""; + + public IComponentRenderMode? GetRenderModeOrDefault() => null; + + private string GetDebuggerDisplay() => $"{Assembly}::{FullTypeName}"; + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..a29a6819ac5a 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #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.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.Extensions.DependencyInjection.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/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/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e0fcfd834340..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(); /// @@ -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 @@ -1126,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/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/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/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..6a34a6938683 --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +// 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; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 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; + } +} diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs new file mode 100644 index 000000000000..4168bc8dedf0 --- /dev/null +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -0,0 +1,271 @@ +// 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.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Components.Reflection; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Internal; + +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; + // For testing purposes only + internal Dictionary Subscriptions => _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(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; + } + + [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; + var propertyType = parameterInfo.PropertyType; + _subscriptions[subscriber] = state.RegisterOnPersisting(() => + { + var storageKey = ComputeKey(subscriber, propertyName); + 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), 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; + var propertyInfo = GetPropertyInfo(type, propertyName); + if (propertyInfo == null) + { + 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) + { + if (_subscriptions.TryGetValue(subscriber, out var subscription)) + { + subscription.Dispose(); + _subscriptions.Remove(subscriber); + } + } + + // 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. + // 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]; + var currentBuffer = keyBuffer; + preKey.CopyTo(keyBuffer); + 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) + { + // 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); + } + else + { + currentBuffer = currentBuffer[..written]; + } + } + } + else + { + var keySpan = ResolveKeySpan(key); + 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); + // 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 + { + currentBuffer = currentBuffer[..written]; + } + } + } + + keyBuffer = keyBuffer[..(preKey.Length + currentBuffer.Length)]; + + var hashSucceeded = SHA256.TryHashData(keyBuffer, keyHash, out _); + Debug.Assert(hashSucceeded); + return Convert.ToBase64String(keyHash); + } + finally + { + if (pool != null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + } + } + + 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, int? size = null) + { + var newPool = pool == null ? ArrayPool.Shared.Rent(size ?? 2048) : ArrayPool.Shared.Rent(pool.Length * 2); + keyBuffer.CopyTo(newPool); + keyBuffer = newPool; + if (pool != null) + { + ArrayPool.Shared.Return(pool, clearArray: true); + } + pool = newPool; + } + + private static object? GetSerializableKey(ComponentState componentState) + { + if (componentState.ParentComponentState 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.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))); + + 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(DateTimeOffset) + || keyType == typeof(DateOnly) + || keyType == typeof(TimeOnly); + + return result; + } +} 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/Lifetime/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs deleted file mode 100644 index 02b104d41f43..000000000000 --- a/src/Components/Components/test/Lifetime/ComponentStatePersistenceManagerTest.cs +++ /dev/null @@ -1,317 +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.Buffers; -using System.Collections; -using System.Text.Json; -using Microsoft.AspNetCore.Components.Infrastructure; -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 ComponentStatePersistenceManagerTest -{ - [Fact] - public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore() - { - // Arrange - var data = new byte[] { 0, 1, 2, 3, 4 }; - var state = new Dictionary - { - ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) - }; - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - - // Act - await lifetime.RestoreStateAsync(store); - - // Assert - Assert.True(lifetime.State.TryTakeFromJson("MyState", out var retrieved)); - Assert.Empty(state); - Assert.Equal(data, retrieved); - } - - [Fact] - public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() - { - // Arrange - var state = new Dictionary - { - ["MyState"] = [0, 1, 2, 3, 4] - }; - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - - await lifetime.RestoreStateAsync(store); - - // Assert - await Assert.ThrowsAsync(() => lifetime.RestoreStateAsync(store)); - } - - [Fact] - public async Task PersistStateAsync_ThrowsWhenCallbackRenerModeCannotBeInferred() - { - // Arrange - var state = new Dictionary(); - var store = new CompositeTestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - - var renderer = new TestRenderer(); - var data = new byte[] { 1, 2, 3, 4 }; - - lifetime.State.RegisterOnPersisting(() => - { - lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); - return Task.CompletedTask; - }); - - // Act - // Assert - await Assert.ThrowsAsync(() => lifetime.PersistStateAsync(store, renderer)); - } - - [Fact] - public async Task PersistStateAsync_SavesPersistedStateToTheStore() - { - // Arrange - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - - var renderer = new TestRenderer(); - var data = new byte[] { 1, 2, 3, 4 }; - - lifetime.State.RegisterOnPersisting(() => - { - lifetime.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); - return Task.CompletedTask; - }, new TestRenderMode()); - - // Act - await lifetime.PersistStateAsync(store, renderer); - - // Assert - Assert.True(store.State.TryGetValue("MyState", out var persisted)); - Assert.Equal(data, JsonSerializer.Deserialize(persisted.ToArray())); - } - - [Fact] - public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() - { - // Arrange - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - var renderer = new TestRenderer(); - var data = new byte[] { 1, 2, 3, 4 }; - var invoked = false; - - lifetime.State.RegisterOnPersisting(() => { invoked = true; return default; }, new TestRenderMode()); - - // Act - await lifetime.PersistStateAsync(store, renderer); - - // Assert - Assert.True(invoked); - } - - [Fact] - public async Task PersistStateAsync_FiresCallbacksInParallel() - { - // Arrange - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - var renderer = new TestRenderer(); - - var sequence = new List { }; - - 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()); - - // Act - var persistTask = lifetime.PersistStateAsync(store, renderer); - tcs.SetResult(); - tcs2.SetResult(); - - await persistTask; - - // Assert - Assert.Equal(new[] { 1, 2, 3, 4 }, sequence); - } - - [Fact] - public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDisposed() - { - // Arrange - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); - var renderer = new TestRenderer(); - - var sequence = new List { }; - - 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); }); - - // Act - subscription1.Dispose(); - subscription2.Dispose(); - - var persistTask = lifetime.PersistStateAsync(store, renderer); - tcs.SetResult(); - tcs2.SetResult(); - - await persistTask; - - // Assert - Assert.Empty(sequence); - } - - [Fact] - public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersistIfACallbackThrows() - { - // Arrange - var sink = new TestSink(); - var loggerFactory = new TestLoggerFactory(sink, true); - var logger = loggerFactory.CreateLogger(); - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger); - 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()); - - // Act - await lifetime.PersistStateAsync(store, renderer); - - // Assert - Assert.True(invoked); - var log = Assert.Single(sink.Writes); - Assert.Equal(LogLevel.Error, log.LogLevel); - } - - [Fact] - public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersistIfACallbackThrowsAsynchonously() - { - // Arrange - var sink = new TestSink(); - var loggerFactory = new TestLoggerFactory(sink, true); - var logger = loggerFactory.CreateLogger(); - var state = new Dictionary(); - var store = new TestStore(state); - var lifetime = new ComponentStatePersistenceManager(logger); - 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()); - - // Act - var persistTask = lifetime.PersistStateAsync(store, renderer); - tcs.SetResult(); - - await persistTask; - - // Assert - Assert.True(invoked); - var log = Assert.Single(sink.Writes); - Assert.Equal(LogLevel.Error, log.LogLevel); - } - - 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 TestStore : IPersistentComponentStateStore - { - public TestStore(IDictionary initialState) - { - State = initialState; - } - - public IDictionary State { get; set; } - - 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 CompositeTestStore : IPersistentComponentStateStore, IEnumerable - { - public CompositeTestStore(IDictionary initialState) - { - State = initialState; - } - - public IDictionary State { get; set; } - - public IEnumerator GetEnumerator() - { - yield return new TestStore(State); - yield return new TestStore(State); - } - - 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; - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - } - - private class TestRenderMode : IComponentRenderMode - { - - } -} 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/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/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs new file mode 100644 index 000000000000..4e5708c10f4d --- /dev/null +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -0,0 +1,444 @@ +// 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; +using System.Text.Json; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; +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() + { + // Arrange + var data = new byte[] { 0, 1, 2, 3, 4 }; + var state = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // Act + await persistenceManager.RestoreStateAsync(store); + + // Assert + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.Empty(state); + Assert.Equal(data, retrieved); + } + + [Fact] + public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() + { + // Arrange + var state = new Dictionary + { + ["MyState"] = [0, 1, 2, 3, 4] + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + await persistenceManager.RestoreStateAsync(store); + + // Assert + await Assert.ThrowsAsync(() => persistenceManager.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 persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + var renderer = new TestRenderer(); + var data = new byte[] { 1, 2, 3, 4 }; + + persistenceManager.State.RegisterOnPersisting(() => + { + persistenceManager.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); + return Task.CompletedTask; + }); + + // Act + // Assert + 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] + public async Task PersistStateAsync_SavesPersistedStateToTheStore() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + var renderer = new TestRenderer(); + var data = new byte[] { 1, 2, 3, 4 }; + + persistenceManager.State.RegisterOnPersisting(() => + { + persistenceManager.State.PersistAsJson("MyState", new byte[] { 1, 2, 3, 4 }); + return Task.CompletedTask; + }, new TestRenderMode()); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.True(store.State.TryGetValue("MyState", out var persisted)); + Assert.Equal(data, JsonSerializer.Deserialize(persisted.ToArray())); + } + + [Fact] + public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var renderer = new TestRenderer(); + var data = new byte[] { 1, 2, 3, 4 }; + var invoked = false; + + persistenceManager.State.RegisterOnPersisting(() => { invoked = true; return default; }, new TestRenderMode()); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.True(invoked); + } + + [Fact] + public async Task PersistStateAsync_FiresCallbacksInParallel() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var renderer = new TestRenderer(); + + var sequence = new List { }; + + var tcs = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + 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 = persistenceManager.PersistStateAsync(store, renderer); + tcs.SetResult(); + tcs2.SetResult(); + + await persistTask; + + // Assert + Assert.Equal(new[] { 2, 1, 3, 4 }, sequence); + } + + [Fact] + public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDisposed() + { + // Arrange + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var renderer = new TestRenderer(); + + var sequence = new List { }; + + var tcs = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + 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 = persistenceManager.PersistStateAsync(store, renderer); + tcs.SetResult(); + tcs2.SetResult(); + + await persistTask; + + // Assert + Assert.Empty(sequence); + } + + [Fact] + public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersistIfACallbackThrows() + { + // Arrange + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, true); + var logger = loggerFactory.CreateLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + CreateServiceProvider()); + var renderer = new TestRenderer(); + var data = new byte[] { 1, 2, 3, 4 }; + var invoked = false; + + persistenceManager.State.RegisterOnPersisting(() => throw new InvalidOperationException(), new TestRenderMode()); + persistenceManager.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }, new TestRenderMode()); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.True(invoked); + var log = Assert.Single(sink.Writes); + Assert.Equal(LogLevel.Error, log.LogLevel); + } + + [Fact] + public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersistIfACallbackThrowsAsynchonously() + { + // Arrange + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, true); + var logger = loggerFactory.CreateLogger(); + var state = new Dictionary(); + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + logger, + CreateServiceProvider()); + var renderer = new TestRenderer(); + var invoked = false; + var tcs = new TaskCompletionSource(); + + 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 = persistenceManager.PersistStateAsync(store, renderer); + tcs.SetResult(); + + await persistTask; + + // Assert + Assert.True(invoked); + var log = Assert.Single(sink.Writes); + 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) + { + } + + 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 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 CompositeTestStore(Dictionary initialState) + : IPersistentComponentStateStore, IEnumerable + { + public Dictionary State { get; set; } = initialState; + + public IEnumerator GetEnumerator() + { + yield return new TestStore(State); + yield return new TestStore(State); + } + + public Task> GetPersistedStateAsync() + { + return Task.FromResult(State as IDictionary); + } + + 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; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + private class TestRenderMode : IComponentRenderMode + { + } + + private class PersistentService : IPersistentServiceRegistration + { + public string Assembly { get; set; } + + public string FullTypeName { get; set; } + + 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 new file mode 100644 index 000000000000..98e0f3081b73 --- /dev/null +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -0,0 +1,511 @@ +// 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 : IPersistentServiceRegistration + { + public string Assembly { get; set; } + public string FullTypeName { get; set; } + + 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/SupplyParameterFromPersistentComponentStateValueProviderTests.cs b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs new file mode 100644 index 000000000000..22fbbba1dda5 --- /dev/null +++ b/src/Components/Components/test/SupplyParameterFromPersistentComponentStateValueProviderTests.cs @@ -0,0 +1,522 @@ +// 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) }, + { 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) }, + { 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(); + } +} 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/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 0836828dc949..302dec7dcb16 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()); + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveAuto); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs b/src/Components/Endpoints/src/Forms/EndpointAntiforgeryStateProvider.cs index 5df713d111c7..b1c113beca88 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; @@ -21,7 +21,7 @@ internal void SetRequestContext(HttpContext context) 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. @@ -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/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.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/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 92526e87ad3c..28ba43aada35 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -3,9 +3,9 @@ using System.Linq; using System.Security.Claims; -using Microsoft.AspNetCore.Components.Forms; 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; @@ -69,8 +69,8 @@ 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); - RestoreAntiforgeryToken(scope); } var serverComponentDeserializer = scope.ServiceProvider.GetRequiredService(); @@ -114,15 +114,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..66c636ba01f5 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -5,8 +5,8 @@ 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.Components.Web; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -758,8 +758,8 @@ 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); - RestoreAntiforgeryToken(_scope); } // Retrieve the circuit handlers at this point. @@ -804,15 +804,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/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/DefaultAntiforgeryStateProvider.cs b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs index 6a3d926a73a2..75dec5721d38 100644 --- a/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs +++ b/src/Components/Shared/src/DefaultAntiforgeryStateProvider.cs @@ -1,39 +1,19 @@ // 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; + protected 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) + [SupplyParameterFromPersistentComponentState] + public AntiforgeryRequestToken? CurrentToken { - // 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); + get => _currentToken ??= GetAntiforgeryToken(); + set => _currentToken = value; } /// public override AntiforgeryRequestToken? GetAntiforgeryToken() => _currentToken; - - /// - public void Dispose() => _subscription.Dispose(); } 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/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) 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/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index a36bb957ccce..21607a9f29d9 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -3,8 +3,8 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection.Metadata; -using Microsoft.AspNetCore.Components.Forms; 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; @@ -136,10 +136,9 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl new PrerenderComponentApplicationStore(_persistedState) : new PrerenderComponentApplicationStore(); + manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly); await manager.RestoreStateAsync(store); - RestoreAntiforgeryToken(); - if (MetadataUpdater.IsSupported) { await WebAssemblyHotReload.InitializeAsync(); @@ -233,11 +232,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(); - } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 8bee06649691..218936a9c1d8 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( @@ -300,18 +300,20 @@ 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.AddSupplyValueFromPersistentComponentStateProvider(); Services.AddSingleton(); Services.AddSingleton(); Services.AddLogging(builder => { builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance)); }); + Services.AddSingleton(); + RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Services, RenderMode.InteractiveWebAssembly); Services.AddSupplyValueFromQueryProvider(); } } 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); 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(); } } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index ec3fdc9fae3b..1aee9a2a03cc 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,15 @@ 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 +1111,54 @@ 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() + { + 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 +1347,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/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 943ee32d635c..9615dcf58df0 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -9,8 +9,10 @@ using Components.TestServer.RazorComponents.Pages.Forms; using Components.TestServer.Services; 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; @@ -32,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 => @@ -40,6 +45,14 @@ public void ConfigureServices(IServiceCollection services) options.SerializeAllClaims = serializeAllClaims; }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + + + + services.AddHttpContextAccessor(); services.AddSingleton(); services.AddCascadingAuthenticationState(); 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..53206e588a22 --- /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/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.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/Components.WasmMinimal/Program.cs b/src/Components/test/testassets/Components.WasmMinimal/Program.cs index 5074e4a0a068..88a28726961b 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Program.cs +++ b/src/Components/test/testassets/Components.WasmMinimal/Program.cs @@ -4,10 +4,16 @@ using System.Runtime.InteropServices.JavaScript; using System.Security.Claims; using Components.TestServer.Services; +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.AddCascadingAuthenticationState(); builder.Services.AddAuthenticationStateDeserialization(options => diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor new file mode 100644 index 000000000000..bbdbcc43dccd --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -0,0 +1,21 @@ +

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() + { + 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; } +} 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; } +} 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()