From e308c86bc0b0eefe4bdbdd74270519d93a5e3134 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 4 Jul 2025 18:41:14 +0200 Subject: [PATCH 1/2] [Blazor] Implement scenario-based persistent state filtering for Blazor * Adds support for ignoring persisted values during prerendering * Adds support for ignoring persisted values during resume * Adds support for receiving value updates during enhanced navigation Fixes #51584 --- .../src/ComponentSubscriptionKey.cs | 27 + .../src/PersistentComponentState.cs | 50 +- .../ComponentStatePersistenceManager.cs | 40 +- .../PersistComponentStateRegistration.cs | 0 .../PersistentComponentStateSerializer.cs | 0 .../PersistentServicesRegistry.cs | 82 +-- ...tateProviderServiceCollectionExtensions.cs | 0 .../PersistentStateValueProvider.cs | 63 ++ ...PersistentStateValueProviderKeyResolver.cs | 214 ++++++ ...stentValueProviderComponentSubscription.cs | 262 ++++++++ .../PersistingComponentStateSubscription.cs | 0 .../src/PersistentState/RestoreBehavior.cs | 32 + .../RestoreComponentStateRegistration.cs | 9 + .../src/PersistentState/RestoreContext.cs | 58 ++ .../src/PersistentState/RestoreOptions.cs | 28 + .../RestoringComponentStateSubscription.cs | 32 + .../src/PersistentStateAttribute.cs | 17 + .../src/PersistentStateValueProvider.cs | 382 ----------- .../Components/src/PublicAPI.Unshipped.txt | 24 + .../src/Reflection/PropertyGetter.cs | 4 + .../Components/src/RenderTree/RendererInfo.cs | 5 + .../src/Rendering/ComponentState.cs | 10 +- ...PersistentComponentStateSerializerTests.cs | 70 +- .../ComponentStatePersistenceManagerTest.cs | 101 +++ ...est.cs => PersistentComponentStateTest.cs} | 137 +++- .../PersistentServicesRegistryTest.cs | 157 ++++- .../PersistentState/RestoreContextTest.cs | 105 +++ ...stentStateValueProviderKeyResolverTests.cs | 428 ++++++++++++ .../test/PersistentStateValueProviderTests.cs | 530 +-------------- ...ValueProviderComponentSubscriptionTests.cs | 626 ++++++++++++++++++ .../src/Rendering/EndpointHtmlRenderer.cs | 2 +- .../Server/src/Circuits/CircuitFactory.cs | 2 +- .../Server/src/Circuits/CircuitHost.cs | 70 +- .../src/Circuits/RemoteComponentState.cs | 5 +- src/Components/Server/src/ComponentHub.cs | 2 +- .../Server/test/Circuits/CircuitHostTest.cs | 116 +++- .../Circuits/CircuitPersistenceManagerTest.cs | 2 + .../Server/test/Circuits/TestCircuitHost.cs | 21 +- .../Shared/src/WebRootComponentManager.cs | 29 +- .../Web.JS/src/Boot.Server.Common.ts | 10 +- .../Web.JS/src/Boot.WebAssembly.Common.ts | 15 +- src/Components/Web.JS/src/GlobalExports.ts | 4 +- .../src/Platform/Circuits/CircuitManager.ts | 15 +- .../src/Services/WebRootComponentManager.ts | 56 +- .../src/Hosting/WebAssemblyHost.cs | 2 +- .../src/Rendering/WebAssemblyRenderer.cs | 27 +- .../Services/DefaultWebAssemblyJSRuntime.cs | 6 +- .../ServerExecutionTests/ServerResumeTests.cs | 36 +- .../ServerRenderingTests/InteractivityTest.cs | 13 + .../E2ETest/Tests/StatePersistenceTest.cs | 149 +++++ ...hancedNavigationPersistentComponents.razor | 64 ++ .../PageWithoutComponents.razor | 1 + .../DeclarativePersistStateComponent.razor | 6 + .../PersistServicesState.razor | 9 + ...ponentWithDeclarativePersistentState.razor | 40 ++ ...ponentWithDeclarativePersistentState.razor | 59 ++ .../PersistentCounter.razor | 22 +- .../Services/InteractiveAutoService.cs | 3 + .../Services/InteractiveServerService.cs | 3 + .../Services/InteractiveWebAssemblyService.cs | 3 + ...ectedPrerenderComponentApplicationStore.cs | 12 +- 61 files changed, 3180 insertions(+), 1117 deletions(-) create mode 100644 src/Components/Components/src/ComponentSubscriptionKey.cs rename src/Components/Components/src/{ => PersistentState}/PersistComponentStateRegistration.cs (100%) rename src/Components/Components/src/{ => PersistentState}/PersistentComponentStateSerializer.cs (100%) rename src/Components/Components/src/{ => PersistentState}/PersistentStateProviderServiceCollectionExtensions.cs (100%) create mode 100644 src/Components/Components/src/PersistentState/PersistentStateValueProvider.cs create mode 100644 src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs create mode 100644 src/Components/Components/src/PersistentState/PersistentValueProviderComponentSubscription.cs rename src/Components/Components/src/{ => PersistentState}/PersistingComponentStateSubscription.cs (100%) create mode 100644 src/Components/Components/src/PersistentState/RestoreBehavior.cs create mode 100644 src/Components/Components/src/PersistentState/RestoreComponentStateRegistration.cs create mode 100644 src/Components/Components/src/PersistentState/RestoreContext.cs create mode 100644 src/Components/Components/src/PersistentState/RestoreOptions.cs create mode 100644 src/Components/Components/src/PersistentState/RestoringComponentStateSubscription.cs delete mode 100644 src/Components/Components/src/PersistentStateValueProvider.cs rename src/Components/Components/test/PersistentState/{ComponentApplicationStateTest.cs => PersistentComponentStateTest.cs} (52%) create mode 100644 src/Components/Components/test/PersistentState/RestoreContextTest.cs create mode 100644 src/Components/Components/test/PersistentStateValueProviderKeyResolverTests.cs create mode 100644 src/Components/Components/test/PersistentValueProviderComponentSubscriptionTests.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithDeclarativeEnhancedNavigationPersistentComponents.razor create mode 100644 src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithDeclarativePersistentState.razor create mode 100644 src/Components/test/testassets/TestContentPackage/PersistentComponents/StreamingComponentWithDeclarativePersistentState.razor diff --git a/src/Components/Components/src/ComponentSubscriptionKey.cs b/src/Components/Components/src/ComponentSubscriptionKey.cs new file mode 100644 index 000000000000..010510f480a6 --- /dev/null +++ b/src/Components/Components/src/ComponentSubscriptionKey.cs @@ -0,0 +1,27 @@ +// 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; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +[DebuggerDisplay("{GetDebuggerDisplay(),nq}")] +internal readonly struct ComponentSubscriptionKey(ComponentState subscriber, string propertyName) : IEquatable +{ + public ComponentState Subscriber { get; } = subscriber; + + public string PropertyName { get; } = propertyName; + + public bool Equals(ComponentSubscriptionKey other) + => Subscriber == other.Subscriber && PropertyName == other.PropertyName; + + public override bool Equals(object? obj) + => obj is ComponentSubscriptionKey other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine(Subscriber, PropertyName); + + private string GetDebuggerDisplay() + => $"{Subscriber.Component.GetType().Name}.{PropertyName}"; +} diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index d3f5e9fd9309..1c7337eb534f 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -16,24 +17,30 @@ public class PersistentComponentState private readonly IDictionary _currentState; private readonly List _registeredCallbacks; + private readonly List _registeredRestoringCallbacks; internal PersistentComponentState( - IDictionary currentState, - List pauseCallbacks) + IDictionary currentState, + List pauseCallbacks, + List restoringCallbacks) { _currentState = currentState; _registeredCallbacks = pauseCallbacks; + _registeredRestoringCallbacks = restoringCallbacks; } internal bool PersistingState { get; set; } - internal void InitializeExistingState(IDictionary existingState) + internal RestoreContext CurrentContext { get; private set; } = RestoreContext.InitialValue; + + internal void InitializeExistingState(IDictionary existingState, RestoreContext context) { if (_existingState != null) { throw new InvalidOperationException("PersistentComponentState already initialized."); } _existingState = existingState ?? throw new ArgumentNullException(nameof(existingState)); + CurrentContext = context; } /// @@ -68,6 +75,30 @@ public PersistingComponentStateSubscription RegisterOnPersisting(Func call return new PersistingComponentStateSubscription(_registeredCallbacks, persistenceCallback); } + /// + /// Register a callback to restore the state when the application state is being restored. + /// + /// The callback to invoke when the application state is being restored. + /// Options that control the restoration behavior. + /// A subscription that can be used to unregister the callback when disposed. + public RestoringComponentStateSubscription RegisterOnRestoring(Action callback, RestoreOptions options) + { + Debug.Assert(CurrentContext != null); + if (CurrentContext.ShouldRestore(options)) + { + callback(); + } + + if (options.AllowUpdates) + { + var registration = new RestoreComponentStateRegistration(callback); + _registeredRestoringCallbacks.Add(registration); + return new RestoringComponentStateSubscription(_registeredRestoringCallbacks, registration); + } + + return default; + } + /// /// Serializes as JSON and persists it under the given . /// @@ -214,4 +245,17 @@ private bool TryTake(string key, out byte[]? value) return false; } } + + internal void UpdateExistingState(IDictionary state, RestoreContext context) + { + ArgumentNullException.ThrowIfNull(state); + + if (_existingState == null || _existingState.Count > 0) + { + throw new InvalidOperationException("Cannot update existing state: previous state has not been cleared or state is not initialized."); + } + + _existingState = state; + CurrentContext = context; + } } diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 72c1ca666411..41fa00a6bb7c 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -12,9 +12,11 @@ namespace Microsoft.AspNetCore.Components.Infrastructure; public class ComponentStatePersistenceManager { private readonly List _registeredCallbacks = new(); + private readonly List _registeredRestoringCallbacks = new(); private readonly ILogger _logger; private bool _stateIsPersisted; + private bool _stateIsInitialized; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); @@ -24,7 +26,7 @@ public class ComponentStatePersistenceManager /// public ComponentStatePersistenceManager(ILogger logger) { - State = new PersistentComponentState(_currentState, _registeredCallbacks); + State = new PersistentComponentState(_currentState, _registeredCallbacks, _registeredRestoringCallbacks); _logger = logger; } @@ -55,10 +57,38 @@ public ComponentStatePersistenceManager(ILoggerThe to restore the application state from. /// A that will complete when the state has been restored. public async Task RestoreStateAsync(IPersistentComponentStateStore store) + { + await RestoreStateAsync(store, RestoreContext.InitialValue); + } + + /// + /// Restores the application state. + /// + /// The to restore the application state from. + /// The that provides additional context for the restoration. + /// A that will complete when the state has been restored. + public async Task RestoreStateAsync(IPersistentComponentStateStore store, RestoreContext context) { var data = await store.GetPersistedStateAsync(); - State.InitializeExistingState(data); - _servicesRegistry?.Restore(State); + + if (_stateIsInitialized) + { + if (context != RestoreContext.ValueUpdate) + { + throw new InvalidOperationException("State already initialized."); + } + State.UpdateExistingState(data, context); + foreach (var registration in _registeredRestoringCallbacks) + { + registration.Callback(); + } + } + else + { + State.InitializeExistingState(data, context); + _servicesRegistry?.RegisterForPersistence(State); + _stateIsInitialized = true; + } } /// @@ -78,9 +108,6 @@ 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) @@ -271,4 +298,5 @@ static async Task AnyTaskFailed(List> pendingCallbackTasks) return true; } } + } diff --git a/src/Components/Components/src/PersistComponentStateRegistration.cs b/src/Components/Components/src/PersistentState/PersistComponentStateRegistration.cs similarity index 100% rename from src/Components/Components/src/PersistComponentStateRegistration.cs rename to src/Components/Components/src/PersistentState/PersistComponentStateRegistration.cs diff --git a/src/Components/Components/src/PersistentComponentStateSerializer.cs b/src/Components/Components/src/PersistentState/PersistentComponentStateSerializer.cs similarity index 100% rename from src/Components/Components/src/PersistentComponentStateSerializer.cs rename to src/Components/Components/src/PersistentState/PersistentComponentStateSerializer.cs diff --git a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs index b072cd4c2c88..e27e5c2560b0 100644 --- a/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs +++ b/src/Components/Components/src/PersistentState/PersistentServicesRegistry.cs @@ -18,11 +18,11 @@ 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 static readonly RootTypeCache _persistentServiceTypeCache = new(); private readonly IServiceProvider _serviceProvider; private IPersistentServiceRegistration[] _registrations; - private List _subscriptions = []; + private List<(PersistingComponentStateSubscription, RestoringComponentStateSubscription)> _subscriptions = []; private static readonly ConcurrentDictionary _cachedAccessorsByType = new(); static PersistentServicesRegistry() @@ -54,7 +54,9 @@ internal void RegisterForPersistence(PersistentComponentState state) return; } - var subscriptions = new List(_registrations.Length + 1); + UpdateRegistrations(state); + var subscriptions = new List<(PersistingComponentStateSubscription, RestoringComponentStateSubscription)>( + _registrations.Length + 1); for (var i = 0; i < _registrations.Length; i++) { var registration = _registrations[i]; @@ -67,20 +69,29 @@ internal void RegisterForPersistence(PersistentComponentState state) var renderMode = registration.GetRenderModeOrDefault(); var instance = _serviceProvider.GetRequiredService(type); - subscriptions.Add(state.RegisterOnPersisting(() => - { - PersistInstanceState(instance, type, state); - return Task.CompletedTask; - }, renderMode)); + subscriptions.Add(( + state.RegisterOnPersisting(() => + { + PersistInstanceState(instance, type, state); + return Task.CompletedTask; + }, renderMode), + // In order to avoid registering one callback per property, we register a single callback with the most + // permissive options and perform the filtering inside of it. + state.RegisterOnRestoring(() => + { + RestoreInstanceState(instance, type, state); + }, new RestoreOptions { AllowUpdates = true }))); } if (RenderMode != null) { - subscriptions.Add(state.RegisterOnPersisting(() => - { - state.PersistAsJson(_registryKey, _registrations); - return Task.CompletedTask; - }, RenderMode)); + subscriptions.Add(( + state.RegisterOnPersisting(() => + { + state.PersistAsJson(_registryKey, _registrations); + return Task.CompletedTask; + }, RenderMode), + default)); } _subscriptions = subscriptions; @@ -92,7 +103,7 @@ private static void PersistInstanceState(object instance, Type type, PersistentC 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 (setter, getter, options) = accessors.GetAccessor(key); var value = getter.GetValue(instance); if (value != null) { @@ -105,33 +116,12 @@ private static void PersistInstanceState(object instance, Type type, PersistentC "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) + private void UpdateRegistrations(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); - 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)")] @@ -140,9 +130,13 @@ private static void RestoreInstanceState(object instance, Type type, PersistentC var accessors = _cachedAccessorsByType.GetOrAdd(instance.GetType(), static (runtimeType, declaredType) => new PropertiesAccessor(runtimeType, declaredType), type); foreach (var (key, propertyType) in accessors.KeyTypePairs) { + var (setter, getter, options) = accessors.GetAccessor(key); + if (!state.CurrentContext.ShouldRestore(options)) + { + continue; + } if (state.TryTakeFromJson(key, propertyType, out var result)) { - var (setter, getter) = accessors.GetAccessor(key); setter.SetValue(instance, result!); } } @@ -165,12 +159,12 @@ private sealed class PropertiesAccessor { internal const BindingFlags BindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase; - private readonly Dictionary _underlyingAccessors; + private readonly Dictionary _underlyingAccessors; private readonly (string, Type)[] _cachedKeysForService; public PropertiesAccessor([DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType, Type keyType) { - _underlyingAccessors = new Dictionary(StringComparer.OrdinalIgnoreCase); + _underlyingAccessors = new Dictionary(StringComparer.OrdinalIgnoreCase); var keys = new List<(string, Type)>(); foreach (var propertyInfo in GetCandidateBindableProperties(targetType)) @@ -204,10 +198,16 @@ public PropertiesAccessor([DynamicallyAccessedMembers(LinkerFlags.Component)] Ty $"The type '{targetType.FullName}' declares a property matching the name '{propertyName}' that is not public. Persistent service properties must be public."); } + var restoreOptions = new RestoreOptions + { + RestoreBehavior = parameterAttribute.RestoreBehavior, + AllowUpdates = parameterAttribute.AllowUpdates, + }; + var propertySetter = new PropertySetter(targetType, propertyInfo); var propertyGetter = new PropertyGetter(targetType, propertyInfo); - _underlyingAccessors.Add(key, (propertySetter, propertyGetter)); + _underlyingAccessors.Add(key, (propertySetter, propertyGetter, restoreOptions)); } _cachedKeysForService = [.. keys]; @@ -236,7 +236,7 @@ internal static IEnumerable GetCandidateBindableProperties( [DynamicallyAccessedMembers(LinkerFlags.Component)] Type targetType) => MemberAssignment.GetPropertiesIncludingInherited(targetType, BindablePropertyFlags); - internal (PropertySetter setter, PropertyGetter getter) GetAccessor(string key) => + internal (PropertySetter setter, PropertyGetter getter, RestoreOptions options) GetAccessor(string key) => _underlyingAccessors.TryGetValue(key, out var result) ? result : default; } diff --git a/src/Components/Components/src/PersistentStateProviderServiceCollectionExtensions.cs b/src/Components/Components/src/PersistentState/PersistentStateProviderServiceCollectionExtensions.cs similarity index 100% rename from src/Components/Components/src/PersistentStateProviderServiceCollectionExtensions.cs rename to src/Components/Components/src/PersistentState/PersistentStateProviderServiceCollectionExtensions.cs diff --git a/src/Components/Components/src/PersistentState/PersistentStateValueProvider.cs b/src/Components/Components/src/PersistentState/PersistentStateValueProvider.cs new file mode 100644 index 000000000000..53158b44e0db --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentStateValueProvider.cs @@ -0,0 +1,63 @@ +// 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.Rendering; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +internal sealed partial class PersistentStateValueProvider(PersistentComponentState state, ILogger logger, IServiceProvider serviceProvider) : ICascadingValueSupplier +{ + 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 PersistentStateAttribute; + + [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!; + + if (_subscriptions.TryGetValue(new(componentState, parameterInfo.PropertyName), out var subscription)) + { + return subscription.GetOrComputeLastValue(); + } + + return null; + } + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + var propertyName = parameterInfo.PropertyName; + + var componentSubscription = new PersistentValueProviderComponentSubscription( + state, + subscriber, + parameterInfo, + serviceProvider, + logger); + + _subscriptions.Add(new ComponentSubscriptionKey(subscriber, propertyName), componentSubscription); + } + + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + if (_subscriptions.TryGetValue(new(subscriber, parameterInfo.PropertyName), out var subscription)) + { + subscription.Dispose(); + _subscriptions.Remove(new(subscriber, parameterInfo.PropertyName)); + } + } +} diff --git a/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs b/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs new file mode 100644 index 000000000000..8de18363342c --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentStateValueProviderKeyResolver.cs @@ -0,0 +1,214 @@ +// 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.Globalization; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +internal static class PersistentStateValueProviderKeyResolver +{ + private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new(); + + static PersistentStateValueProviderKeyResolver() + { + if (HotReloadManager.Default.MetadataUpdateSupported) + { + HotReloadManager.Default.OnDeltaApplied += ClearCaches; + } + } + + private static void ClearCaches() + { + _keyCache.Clear(); + } + + // 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 because 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) + { + var componentKey = componentState.GetComponentKey(); + if (componentKey != null && IsSerializableKey(componentKey)) + { + return componentKey; + } + + return null; + } + + private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!; + + private static string GetParentComponentType(ComponentState componentState) + { + if (componentState.ParentComponentState == null) + { + return ""; + } + if (componentState.ParentComponentState.Component == null) + { + return ""; + } + + if (componentState.ParentComponentState.ParentComponentState != null) + { + var renderer = componentState.Renderer; + var parentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.Component); + var grandParentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.ParentComponentState.Component); + if (parentRenderMode != grandParentRenderMode) + { + // This is the case when EndpointHtmlRenderer introduces an SSRRenderBoundary component. + // We want to return "" because the SSRRenderBoundary component is not a real component + // and won't appear on the component tree in the WebAssemblyRenderer and RemoteRenderer + // interactive scenarios. + return ""; + } + } + + return 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/src/PersistentState/PersistentValueProviderComponentSubscription.cs b/src/Components/Components/src/PersistentState/PersistentValueProviderComponentSubscription.cs new file mode 100644 index 000000000000..94dc09f73ad9 --- /dev/null +++ b/src/Components/Components/src/PersistentState/PersistentValueProviderComponentSubscription.cs @@ -0,0 +1,262 @@ +// 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.CodeAnalysis; +using System.Reflection; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Components.Reflection; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +internal partial class PersistentValueProviderComponentSubscription : IDisposable +{ + private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); + private static readonly ConcurrentDictionary _serializerCache = new(); + private static readonly object _uninitializedSentinel = new(); + + static PersistentValueProviderComponentSubscription() + { + if (HotReloadManager.Default.MetadataUpdateSupported) + { + HotReloadManager.Default.OnDeltaApplied += ClearCaches; + } + } + + private static void ClearCaches() + { + _propertyGetterCache.Clear(); + _serializerCache.Clear(); + } + + private readonly PersistentComponentState _state; + private readonly ComponentState _subscriber; + private readonly string _propertyName; + private readonly Type _propertyType; + private readonly PropertyGetter _propertyGetter; + private readonly IPersistentComponentStateSerializer? _customSerializer; + private readonly ILogger _logger; + + private readonly PersistingComponentStateSubscription? _persistingSubscription; + private readonly RestoringComponentStateSubscription? _restoringSubscription; + private object? _lastValue = _uninitializedSentinel; + private bool _hasPendingInitialValue; + private bool _ignoreComponentPropertyValue; + private string? _storageKey; + + public PersistentValueProviderComponentSubscription( + PersistentComponentState state, + ComponentState subscriber, + CascadingParameterInfo parameterInfo, + IServiceProvider serviceProvider, + ILogger logger) + { + _state = state; + _subscriber = subscriber; + _propertyName = parameterInfo.PropertyName; + _propertyType = parameterInfo.PropertyType; + _logger = logger; + var attribute = (PersistentStateAttribute)parameterInfo.Attribute; + + _customSerializer = _serializerCache.GetOrAdd(_propertyType, SerializerFactory, serviceProvider); + _propertyGetter = _propertyGetterCache.GetOrAdd((subscriber.Component.GetType(), _propertyName), PropertyGetterFactory); + + _persistingSubscription = state.RegisterOnPersisting( + PersistProperty, + subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); + + _restoringSubscription = state.RegisterOnRestoring( + RestoreProperty, + new RestoreOptions { RestoreBehavior = attribute.RestoreBehavior, AllowUpdates = attribute.AllowUpdates }); + } + + // GetOrComputeLastValue is a bit of a special provider. + // Right after a Restore operation it will capture the last value and return that, but it must support the user + // overriding the property at a later point, so to support that, we need to keep track of whether or not we have + // delivered the last value, and if so, instead of returning the _lastValue, we simply read the property and return + // that instead. That way, if the component updates the property in SetParametersAsync, we won't revert it to the + // value we restored from the persistent state. + internal object? GetOrComputeLastValue() + { + var isInitialized = !ReferenceEquals(_lastValue, _uninitializedSentinel); + if (!isInitialized) + { + // Remove the uninitialized sentinel. + _lastValue = null; + if (_hasPendingInitialValue) + { + RestoreProperty(); + _hasPendingInitialValue = false; + } + } + else + { + if (_ignoreComponentPropertyValue) + { + // At this point, we just received a value update from `RestoreProperty`. + // The property value might have been modified by the component and in this + // case we want to overwrite it with the value we just restored. + _ignoreComponentPropertyValue = false; + return _lastValue; + } + else + { + // In this case, the component might have modified the property value after + // we restored it from the persistent state. We don't want to overwrite it + // with a previously restored value. + var currentPropertyValue = _propertyGetter.GetValue(_subscriber.Component); + return currentPropertyValue; + } + } + + return _lastValue; + } + + [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")] + [UnconditionalSuppressMessage("Trimming", "IL2077:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes' in call to target method. The source field does not have matching annotations.", Justification = "Property types on components are preserved through other means.")] + internal void RestoreProperty() + { + var skipNotifications = _hasPendingInitialValue; + if (ReferenceEquals(_lastValue, _uninitializedSentinel) && !_hasPendingInitialValue) + { + // Upon subscribing, the callback might be invoked right away, + // but this is too early to restore the first value since the component state + // hasn't been fully initialized yet. + // For that reason, we make a mark to restore the state on GetOrComputeLastValue. + _hasPendingInitialValue = true; + return; + } + + // The key needs to be computed here, do not move this outside of the lambda. + _storageKey ??= PersistentStateValueProviderKeyResolver.ComputeKey(_subscriber, _propertyName); + + if (_customSerializer != null) + { + if (_state.TryTakeBytes(_storageKey, out var data)) + { + Log.RestoringValueFromState(_logger, _storageKey, _propertyType.Name, _propertyName); + var sequence = new ReadOnlySequence(data!); + _lastValue = _customSerializer.Restore(_propertyType, sequence); + if (!skipNotifications) + { + _ignoreComponentPropertyValue = true; + _subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound); + } + } + else + { + Log.ValueNotFoundInPersistentState(_logger, _storageKey, _propertyType.Name, "null", _propertyName); + } + } + else + { + if (_state.TryTakeFromJson(_storageKey, _propertyType, out var value)) + { + Log.RestoredValueFromPersistentState(_logger, _storageKey, _propertyType.Name, "null", _propertyName); + _lastValue = value; + if (!skipNotifications) + { + _ignoreComponentPropertyValue = true; + _subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound); + } + } + else + { + Log.NoValueToRestoreFromState(_logger, _storageKey, _propertyType.Name, _propertyName); + } + } + } + + [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")] + [UnconditionalSuppressMessage("Trimming", "IL2077:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes' in call to target method. The source field does not have matching annotations.", Justification = "Property types on components are preserved through other means.")] + private Task PersistProperty() + { + // The key needs to be computed here, do not move this outside of the lambda. + _storageKey ??= PersistentStateValueProviderKeyResolver.ComputeKey(_subscriber, _propertyName); + + var property = _propertyGetter.GetValue(_subscriber.Component); + if (property == null) + { + Log.SkippedPersistingNullValue(_logger, _storageKey, _propertyType.Name, _subscriber.Component.GetType().Name, _propertyName); + return Task.CompletedTask; + } + + if (_customSerializer != null) + { + Log.PersistingValueToState(_logger, _storageKey, _propertyType.Name, _subscriber.Component.GetType().Name, _propertyName); + + using var writer = new PooledArrayBufferWriter(); + _customSerializer.Persist(_propertyType, property, writer); + _state.PersistAsBytes(_storageKey, writer.WrittenMemory.ToArray()); + return Task.CompletedTask; + } + + // Fallback to JSON serialization + Log.PersistingValueToState(_logger, _storageKey, _propertyType.Name, _subscriber.Component.GetType().Name, _propertyName); + _state.PersistAsJson(_storageKey, property, _propertyType); + return Task.CompletedTask; + } + + public void Dispose() + { + _persistingSubscription?.Dispose(); + _restoringSubscription?.Dispose(); + } + + private IPersistentComponentStateSerializer? SerializerFactory(Type type, IServiceProvider serviceProvider) + { + var serializerType = typeof(PersistentComponentStateSerializer<>).MakeGenericType(type); + var serializer = serviceProvider.GetService(serializerType); + + // The generic class now inherits from the internal interface, so we can cast directly + return serializer as IPersistentComponentStateSerializer; + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2077:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The source field does not have matching annotations.", + 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); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Persisting value for storage key '{StorageKey}' of type '{PropertyType}' from component '{ComponentType}' for property '{PropertyName}'", EventName = "PersistingValueToState")] + public static partial void PersistingValueToState(ILogger logger, string storageKey, string propertyType, string componentType, string propertyName); + + [LoggerMessage(2, LogLevel.Debug, "Skipped persisting null value for storage key '{StorageKey}' of type '{PropertyType}' from component '{ComponentType}' for property '{PropertyName}'", EventName = "SkippedPersistingNullValue")] + public static partial void SkippedPersistingNullValue(ILogger logger, string storageKey, string propertyType, string componentType, string propertyName); + + [LoggerMessage(3, LogLevel.Debug, "Restoring value for storage key '{StorageKey}' of type '{PropertyType}' for property '{PropertyName}'", EventName = "RestoringValueFromState")] + public static partial void RestoringValueFromState(ILogger logger, string storageKey, string propertyType, string propertyName); + + [LoggerMessage(4, LogLevel.Debug, "No value to restore for storage key '{StorageKey}' of type '{PropertyType}' for property '{PropertyName}'", EventName = "NoValueToRestoreFromState")] + public static partial void NoValueToRestoreFromState(ILogger logger, string storageKey, string propertyType, string propertyName); + + [LoggerMessage(5, LogLevel.Debug, "Restored value from persistent state for storage key '{StorageKey}' of type '{PropertyType}' for component '{ComponentType}' for property '{PropertyName}'", EventName = "RestoredValueFromPersistentState")] + public static partial void RestoredValueFromPersistentState(ILogger logger, string storageKey, string propertyType, string componentType, string propertyName); + + [LoggerMessage(6, LogLevel.Debug, "Value not found in persistent state for storage key '{StorageKey}' of type '{PropertyType}' for component '{ComponentType}' for property '{PropertyName}'", EventName = "ValueNotFoundInPersistentState")] + public static partial void ValueNotFoundInPersistentState(ILogger logger, string storageKey, string propertyType, string componentType, string propertyName); + } +} diff --git a/src/Components/Components/src/PersistingComponentStateSubscription.cs b/src/Components/Components/src/PersistentState/PersistingComponentStateSubscription.cs similarity index 100% rename from src/Components/Components/src/PersistingComponentStateSubscription.cs rename to src/Components/Components/src/PersistentState/PersistingComponentStateSubscription.cs diff --git a/src/Components/Components/src/PersistentState/RestoreBehavior.cs b/src/Components/Components/src/PersistentState/RestoreBehavior.cs new file mode 100644 index 000000000000..e958eed03c15 --- /dev/null +++ b/src/Components/Components/src/PersistentState/RestoreBehavior.cs @@ -0,0 +1,32 @@ +// 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 the behavior to use when restoring state for a component parameter. +/// +/// +/// By default, it always restores the value in all situations. +/// Use to skip restoring the initial value +/// when the host starts up. +/// Use to skip restoring the last value captured +/// the last time the current host was shut down, if the host supports restarting. +/// +[Flags] +public enum RestoreBehavior +{ + /// + /// Restore the value in all situations. + /// + Default = 0, + /// + /// Avoid restoring the initial value when the host starts up. + /// + SkipInitialValue = 1, + + /// + /// Avoid restoring the last value captured when the current host was shut down. + /// + SkipLastSnapshot = 2 +} diff --git a/src/Components/Components/src/PersistentState/RestoreComponentStateRegistration.cs b/src/Components/Components/src/PersistentState/RestoreComponentStateRegistration.cs new file mode 100644 index 000000000000..1d5b16f8fce4 --- /dev/null +++ b/src/Components/Components/src/PersistentState/RestoreComponentStateRegistration.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +internal readonly struct RestoreComponentStateRegistration(Action callback) +{ + public Action Callback { get; } = callback; +} diff --git a/src/Components/Components/src/PersistentState/RestoreContext.cs b/src/Components/Components/src/PersistentState/RestoreContext.cs new file mode 100644 index 000000000000..461792bff6f7 --- /dev/null +++ b/src/Components/Components/src/PersistentState/RestoreContext.cs @@ -0,0 +1,58 @@ +// 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; + +/// +/// The context where the restore operation is taking place. +/// +public sealed class RestoreContext +{ + private readonly bool _initialValue; + private readonly bool _lastSnapshot; + private readonly bool _allowUpdates; + + /// + /// Gets a that indicates the host is restoring initial values. + /// + public static RestoreContext InitialValue { get; } = new RestoreContext(true, false, false); + + /// + /// Gets a that indicates the host is restoring the last snapshot + /// available from the previous time the host was running. + /// + public static RestoreContext LastSnapshot { get; } = new RestoreContext(false, true, false); + + /// + /// Gets the that indicates the host is providing an external state + /// update to the current value. + /// + public static RestoreContext ValueUpdate { get; } = new RestoreContext(false, false, true); + + private RestoreContext(bool initialValue, bool lastSnapshot, bool allowUpdates) + { + _initialValue = initialValue; + _lastSnapshot = lastSnapshot; + _allowUpdates = allowUpdates; + } + + internal bool ShouldRestore(RestoreOptions options) + { + if (_initialValue && !options.RestoreBehavior.HasFlag(RestoreBehavior.SkipInitialValue)) + { + return true; + } + + if (_lastSnapshot && !options.RestoreBehavior.HasFlag(RestoreBehavior.SkipLastSnapshot)) + { + return true; + } + + if (_allowUpdates && options.AllowUpdates) + { + return true; + } + + return false; + } +} diff --git a/src/Components/Components/src/PersistentState/RestoreOptions.cs b/src/Components/Components/src/PersistentState/RestoreOptions.cs new file mode 100644 index 000000000000..4f2696f6546f --- /dev/null +++ b/src/Components/Components/src/PersistentState/RestoreOptions.cs @@ -0,0 +1,28 @@ +// 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 the options available for a restore operation. +/// +public readonly struct RestoreOptions +{ + /// + /// Initializes a new instance of the struct with default values. + /// + public RestoreOptions() + { + } + + /// + /// Gets the behavior to use when restoring data. + /// + public RestoreBehavior RestoreBehavior { get; init; } = RestoreBehavior.Default; + + /// + /// Gets a value indicating whether the registration wants to receive updates beyond + /// the initially provided value. + /// + public bool AllowUpdates { get; init; } = false; +} diff --git a/src/Components/Components/src/PersistentState/RestoringComponentStateSubscription.cs b/src/Components/Components/src/PersistentState/RestoringComponentStateSubscription.cs new file mode 100644 index 000000000000..48b2319ba7cf --- /dev/null +++ b/src/Components/Components/src/PersistentState/RestoringComponentStateSubscription.cs @@ -0,0 +1,32 @@ +// 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 subscription to component state restoration events. Dispose to unsubscribe. +/// +public readonly struct RestoringComponentStateSubscription : IDisposable +{ + private readonly List _registeredRestoringCallbacks; + private readonly RestoreComponentStateRegistration? _registration; + + internal RestoringComponentStateSubscription( + List registeredRestoringCallbacks, + RestoreComponentStateRegistration registration) + { + _registeredRestoringCallbacks = registeredRestoringCallbacks; + _registration = registration; + } + + /// + /// Unsubscribes from state restoration events. + /// + public void Dispose() + { + if (_registration.HasValue) + { + _registeredRestoringCallbacks?.Remove(_registration.Value); + } + } +} diff --git a/src/Components/Components/src/PersistentStateAttribute.cs b/src/Components/Components/src/PersistentStateAttribute.cs index 79fd4f9ac838..cd8de101bda9 100644 --- a/src/Components/Components/src/PersistentStateAttribute.cs +++ b/src/Components/Components/src/PersistentStateAttribute.cs @@ -10,4 +10,21 @@ namespace Microsoft.AspNetCore.Components; [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class PersistentStateAttribute : CascadingParameterAttributeBase { + /// + /// Gets or sets the behavior to use when restoring data. + /// + /// + /// By default it always restores the value on all situations. + /// Use to skip restoring the initial value + /// when the host starts up. + /// Use to skip restoring the last value captured + /// the last time the current host was shut down. + /// + public RestoreBehavior RestoreBehavior { get; set; } = RestoreBehavior.Default; + + /// + /// Gets or sets a value whether the component wants to receive updates to the parameter + /// beyond the initial value provided during initialization. + /// + public bool AllowUpdates { get; set; } } diff --git a/src/Components/Components/src/PersistentStateValueProvider.cs b/src/Components/Components/src/PersistentStateValueProvider.cs deleted file mode 100644 index 18589473d4e3..000000000000 --- a/src/Components/Components/src/PersistentStateValueProvider.cs +++ /dev/null @@ -1,382 +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.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.HotReload; -using Microsoft.AspNetCore.Components.Reflection; -using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Internal; - -namespace Microsoft.AspNetCore.Components.Infrastructure; - -internal sealed class PersistentStateValueProvider(PersistentComponentState state, IServiceProvider serviceProvider) : ICascadingValueSupplier -{ - private static readonly ConcurrentDictionary<(string, string, string), byte[]> _keyCache = new(); - private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); - private static readonly ConcurrentDictionary _serializerCache = new(); - - static PersistentStateValueProvider() - { - if (HotReloadManager.Default.MetadataUpdateSupported) - { - HotReloadManager.Default.OnDeltaApplied += ClearCaches; - } - } - - private static void ClearCaches() - { - _propertyGetterCache.Clear(); - _serializerCache.Clear(); - } - - 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 PersistentStateAttribute; - - [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); - - // Try to get a custom serializer for this type first - var customSerializer = _serializerCache.GetOrAdd(parameterInfo.PropertyType, SerializerFactory); - - if (customSerializer != null) - { - if (state.TryTakeBytes(storageKey, out var data)) - { - var sequence = new ReadOnlySequence(data!); - return customSerializer.Restore(parameterInfo.PropertyType, sequence); - } - return null; - } - - // Fallback to JSON serialization - return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; - } - - [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; - - // Resolve serializer outside the lambda - var customSerializer = _serializerCache.GetOrAdd(propertyType, SerializerFactory); - - _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; - } - - if (customSerializer != null) - { - using var writer = new PooledArrayBufferWriter(); - customSerializer.Persist(propertyType, property, writer); - state.PersistAsBytes(storageKey, writer.WrittenMemory.ToArray()); - return Task.CompletedTask; - } - - // Fallback to JSON serialization - state.PersistAsJson(storageKey, property, propertyType); - return Task.CompletedTask; - }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); - } - - private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) - { - return _propertyGetterCache.GetOrAdd((type, propertyName), PropertyGetterFactory); - } - - private IPersistentComponentStateSerializer? SerializerFactory(Type type) - { - var serializerType = typeof(PersistentComponentStateSerializer<>).MakeGenericType(type); - var serializer = serviceProvider.GetService(serializerType); - - // The generic class now inherits from the internal interface, so we can cast directly - return serializer as IPersistentComponentStateSerializer; - } - - [UnconditionalSuppressMessage( - "Trimming", - "IL2077:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The source field does not have matching annotations.", - 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) - { - var componentKey = componentState.GetComponentKey(); - if (componentKey != null && IsSerializableKey(componentKey)) - { - return componentKey; - } - - return null; - } - - private static string GetComponentType(ComponentState componentState) => componentState.Component.GetType().FullName!; - - private static string GetParentComponentType(ComponentState componentState) - { - if (componentState.ParentComponentState == null) - { - return ""; - } - if (componentState.ParentComponentState.Component == null) - { - return ""; - } - - if (componentState.ParentComponentState.ParentComponentState != null) - { - var renderer = componentState.Renderer; - var parentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.Component); - var grandParentRenderMode = renderer.GetComponentRenderMode(componentState.ParentComponentState.ParentComponentState.Component); - if (parentRenderMode != grandParentRenderMode) - { - // This is the case when EndpointHtmlRenderer introduces an SSRRenderBoundary component. - // We want to return "" because the SSRRenderBoundary component is not a real component - // and won't appear on the component tree in the WebAssemblyRenderer and RemoteRenderer - // interactive scenarios. - return ""; - } - } - - return 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; - } - - /// - /// Serializes using the provided and persists it under the given . - /// - /// The type. - /// The key to use to persist the state. - /// The instance to persist. - /// The custom serializer to use for serialization. - internal void PersistAsync(string key, TValue instance, PersistentComponentStateSerializer serializer) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(serializer); - - using var writer = new PooledArrayBufferWriter(); - serializer.Persist(instance, writer); - state.PersistAsBytes(key, writer.WrittenMemory.ToArray()); - } - - /// - /// Tries to retrieve the persisted state with the given and deserializes it using the provided into an - /// instance of type . - /// When the key is present, the state is successfully returned via - /// and removed from the . - /// - /// The key used to persist the instance. - /// The custom serializer to use for deserialization. - /// The persisted instance. - /// true if the state was found; false otherwise. - internal bool TryTake(string key, PersistentComponentStateSerializer serializer, [MaybeNullWhen(false)] out TValue instance) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(serializer); - - if (state.TryTakeBytes(key, out var data)) - { - var sequence = new ReadOnlySequence(data!); - instance = serializer.Restore(sequence); - return true; - } - else - { - instance = default; - return false; - } - } -} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 442ad26d73a2..2da7d492e710 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,6 +1,27 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties) -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.RestoreStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RestoreContext! context) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(System.Action! callback, Microsoft.AspNetCore.Components.RestoreOptions options) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentStateAttribute.AllowUpdates.get -> bool +Microsoft.AspNetCore.Components.PersistentStateAttribute.AllowUpdates.set -> void +Microsoft.AspNetCore.Components.PersistentStateAttribute.RestoreBehavior.get -> Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.PersistentStateAttribute.RestoreBehavior.set -> void +Microsoft.AspNetCore.Components.Rendering.ComponentState.Renderer.get -> Microsoft.AspNetCore.Components.RenderTree.Renderer! Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList? properties = null) -> void +Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.RestoreBehavior.Default = 0 -> Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.RestoreBehavior.SkipInitialValue = 1 -> Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.RestoreBehavior.SkipLastSnapshot = 2 -> Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.RestoreContext +Microsoft.AspNetCore.Components.RestoreOptions +Microsoft.AspNetCore.Components.RestoreOptions.AllowUpdates.get -> bool +Microsoft.AspNetCore.Components.RestoreOptions.AllowUpdates.init -> void +Microsoft.AspNetCore.Components.RestoreOptions.RestoreBehavior.get -> Microsoft.AspNetCore.Components.RestoreBehavior +Microsoft.AspNetCore.Components.RestoreOptions.RestoreBehavior.init -> void +Microsoft.AspNetCore.Components.RestoreOptions.RestoreOptions() -> void +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.Dispose() -> void +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.RestoringComponentStateSubscription() -> void Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type? Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions @@ -24,6 +45,9 @@ abstract Microsoft.AspNetCore.Components.PersistentComponentStateSerializer.R static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.RestoreContext.InitialValue.get -> Microsoft.AspNetCore.Components.RestoreContext! +static Microsoft.AspNetCore.Components.RestoreContext.LastSnapshot.get -> Microsoft.AspNetCore.Components.RestoreContext! +static Microsoft.AspNetCore.Components.RestoreContext.ValueUpdate.get -> Microsoft.AspNetCore.Components.RestoreContext! virtual Microsoft.AspNetCore.Components.OwningComponentBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask static Microsoft.AspNetCore.Components.Infrastructure.PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object? diff --git a/src/Components/Components/src/Reflection/PropertyGetter.cs b/src/Components/Components/src/Reflection/PropertyGetter.cs index 03fa596cbc5c..c458feda0af7 100644 --- a/src/Components/Components/src/Reflection/PropertyGetter.cs +++ b/src/Components/Components/src/Reflection/PropertyGetter.cs @@ -14,12 +14,16 @@ internal sealed class PropertyGetter private readonly Func _GetterDelegate; + public PropertyInfo PropertyInfo { get; } + [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) { + PropertyInfo = property; + if (property.GetMethod == null) { throw new InvalidOperationException("Cannot provide a value for property " + diff --git a/src/Components/Components/src/RenderTree/RendererInfo.cs b/src/Components/Components/src/RenderTree/RendererInfo.cs index 9c401b5ce006..7f006550acb8 100644 --- a/src/Components/Components/src/RenderTree/RendererInfo.cs +++ b/src/Components/Components/src/RenderTree/RendererInfo.cs @@ -1,6 +1,8 @@ // 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; /// @@ -8,6 +10,7 @@ namespace Microsoft.AspNetCore.Components; /// /// The name of the platform. /// A flag to indicate if the platform is interactive. +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class RendererInfo(string rendererName, bool isInteractive) { /// @@ -19,4 +22,6 @@ public sealed class RendererInfo(string rendererName, bool isInteractive) /// Gets a flag to indicate if the platform is interactive. /// public bool IsInteractive { get; } = isInteractive; + + private string GetDebuggerDisplay() => $"Name: {Name}, Interactive: {IsInteractive}"; } diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index c5bddeb88d2d..d1b42a80b153 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -93,7 +93,12 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, internal RenderTreeBuilder CurrentRenderTree { get; set; } - internal Renderer Renderer => _renderer; + /// + /// Gets the instance used to render the output. + /// + /// The instance is accessible to derived classes and classes within the + /// same assembly. It provides rendering functionality that may be used for custom rendering logic. + protected internal Renderer Renderer => _renderer; internal void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception? renderFragmentException) { @@ -356,6 +361,9 @@ internal ValueTask DisposeInBatchAsync(RenderBatchBuilder batchBuilder) for (var i = 0; i < frames.Count; i++) { ref var currentFrame = ref frames.Array[i]; + + Debug.Assert(currentFrame.FrameType != RenderTreeFrameType.Component || currentFrame.Component != null, "GetComponentKey is being invoked too soon, ComponentState is not fully constructed."); + if (currentFrame.FrameType != RenderTreeFrameType.Component || !ReferenceEquals(Component, currentFrame.Component)) { diff --git a/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs b/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs index 2b237fb2431b..d8c1311fec9a 100644 --- a/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs +++ b/src/Components/Components/test/IPersistentComponentStateSerializerTests.cs @@ -6,74 +6,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.AspNetCore.Components; - -public class IPersistentComponentStateSerializerTests -{ - [Fact] - public void PersistAsync_CanUseCustomSerializer() - { - // Arrange - var currentState = new Dictionary(); - var state = new PersistentComponentState(currentState, []); - var serviceProvider = new ServiceCollection().BuildServiceProvider(); - var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider); - var customSerializer = new TestStringSerializer(); - var testValue = "Hello, World!"; - - state.PersistingState = true; - - // Act - stateValueProvider.PersistAsync("test-key", testValue, customSerializer); - - // Assert - state.PersistingState = false; - - // Simulate the state transfer that happens between persist and restore phases - var newState = new PersistentComponentState(new Dictionary(), []); - newState.InitializeExistingState(currentState); - var newStateValueProvider = new PersistentStateValueProvider(newState, serviceProvider); - - Assert.True(newStateValueProvider.TryTake("test-key", customSerializer, out var retrievedValue)); - Assert.Equal(testValue, retrievedValue); - } - - [Fact] - public void TryTake_CanUseCustomSerializer() - { - // Arrange - var customData = "Custom Data"; - var customBytes = Encoding.UTF8.GetBytes(customData); - var existingState = new Dictionary { { "test-key", customBytes } }; - - var state = new PersistentComponentState(new Dictionary(), []); - state.InitializeExistingState(existingState); - - var serviceProvider = new ServiceCollection().BuildServiceProvider(); - var stateValueProvider = new PersistentStateValueProvider(state, serviceProvider); - var customSerializer = new TestStringSerializer(); - - // Act - var success = stateValueProvider.TryTake("test-key", customSerializer, out var retrievedValue); - - // Assert - Assert.True(success); - Assert.Equal(customData, retrievedValue); - } - - private class TestStringSerializer : PersistentComponentStateSerializer - { - public override void Persist(string value, IBufferWriter writer) - { - var bytes = Encoding.UTF8.GetBytes(value); - writer.Write(bytes); - } - - public override string Restore(ReadOnlySequence data) - { - var bytes = data.ToArray(); - return Encoding.UTF8.GetString(bytes); - } - } -} \ No newline at end of file diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 4e5708c10f4d..a8857107a782 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -120,6 +120,8 @@ public async Task PersistStateAsync_PersistsRegistry() persistenceManager.SetPlatformRenderMode(new TestRenderMode()); var testStore = new TestStore([]); + await persistenceManager.RestoreStateAsync(new TestStore([]), RestoreContext.InitialValue); + // Act await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); @@ -351,6 +353,105 @@ public async Task PersistStateAsync_InvokesAllCallbacksEvenIfACallbackIsRemovedA Assert.Equal(4, executionSequence.Count); } + [Fact] + public async Task RestoreStateAsync_ValidatesOnlySupportUpdatesWhenRestoreContextValueUpdate() + { + var store = new TestStore([]); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // First restore should work (initialize state) + await persistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue); + + // Second restore with non-ValueUpdate context should throw + var exception = await Assert.ThrowsAsync(() => + persistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue)); + + Assert.Equal("State already initialized.", exception.Message); + } + + [Fact] + public async Task RestoreStateAsync_ValidatesRegisteredUpdateCallbacksAreInvokedOnValueUpdates() + { + var store = new TestStore([]); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + var callbackInvoked = false; + + // First restore to initialize state + await persistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue); + + // Register a callback for value updates through the state object + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.Default, AllowUpdates = true }; + persistenceManager.State.RegisterOnRestoring(() => { callbackInvoked = true; }, options); + // RegisterOnRestoring will invoke the callback immediately, so we reset it + callbackInvoked = false; + + // Second restore with ValueUpdate context should invoke callbacks + await persistenceManager.RestoreStateAsync(store, RestoreContext.ValueUpdate); + + Assert.True(callbackInvoked); + } + + [Theory] + [MemberData(nameof(RestoreContexts))] + public async Task RestoreStateAsync_RestoresServicesForDifferentContexts(RestoreContext context) + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var (serviceProvider, persistenceManager) = CreatePersistenceManagerWithService(componentRenderMode); + + var service = serviceProvider.GetRequiredService(); + service.TestProperty = "Test Value"; + + var store = new TestStore([]); + await persistenceManager.RestoreStateAsync(store, RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(store, new TestRenderer()); + + // Act - Create new service provider and persistence manager + var (newServiceProvider, newPersistenceManager) = CreatePersistenceManagerWithService(componentRenderMode); + + // Restore with the specified context + await newPersistenceManager.RestoreStateAsync(store, context); + + // Assert + var restoredService = newServiceProvider.GetRequiredService(); + Assert.Equal("Test Value", restoredService.TestProperty); + + static (IServiceProvider serviceProvider, ComponentStatePersistenceManager persistenceManager) CreatePersistenceManagerWithService(IComponentRenderMode renderMode) + { + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(renderMode) + .BuildServiceProvider(); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider); + persistenceManager.SetPlatformRenderMode(renderMode); + + return (serviceProvider, persistenceManager); + } + } + + public static IEnumerable RestoreContexts() + { + yield return new object[] { RestoreContext.InitialValue }; + yield return new object[] { RestoreContext.ValueUpdate }; + yield return new object[] { RestoreContext.LastSnapshot }; + } + + private class TestPersistentService + { + [PersistentState(AllowUpdates = true)] + public string TestProperty { get; set; } = string.Empty; + } + private class TestRenderer : Renderer { public TestRenderer() : base(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) diff --git a/src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs b/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs similarity index 52% rename from src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs rename to src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs index 5c13d647e3a9..a346e84645f8 100644 --- a/src/Components/Components/test/PersistentState/ComponentApplicationStateTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentComponentStateTest.cs @@ -2,23 +2,27 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; namespace Microsoft.AspNetCore.Components; -public class ComponentApplicationStateTest +public class PersistentComponentStateTest { [Fact] public void InitializeExistingState_SetupsState() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), []); + var applicationState = new PersistentComponentState(new Dictionary(), [], []); var existingState = new Dictionary { ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 }) }; // Act - applicationState.InitializeExistingState(existingState); + applicationState.InitializeExistingState(existingState, RestoreContext.InitialValue); // Assert Assert.True(applicationState.TryTakeFromJson("MyState", out var existing)); @@ -29,16 +33,16 @@ public void InitializeExistingState_SetupsState() public void InitializeExistingState_ThrowsIfAlreadyInitialized() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), []); + var applicationState = new PersistentComponentState(new Dictionary(), [], []); var existingState = new Dictionary { ["MyState"] = new byte[] { 1, 2, 3, 4 } }; - applicationState.InitializeExistingState(existingState); + applicationState.InitializeExistingState(existingState, RestoreContext.InitialValue); // Act & Assert - Assert.Throws(() => applicationState.InitializeExistingState(existingState)); + Assert.Throws(() => applicationState.InitializeExistingState(existingState, null)); } [Fact] @@ -46,11 +50,11 @@ public void RegisterOnPersisting_ThrowsIfCalledDuringOnPersisting() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, []) + var applicationState = new PersistentComponentState(currentState, [], []) { PersistingState = true }; - + // Act & Assert Assert.Throws(() => applicationState.RegisterOnPersisting(() => Task.CompletedTask)); } @@ -59,14 +63,14 @@ public void RegisterOnPersisting_ThrowsIfCalledDuringOnPersisting() public void TryRetrieveState_ReturnsStateWhenItExists() { // Arrange - var applicationState = new PersistentComponentState(new Dictionary(), []); + var applicationState = new PersistentComponentState(new Dictionary(), [], []); var existingState = new Dictionary { ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(new byte[] { 1, 2, 3, 4 }) }; // Act - applicationState.InitializeExistingState(existingState); + applicationState.InitializeExistingState(existingState, RestoreContext.InitialValue); // Assert Assert.True(applicationState.TryTakeFromJson("MyState", out var existing)); @@ -79,7 +83,7 @@ public void PersistState_SavesDataToTheStoreAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, []) + var applicationState = new PersistentComponentState(currentState, [], []) { PersistingState = true }; @@ -98,7 +102,7 @@ public void PersistState_ThrowsForDuplicateKeys() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, []) + var applicationState = new PersistentComponentState(currentState, [], []) { PersistingState = true }; @@ -115,7 +119,7 @@ public void PersistAsJson_SerializesTheDataToJsonAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, []) + var applicationState = new PersistentComponentState(currentState, [], []) { PersistingState = true }; @@ -134,7 +138,7 @@ public void PersistAsJson_NullValueAsync() { // Arrange var currentState = new Dictionary(); - var applicationState = new PersistentComponentState(currentState, []) + var applicationState = new PersistentComponentState(currentState, [], []) { PersistingState = true }; @@ -154,9 +158,9 @@ 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(), []); + var applicationState = new PersistentComponentState(new Dictionary(), [], []); - applicationState.InitializeExistingState(existingState); + applicationState.InitializeExistingState(existingState, RestoreContext.InitialValue); // Act Assert.True(applicationState.TryTakeFromJson("MyState", out var stored)); @@ -172,9 +176,9 @@ public void TryRetrieveFromJson_NullValue() // Arrange var serialized = JsonSerializer.SerializeToUtf8Bytes(null); var existingState = new Dictionary() { ["MyState"] = serialized }; - var applicationState = new PersistentComponentState(new Dictionary(), []); + var applicationState = new PersistentComponentState(new Dictionary(), [], []); - applicationState.InitializeExistingState(existingState); + applicationState.InitializeExistingState(existingState, RestoreContext.InitialValue); // Act Assert.True(applicationState.TryTakeFromJson("MyState", out var stored)); @@ -183,4 +187,101 @@ public void TryRetrieveFromJson_NullValue() Assert.Null(stored); Assert.False(applicationState.TryTakeFromJson("MyState", out _)); } + + [Fact] + public void RegisterOnRestoring_InvokesCallbackWhenShouldRestoreMatches() + { + // Arrange + var currentState = new Dictionary(); + var callbacks = new List(); + var applicationState = new PersistentComponentState(currentState, [], callbacks); + applicationState.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + + var callbackInvoked = false; + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.Default, AllowUpdates = false }; + + // Act + var subscription = applicationState.RegisterOnRestoring(() => { callbackInvoked = true; }, options); + + // Assert + Assert.True(callbackInvoked); + } + + [Fact] + public void RegisterOnRestoring_DoesNotInvokeCallbackWhenShouldRestoreDoesNotMatch() + { + // Arrange + var currentState = new Dictionary(); + var callbacks = new List(); + var applicationState = new PersistentComponentState(currentState, [], callbacks); + applicationState.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + + var callbackInvoked = false; + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.SkipInitialValue, AllowUpdates = false }; + + // Act + var subscription = applicationState.RegisterOnRestoring(() => { callbackInvoked = true; }, options); + + // Assert + Assert.False(callbackInvoked); + } + + [Fact] + public void RegisterOnRestoring_ReturnsDefaultSubscriptionWhenNotAllowingUpdates() + { + // Arrange + var currentState = new Dictionary(); + var callbacks = new List(); + var applicationState = new PersistentComponentState(currentState, [], callbacks); + applicationState.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.Default, AllowUpdates = false }; + + // Act + var subscription = applicationState.RegisterOnRestoring(() => { }, options); + + // Assert + Assert.Equal(default, subscription); + Assert.Empty(callbacks); + } + + [Fact] + public void RegisterOnRestoring_ReturnsRestoringSubscriptionWhenAllowsUpdates() + { + // Arrange + var currentState = new Dictionary(); + var callbacks = new List(); + var applicationState = new PersistentComponentState(currentState, [], callbacks); + applicationState.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.Default, AllowUpdates = true }; + + // Act + var subscription = applicationState.RegisterOnRestoring(() => { }, options); + + // Assert + Assert.NotEqual(default, subscription); + Assert.Single(callbacks); + } + + [Fact] + public void RegisterOnRestoring_SubscriptionCanBeDisposed() + { + // Arrange + var currentState = new Dictionary(); + var callbacks = new List(); + var applicationState = new PersistentComponentState(currentState, [], callbacks); + applicationState.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.Default, AllowUpdates = true }; + var subscription = applicationState.RegisterOnRestoring(() => { }, options); + + Assert.Single(callbacks); + + // Act + subscription.Dispose(); + + // Assert + Assert.Empty(callbacks); + } } diff --git a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs index aa224adde065..0fae4c1ebaf4 100644 --- a/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs +++ b/src/Components/Components/test/PersistentState/PersistentServicesRegistryTest.cs @@ -33,8 +33,10 @@ public async Task PersistStateAsync_PersistsServiceProperties() var registry = persistenceManager.ServicesRegistry; + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); - var componentState = new PersistentComponentState(testStore.State, []); + var componentState = new PersistentComponentState(testStore.State, [], []); var secondScope = serviceProvider.CreateAsyncScope().ServiceProvider; var secondManager = new ComponentStatePersistenceManager( @@ -74,6 +76,8 @@ public async Task PersistStateAsync_PersistsBaseServiceProperties() persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); var testStore = new TestStore(new Dictionary()); + await persistenceManagerOne.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; @@ -140,6 +144,7 @@ public async Task PersistStateAsync_DoesNotPersistNullServiceProperties() scope); persistenceManager.SetPlatformRenderMode(componentRenderMode); var testStore = new TestStore(new Dictionary()); + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); // Act await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); @@ -210,6 +215,8 @@ public async Task PersistStateAsync_RestoresStateForPersistedRegistrations() persistenceManagerOne.SetPlatformRenderMode(componentRenderMode); var testStore = new TestStore(new Dictionary()); + await persistenceManagerOne.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManagerOne.PersistStateAsync(testStore, new TestRenderer()); var scopeTwo = serviceProviderTwo.CreateAsyncScope().ServiceProvider; @@ -217,7 +224,7 @@ public async Task PersistStateAsync_RestoresStateForPersistedRegistrations() NullLogger.Instance, scopeTwo); - await persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State)); + await persistenceManagerTwo.RestoreStateAsync(new TestStore(testStore.State), RestoreContext.InitialValue); // Assert var derivedTwo = scopeTwo.GetRequiredService() as DerivedTwo; @@ -324,6 +331,8 @@ public async Task PersistStateAsync_PersistsMultipleServicesWithDifferentStates( persistenceManager.SetPlatformRenderMode(componentRenderMode); var testStore = new TestStore(new Dictionary()); + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); var secondScope = serviceProvider.CreateAsyncScope().ServiceProvider; @@ -368,6 +377,7 @@ public async Task PersistStateAsync_PersistsServiceWithComplexState() scope); persistenceManager.SetPlatformRenderMode(componentRenderMode); var testStore = new TestStore(new Dictionary()); + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); @@ -389,6 +399,149 @@ public async Task PersistStateAsync_PersistsServiceWithComplexState() } } + [Fact] + public async Task PersistStateAsync_RespectsSkipInitialValueBehavior() + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var service = scope.GetRequiredService(); + service.SkipInitialValueProperty = "TestValue"; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + // Act - Restore with InitialValue context + var initialValueScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var initialValueManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + initialValueScope); + + await initialValueManager.RestoreStateAsync(new TestStore(testStore.State), RestoreContext.InitialValue); + var restoredService = initialValueScope.GetRequiredService(); + + // Assert - Property should be null because it was skipped during InitialValue restore + Assert.Null(restoredService.SkipInitialValueProperty); + } + + [Fact] + public async Task PersistStateAsync_RespectsSkipLastSnapshotBehavior() + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var service = scope.GetRequiredService(); + service.SkipLastSnapshotProperty = "TestValue"; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var testStore = new TestStore(new Dictionary()); + + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(testStore, new TestRenderer()); + + // Act - Restore with LastSnapshot context + var lastSnapshotScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var lastSnapshotManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + lastSnapshotScope); + + await lastSnapshotManager.RestoreStateAsync(new TestStore(testStore.State), RestoreContext.LastSnapshot); + var restoredService = lastSnapshotScope.GetRequiredService(); + + // Assert - Property should be null because it was skipped during LastSnapshot restore + Assert.Null(restoredService.SkipLastSnapshotProperty); + } + + [Fact] + public async Task PersistStateAsync_RespectsAllowUpdatesBehavior() + { + // Arrange + var componentRenderMode = new TestRenderMode(); + var serviceProvider = new ServiceCollection() + .AddScoped() + .AddPersistentService(componentRenderMode) + .BuildServiceProvider(); + + var scope = serviceProvider.CreateAsyncScope().ServiceProvider; + var service = scope.GetRequiredService(); + service.AllowUpdatesProperty = "InitialValue"; + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + scope); + persistenceManager.SetPlatformRenderMode(componentRenderMode); + var initialStore = new TestStore(new Dictionary()); + + await persistenceManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await persistenceManager.PersistStateAsync(initialStore, new TestRenderer()); + + // Create updated state + var updatedScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var updatedService = updatedScope.GetRequiredService(); + updatedService.AllowUpdatesProperty = "UpdatedValue"; + + var updatedManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + updatedScope); + var updatedStore = new TestStore(new Dictionary()); + + await updatedManager.RestoreStateAsync(new TestStore(new Dictionary()), RestoreContext.InitialValue); + await updatedManager.PersistStateAsync(updatedStore, new TestRenderer()); + + // Act - First restore with InitialValue, then update with ValueUpdate context + var targetScope = serviceProvider.CreateAsyncScope().ServiceProvider; + var targetManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + targetScope); + + await targetManager.RestoreStateAsync(new TestStore(initialStore.State), RestoreContext.InitialValue); + var restoredService = targetScope.GetRequiredService(); + Assert.Equal("InitialValue", restoredService.AllowUpdatesProperty); + + // Update with ValueUpdate context + await targetManager.RestoreStateAsync(new TestStore(updatedStore.State), RestoreContext.ValueUpdate); + + // Assert - Property should be updated because AllowUpdates is true + Assert.Equal("UpdatedValue", restoredService.AllowUpdatesProperty); + } + + private class ServiceWithSkipInitialValue + { + [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)] + public string SkipInitialValueProperty { get; set; } + } + + private class ServiceWithSkipLastSnapshot + { + [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)] + public string SkipLastSnapshotProperty { get; set; } + } + + private class ServiceWithAllowUpdates + { + [PersistentState(AllowUpdates = true)] + public string AllowUpdatesProperty { get; set; } + } + private class AnotherTestService { [PersistentState] diff --git a/src/Components/Components/test/PersistentState/RestoreContextTest.cs b/src/Components/Components/test/PersistentState/RestoreContextTest.cs new file mode 100644 index 000000000000..c4ca50e65fd7 --- /dev/null +++ b/src/Components/Components/test/PersistentState/RestoreContextTest.cs @@ -0,0 +1,105 @@ +// 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; + +public class RestoreContextTest +{ + [Theory] + [InlineData(RestoreBehavior.Default)] + [InlineData(RestoreBehavior.SkipLastSnapshot)] + public void ShouldRestore_InitialValueContext_WithDefaultOrSkipLastSnapshot(RestoreBehavior behavior) + { + var options = new RestoreOptions { RestoreBehavior = behavior }; + + var result = RestoreContext.InitialValue.ShouldRestore(options); + + Assert.True(result); + } + + [Fact] + public void ShouldRestore_InitialValueContext_WithSkipInitialValue() + { + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.SkipInitialValue }; + + var result = RestoreContext.InitialValue.ShouldRestore(options); + + Assert.False(result); + } + + [Theory] + [InlineData(RestoreBehavior.Default, true, true)] + [InlineData(RestoreBehavior.Default, false, true)] + [InlineData(RestoreBehavior.SkipInitialValue, true, false)] + [InlineData(RestoreBehavior.SkipInitialValue, false, false)] + public void ShouldRestore_InitialValueContext_ShouldRestore_IsIndependentOfAllowUpdates(RestoreBehavior behavior, bool allowUpdates, bool expectedResult) + { + var options = new RestoreOptions { RestoreBehavior = behavior, AllowUpdates = allowUpdates }; + + var result = RestoreContext.InitialValue.ShouldRestore(options); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(RestoreBehavior.Default)] + [InlineData(RestoreBehavior.SkipInitialValue)] + public void ShouldRestore_LastSnapshotContext_WithDefaultOrSkipInitialValue(RestoreBehavior behavior) + { + var options = new RestoreOptions { RestoreBehavior = behavior }; + + var result = RestoreContext.LastSnapshot.ShouldRestore(options); + + Assert.True(result); + } + + [Fact] + public void ShouldRestore_LastSnapshotContext_WithSkipLastSnapshot() + { + var options = new RestoreOptions { RestoreBehavior = RestoreBehavior.SkipLastSnapshot }; + + var result = RestoreContext.LastSnapshot.ShouldRestore(options); + + Assert.False(result); + } + + [Theory] + [InlineData(RestoreBehavior.Default, true, true)] + [InlineData(RestoreBehavior.Default, false, true)] + [InlineData(RestoreBehavior.SkipLastSnapshot, true, false)] + [InlineData(RestoreBehavior.SkipLastSnapshot, false, false)] + public void ShouldRestore_LastSnapshotContext_ShouldRestore_IsIndependentOfAllowUpdates(RestoreBehavior behavior, bool allowUpdates, bool expectedResult) + { + var options = new RestoreOptions { RestoreBehavior = behavior, AllowUpdates = allowUpdates }; + + var result = RestoreContext.LastSnapshot.ShouldRestore(options); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData(RestoreBehavior.Default)] + [InlineData(RestoreBehavior.SkipInitialValue)] + [InlineData(RestoreBehavior.SkipLastSnapshot)] + public void ShouldRestore_ValueUpdateContext_WithoutAllowUpdates(RestoreBehavior behavior) + { + var options = new RestoreOptions { RestoreBehavior = behavior }; + + var result = RestoreContext.ValueUpdate.ShouldRestore(options); + + Assert.False(result); + } + + [Theory] + [InlineData(RestoreBehavior.Default)] + [InlineData(RestoreBehavior.SkipInitialValue)] + [InlineData(RestoreBehavior.SkipLastSnapshot)] + public void ShouldRestore_ValueUpdateContext_WithAllowUpdates(RestoreBehavior behavior) + { + var options = new RestoreOptions { AllowUpdates = true, RestoreBehavior = behavior }; + + var result = RestoreContext.ValueUpdate.ShouldRestore(options); + + Assert.True(result); + } +} diff --git a/src/Components/Components/test/PersistentStateValueProviderKeyResolverTests.cs b/src/Components/Components/test/PersistentStateValueProviderKeyResolverTests.cs new file mode 100644 index 000000000000..2af1c97ce0d4 --- /dev/null +++ b/src/Components/Components/test/PersistentStateValueProviderKeyResolverTests.cs @@ -0,0 +1,428 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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 PersistentStateValueProviderKeyResolverTests +{ + 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) }, + }; + + public static TheoryData InvalidKeyTypesData => new TheoryData + { + { new object(), new object() }, + { new TestComponent(), new TestComponent() } + }; + + [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()); + + persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var parentComponent = new ParentComponent(); + + var componentStates = CreateComponentState(renderer, [(component1, "key1"), (component2, "key2")], parentComponent); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.NotEmpty(store.State); + + // Check that keys are different for different component instances with different keys + var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); + var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName); + + Assert.NotEqual(key1, key2); + + // Verify both states were persisted correctly + var newState = new PersistentComponentState(new Dictionary(), [], []); + newState.InitializeExistingState(store.State, null); + + Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue1)); + Assert.Equal("state1", retrievedValue1); + + Assert.True(newState.TryTakeFromJson(key2, out var retrievedValue2)); + Assert.Equal("state2", retrievedValue2); + } + + [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()); + + await persistenceManager.RestoreStateAsync(new TestStore([]), RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var parentComponent = new ParentComponent(); + + var componentStates = CreateComponentState(renderer, [(component1, componentKey1), (component2, componentKey2)], parentComponent); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + Assert.NotEmpty(store.State); + + // Check that keys are different for different component instances with different keys + var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); + var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName); + + Assert.NotEqual(key1, key2); + + // Verify both states were persisted correctly + var newState = new PersistentComponentState(new Dictionary(), [], []); + newState.InitializeExistingState(store.State, null); + + Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue1)); + Assert.Equal("state1", retrievedValue1); + + Assert.True(newState.TryTakeFromJson(key2, out var retrievedValue2)); + Assert.Equal("state2", retrievedValue2); + } + + [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()); + + persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], null); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Check that keys are the same for multiple component instances without keys + var key1 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); + var key2 = PersistentStateValueProviderKeyResolver.ComputeKey(componentState2, cascadingParameterInfo.PropertyName); + + Assert.Equal(key1, key2); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList(); + Assert.Single(messages); + } + + private static (ILogger logger, TestSink testLoggerSink) CreateTestLogger() + { + var testLoggerSink = new TestSink(); + var loggerFactory = new TestLoggerFactory(testLoggerSink, enabled: true); + var logger = loggerFactory.CreateLogger(); + 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()); + + persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var parentComponent = new TestComponent(); + + var componentStates = CreateComponentState(renderer, [(component1, null), (component2, null)], parentComponent); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList(); + Assert.Single(messages); + } + + [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()); + + persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var parentComponent = new ParentComponent(); + + var componentStates = CreateComponentState(renderer, [(component1, "key1"), (component2, "key1")], parentComponent); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList(); + Assert.Single(messages); + } + + [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()); + + persistenceManager.State.InitializeExistingState(state, RestoreContext.InitialValue); + + var renderer = new TestRenderer(); + var component1 = new TestComponent { State = "state1" }; + var component2 = new TestComponent { State = "state2" }; + + var parentComponent = new ParentComponent(); + + var componentStates = CreateComponentState(renderer, [(component1, componentKeyType1), (component2, componentKeyType2)], parentComponent); + var componentState1 = componentStates[0]; + var componentState2 = componentStates[1]; + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + var provider = new PersistentStateValueProvider(persistenceManager.State, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); + + provider.Subscribe(componentState1, cascadingParameterInfo); + provider.Subscribe(componentState2, cascadingParameterInfo); + + // Act + await persistenceManager.PersistStateAsync(store, renderer); + + // Assert + var messages = sink.Writes.Where(x => x.LogLevel >= LogLevel.Warning).ToList(); + Assert.Single(messages); + } + + private static CascadingParameterInfo CreateCascadingParameterInfo(string propertyName, Type propertyType) + { + return new CascadingParameterInfo( + new PersistentStateAttribute(), + propertyName, + propertyType); + } + + private static List CreateComponentState( + TestRenderer renderer, + List<(IComponent, object)> components, + IComponent 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) + { + // Open component based on the actual component type + if (component is TestComponent) + { + currentRenderTree.OpenComponent(0); + } + else if (component is ValueTypeTestComponent) + { + currentRenderTree.OpenComponent(0); + } + else + { + 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 + { + [PersistentState] + public string State { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class ValueTypeTestComponent : IComponent + { + [PersistentState] + public int IntValue { get; set; } + + [PersistentState] + public int? NullableIntValue { get; set; } + + [PersistentState] + public (string, int) TupleValue { get; set; } + + [PersistentState] + public (string, int)? NullableTupleValue { 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/Components/test/PersistentStateValueProviderTests.cs b/src/Components/Components/test/PersistentStateValueProviderTests.cs index 6f8fc48e968f..65413bd335da 100644 --- a/src/Components/Components/test/PersistentStateValueProviderTests.cs +++ b/src/Components/Components/test/PersistentStateValueProviderTests.cs @@ -1,10 +1,6 @@ // 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.Linq; -using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; @@ -12,9 +8,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Testing; namespace Microsoft.AspNetCore.Components; + public class PersistentStateValueProviderTests { [Fact] @@ -23,12 +19,12 @@ public void CanRestoreState_ForComponentWithProperties() // Arrange var state = new PersistentComponentState( new Dictionary(), + [], []); - var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); + var provider = new PersistentStateValueProvider(state, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); 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)); @@ -39,6 +35,8 @@ public void CanRestoreState_ForComponentWithProperties() (componentState, cascadingParameterInfo.PropertyName, "state") }); + provider.Subscribe(componentState, cascadingParameterInfo); + // Act var result = provider.GetCurrentValue(componentState, cascadingParameterInfo); @@ -52,8 +50,12 @@ public void Subscribe_RegistersPersistenceCallback() // Arrange var state = new PersistentComponentState( new Dictionary(), + [], []); - var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); + + InitializeState(state, []); + + var provider = new PersistentStateValueProvider(state, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); var renderer = new TestRenderer(); var component = new TestComponent(); var componentStates = CreateComponentState(renderer, [(component, null)], null); @@ -74,502 +76,27 @@ public void Unsubscribe_RemovesCallbackFromRegisteredCallbacks() // Arrange var state = new PersistentComponentState( new Dictionary(), + [], []); - var provider = new PersistentStateValueProvider(state, new ServiceCollection().BuildServiceProvider()); - 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); + InitializeState(state, []); - // 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 provider = new PersistentStateValueProvider(state, NullLogger.Instance, new ServiceCollection().BuildServiceProvider()); var renderer = new TestRenderer(); - var component = new TestComponent { State = "testValue" }; + var component = new TestComponent(); var componentStates = CreateComponentState(renderer, [(component, null)], null); var componentState = componentStates.First(); - // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); 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 PersistentStateValueProvider - var key = PersistentStateValueProvider.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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider - var key = PersistentStateValueProvider.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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider - var key1 = PersistentStateValueProvider.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue)); - Assert.Equal("testValue1", retrievedValue); - - var key2 = PersistentStateValueProvider.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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider - var key1 = PersistentStateValueProvider.ComputeKey(componentState1, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson(key1, out var retrievedValue)); - Assert.Equal("testValue1", retrievedValue); - - var key2 = PersistentStateValueProvider.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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - 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 PersistAsync_CanPersistValueTypes_IntProperty() - { - // 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 ValueTypeTestComponent { IntValue = 42 }; - var componentStates = CreateComponentState(renderer, [(component, null)], null); - var componentState = componentStates.First(); - - // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.IntValue), typeof(int)); - provider.Subscribe(componentState, cascadingParameterInfo); - - // Act - await persistenceManager.PersistStateAsync(store, renderer); - - // Assert - Assert.NotEmpty(store.State); - - // Verify the value was persisted correctly - var newState = new PersistentComponentState(new Dictionary(), []); - newState.InitializeExistingState(store.State); - - var key = PersistentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson(key, out var retrievedValue)); - Assert.Equal(42, retrievedValue); - } - - [Fact] - public async Task PersistAsync_CanPersistValueTypes_NullableIntProperty() - { - // 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 ValueTypeTestComponent { NullableIntValue = 123 }; - var componentStates = CreateComponentState(renderer, [(component, null)], null); - var componentState = componentStates.First(); - - // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableIntValue), typeof(int?)); - provider.Subscribe(componentState, cascadingParameterInfo); - - // Act - await persistenceManager.PersistStateAsync(store, renderer); - - // Assert - Assert.NotEmpty(store.State); - - // Verify the value was persisted correctly - var newState = new PersistentComponentState(new Dictionary(), []); - newState.InitializeExistingState(store.State); - - var key = PersistentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson(key, out var retrievedValue)); - Assert.Equal(123, retrievedValue); - } - - [Fact] - public async Task PersistAsync_CanPersistValueTypes_TupleProperty() - { - // 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 ValueTypeTestComponent { TupleValue = ("test", 456) }; - var componentStates = CreateComponentState(renderer, [(component, null)], null); - var componentState = componentStates.First(); - - // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.TupleValue), typeof((string, int))); - provider.Subscribe(componentState, cascadingParameterInfo); - - // Act - await persistenceManager.PersistStateAsync(store, renderer); - - // Assert - Assert.NotEmpty(store.State); - - // Verify the value was persisted correctly - var newState = new PersistentComponentState(new Dictionary(), []); - newState.InitializeExistingState(store.State); - - var key = PersistentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson<(string, int)>(key, out var retrievedValue)); - Assert.Equal(("test", 456), retrievedValue); - } - - [Fact] - public async Task PersistAsync_CanPersistValueTypes_NullableTupleProperty() - { - // 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 ValueTypeTestComponent { NullableTupleValue = ("test2", 789) }; - var componentStates = CreateComponentState(renderer, [(component, null)], null); - var componentState = componentStates.First(); - - // Create the provider and subscribe the component - var provider = new PersistentStateValueProvider(persistenceManager.State, new ServiceCollection().BuildServiceProvider()); - var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableTupleValue), typeof((string, int)?)); - provider.Subscribe(componentState, cascadingParameterInfo); - - // Act - await persistenceManager.PersistStateAsync(store, renderer); + provider.Unsubscribe(componentState, cascadingParameterInfo); // Assert - Assert.NotEmpty(store.State); - - // Verify the value was persisted correctly - var newState = new PersistentComponentState(new Dictionary(), []); - newState.InitializeExistingState(store.State); - - var key = PersistentStateValueProvider.ComputeKey(componentState, cascadingParameterInfo.PropertyName); - Assert.True(newState.TryTakeFromJson<(string, int)?>(key, out var retrievedValue)); - Assert.Equal(("test2", 789), retrievedValue); + Assert.Empty(provider.Subscriptions); } private static void InitializeState(PersistentComponentState state, List<(ComponentState componentState, string propertyName, string value)> items) @@ -577,10 +104,10 @@ private static void InitializeState(PersistentComponentState state, List<(Compon var dictionary = new Dictionary(); foreach (var item in items) { - var key = PersistentStateValueProvider.ComputeKey(item.componentState, item.propertyName); + var key = PersistentStateValueProviderKeyResolver.ComputeKey(item.componentState, item.propertyName); dictionary[key] = JsonSerializer.SerializeToUtf8Bytes(item.value, JsonSerializerOptions.Web); } - state.InitializeExistingState(dictionary); + state.InitializeExistingState(dictionary, RestoreContext.InitialValue); } private static CascadingParameterInfo CreateCascadingParameterInfo(string propertyName, Type propertyType) @@ -594,7 +121,7 @@ private static CascadingParameterInfo CreateCascadingParameterInfo(string proper private static List CreateComponentState( TestRenderer renderer, List<(IComponent, object)> components, - ParentComponent parentComponent = null) + IComponent parentComponent = null) { var i = 1; var parentComponentState = parentComponent != null ? new ComponentState(renderer, i++, parentComponent, null) : null; @@ -618,7 +145,7 @@ private static List CreateComponentState( { currentRenderTree.OpenComponent(0); } - + var frames = currentRenderTree.GetFrames(); frames.Array[frames.Count - 1].ComponentStateField = componentState; if (key != null) @@ -669,23 +196,6 @@ private class ValueTypeTestComponent : IComponent 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(); diff --git a/src/Components/Components/test/PersistentValueProviderComponentSubscriptionTests.cs b/src/Components/Components/test/PersistentValueProviderComponentSubscriptionTests.cs new file mode 100644 index 000000000000..7e6dff029752 --- /dev/null +++ b/src/Components/Components/test/PersistentValueProviderComponentSubscriptionTests.cs @@ -0,0 +1,626 @@ +// 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.Runtime.ExceptionServices; +using System.Text.Json; +using System.Threading.Tasks; +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; + +namespace Microsoft.AspNetCore.Components; + +public class PersistentValueProviderComponentSubscriptionTests +{ + [Fact] + public void Constructor_CreatesSubscription_AndRegistersCallbacks() + { + // Arrange + var state = new PersistentComponentState(new Dictionary(), [], []); + state.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "test-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + // Act + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Assert - Constructor should complete without throwing + Assert.NotNull(subscription); + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_ReturnsNull_WhenNotInitialized() + { + // Arrange + var state = new PersistentComponentState(new Dictionary(), [], []); + state.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "test-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Null(result); + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_RestoresFromPersistentState_OnFirstCall() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "initial-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate the state with serialized data + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(TestComponent.State)); + initialState[key] = JsonSerializer.SerializeToUtf8Bytes("persisted-value", JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal("persisted-value", result); + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_ReturnsCurrentPropertyValue_AfterInitialization() + { + // Arrange + var state = new PersistentComponentState(new Dictionary(), [], []); + state.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "current-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Initialize by calling once + subscription.GetOrComputeLastValue(); + + // Change the component's property value + component.State = "updated-value"; + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal("updated-value", result); + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_CanRestoreValueTypes() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new ValueTypeTestComponent { IntValue = 42 }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate the state with serialized data + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(ValueTypeTestComponent.IntValue)); + initialState[key] = JsonSerializer.SerializeToUtf8Bytes(123, JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.IntValue), typeof(int)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal(123, result); + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_CanRestoreNullableValueTypes() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new ValueTypeTestComponent { NullableIntValue = 42 }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate the state with serialized data + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(ValueTypeTestComponent.NullableIntValue)); + initialState[key] = JsonSerializer.SerializeToUtf8Bytes((int?)456, JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableIntValue), typeof(int?)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal(456, result); + subscription.Dispose(); + } + + [Fact] + public void Dispose_DisposesSubscriptions() + { + // Arrange + var state = new PersistentComponentState(new Dictionary(), [], []); + state.InitializeExistingState(new Dictionary(), RestoreContext.InitialValue); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "test-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act & Assert - Should not throw + subscription.Dispose(); + } + + [Fact] + public void GetOrComputeLastValue_ReturnsNull_WhenSkipInitialValueAndInitialContext() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new TestComponent { State = "initial-value" }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate the state with serialized data that should be skipped + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(TestComponent.State)); + initialState[key] = JsonSerializer.SerializeToUtf8Bytes("persisted-value", JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.InitialValue); + + var cascadingParameterInfo = CreateCascadingParameterInfoWithBehavior( + nameof(TestComponent.State), + typeof(string), + RestoreBehavior.SkipInitialValue); + + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + Assert.Null(result); + subscription.Dispose(); + } + + [Fact] + public async Task GetOrComputeLastValue_FollowsCorrectValueTransitionSequence() + { + // Arrange + var appState = new Dictionary(); + var manager = new ComponentStatePersistenceManager(NullLogger.Instance); + var state = manager.State; + var serviceProvider = PersistentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(new ServiceCollection()) + .AddSingleton(manager) + .AddSingleton(manager.State) + .AddFakeLogging() + .BuildServiceProvider(); + var renderer = new TestRenderer(serviceProvider); + var provider = (PersistentStateValueProvider)renderer.ServiceProviderCascadingValueSuppliers.Single(); + var component = new TestComponent { State = "initial-property-value" }; + var componentId = renderer.AssignRootComponentId(component); + var componentState = renderer.GetComponentState(component); + + // Pre-populate the state with serialized data + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(TestComponent.State)); + appState[key] = JsonSerializer.SerializeToUtf8Bytes("first-restored-value", JsonSerializerOptions.Web); + await manager.RestoreStateAsync(new TestStore(appState), RestoreContext.InitialValue); + + await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId, ParameterView.Empty)); + var cascadingParameterInfo = CreateCascadingParameterInfo(nameof(TestComponent.State), typeof(string)); + + // Act & Assert - First call: Returns restored value from state + Assert.Equal("first-restored-value", component.State); + + // Change the component's property value + component.State = "updated-property-value"; + + // Second call: Returns the component's property value + var result2 = provider.GetCurrentValue(componentState, cascadingParameterInfo); + Assert.Equal("updated-property-value", result2); + + appState.Clear(); + var newState = new Dictionary + { + [key] = JsonSerializer.SerializeToUtf8Bytes("second-restored-value", JsonSerializerOptions.Web) + }; + // Simulate invoking the callback with a value update. + await renderer.Dispatcher.InvokeAsync(() => manager.RestoreStateAsync(new TestStore(newState), RestoreContext.ValueUpdate)); + Assert.Equal("second-restored-value", component.State); + + component.State = "another-updated-value"; + // Other calls: Returns the updated value from state + Assert.Equal("another-updated-value", provider.GetCurrentValue(componentState, cascadingParameterInfo)); + component.State = "final-updated-value"; + Assert.Equal("final-updated-value", provider.GetCurrentValue(componentState, cascadingParameterInfo)); + Assert.Equal("final-updated-value", provider.GetCurrentValue(componentState, cascadingParameterInfo)); + } + + [Fact] + public void GetOrComputeLastValue_UsesCustomSerializer_ForRestoration() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new CustomSerializerTestComponent { CustomValue = new CustomData { Value = "initial" } }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate the state with custom serialized data + var key = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(CustomSerializerTestComponent.CustomValue)); + var customSerializer = new TestCustomDataSerializer(); + var testData = new CustomData { Value = "restored-custom" }; + var writer = new ArrayBufferWriter(); + customSerializer.Persist(testData, writer); + initialState[key] = writer.WrittenSpan.ToArray(); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var cascadingParameterInfo = CreateCascadingParameterInfo( + nameof(CustomSerializerTestComponent.CustomValue), + typeof(CustomData)); + + var serviceProvider = new ServiceCollection() + .AddSingleton, TestCustomDataSerializer>() + .BuildServiceProvider(); + var logger = NullLogger.Instance; + + var subscription = new PersistentValueProviderComponentSubscription( + state, componentState, cascadingParameterInfo, serviceProvider, logger); + + // Act + var result = subscription.GetOrComputeLastValue(); + + // Assert + var customDataResult = Assert.IsType(result); + Assert.Equal("restored-custom", customDataResult.Value); + subscription.Dispose(); + } + + [Fact] + public void CanPersistAndRestore_MultipleProperties_OnSameComponent() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new MultiplePropertiesComponent + { + StringValue = "initial-string", + IntValue = 42, + BoolValue = true + }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate state for all properties + var stringKey = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(MultiplePropertiesComponent.StringValue)); + var intKey = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(MultiplePropertiesComponent.IntValue)); + var boolKey = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(MultiplePropertiesComponent.BoolValue)); + + initialState[stringKey] = JsonSerializer.SerializeToUtf8Bytes("restored-string", JsonSerializerOptions.Web); + initialState[intKey] = JsonSerializer.SerializeToUtf8Bytes(123, JsonSerializerOptions.Web); + initialState[boolKey] = JsonSerializer.SerializeToUtf8Bytes(false, JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + // Create subscriptions for each property + var stringSubscription = new PersistentValueProviderComponentSubscription( + state, componentState, + CreateCascadingParameterInfo(nameof(MultiplePropertiesComponent.StringValue), typeof(string)), + serviceProvider, logger); + + var intSubscription = new PersistentValueProviderComponentSubscription( + state, componentState, + CreateCascadingParameterInfo(nameof(MultiplePropertiesComponent.IntValue), typeof(int)), + serviceProvider, logger); + + var boolSubscription = new PersistentValueProviderComponentSubscription( + state, componentState, + CreateCascadingParameterInfo(nameof(MultiplePropertiesComponent.BoolValue), typeof(bool)), + serviceProvider, logger); + + // Act + var stringResult = stringSubscription.GetOrComputeLastValue(); + var intResult = intSubscription.GetOrComputeLastValue(); + var boolResult = boolSubscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal("restored-string", stringResult); + Assert.Equal(123, intResult); + Assert.Equal(false, boolResult); + + // Cleanup + stringSubscription.Dispose(); + intSubscription.Dispose(); + boolSubscription.Dispose(); + } + + [Fact] + public void CanPersistAndRestore_DifferentPropertyTypes_OnSameComponent() + { + // Arrange + var initialState = new Dictionary(); + var state = new PersistentComponentState(initialState, [], []); + var renderer = new TestRenderer(); + var component = new ValueTypeTestComponent + { + IntValue = 42, + NullableIntValue = 100 + }; + var componentState = CreateComponentState(renderer, component, null, null); + + // Pre-populate state for different property types + var intKey = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(ValueTypeTestComponent.IntValue)); + var nullableIntKey = PersistentStateValueProviderKeyResolver.ComputeKey(componentState, nameof(ValueTypeTestComponent.NullableIntValue)); + + initialState[intKey] = JsonSerializer.SerializeToUtf8Bytes(999, JsonSerializerOptions.Web); + initialState[nullableIntKey] = JsonSerializer.SerializeToUtf8Bytes((int?)777, JsonSerializerOptions.Web); + state.InitializeExistingState(initialState, RestoreContext.LastSnapshot); + + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var logger = NullLogger.Instance; + + // Create subscriptions for different property types + var intSubscription = new PersistentValueProviderComponentSubscription( + state, componentState, + CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.IntValue), typeof(int)), + serviceProvider, logger); + + var nullableIntSubscription = new PersistentValueProviderComponentSubscription( + state, componentState, + CreateCascadingParameterInfo(nameof(ValueTypeTestComponent.NullableIntValue), typeof(int?)), + serviceProvider, logger); + + // Act + var intResult = intSubscription.GetOrComputeLastValue(); + var nullableIntResult = nullableIntSubscription.GetOrComputeLastValue(); + + // Assert + Assert.Equal(999, intResult); + Assert.Equal(777, nullableIntResult); + + // Cleanup + intSubscription.Dispose(); + nullableIntSubscription.Dispose(); + } + + private static CascadingParameterInfo CreateCascadingParameterInfo(string propertyName, Type propertyType) + { + return new CascadingParameterInfo( + new PersistentStateAttribute(), + propertyName, + propertyType); + } + + private static CascadingParameterInfo CreateCascadingParameterInfoWithBehavior( + string propertyName, + Type propertyType, + RestoreBehavior restoreBehavior, + bool allowUpdates = false) + { + return new CascadingParameterInfo( + new PersistentStateAttribute + { + RestoreBehavior = restoreBehavior, + AllowUpdates = allowUpdates + }, + propertyName, + propertyType); + } + + private static ComponentState CreateComponentState( + TestRenderer renderer, + IComponent component, + IComponent parentComponent, + object key) + { + var parentComponentState = parentComponent != null + ? new ComponentState(renderer, 1, parentComponent, null) + : null; + var componentState = new ComponentState(renderer, 2, component, parentComponentState); + + if (parentComponentState != null && parentComponentState.CurrentRenderTree != null && key != null) + { + var currentRenderTree = parentComponentState.CurrentRenderTree; + + // Open component based on the actual component type + if (component is TestComponent) + { + currentRenderTree.OpenComponent(0); + } + else if (component is ValueTypeTestComponent) + { + currentRenderTree.OpenComponent(0); + } + else + { + currentRenderTree.OpenComponent(0); + } + + var frames = currentRenderTree.GetFrames(); + frames.Array[frames.Count - 1].ComponentStateField = componentState; + currentRenderTree.SetKey(key); + currentRenderTree.CloseComponent(); + } + + return componentState; + } + + private class TestRenderer(IServiceProvider serviceProvider) : Renderer(serviceProvider, NullLoggerFactory.Instance) + { + public TestRenderer() : this(new ServiceCollection().BuildServiceProvider()) { } + + public override Dispatcher Dispatcher => new TestDispatcher(); + + protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => throw new NotImplementedException(); + } + + private class TestDispatcher : Dispatcher + { + public override bool CheckAccess() => true; + public override Task InvokeAsync(Action workItem) + { + workItem(); + return Task.CompletedTask; + } + + public override Task InvokeAsync(Func workItem) + { + return workItem(); + } + public override Task InvokeAsync(Func workItem) + { + return Task.FromResult(workItem()); + } + public override Task InvokeAsync(Func> workItem) + { + return workItem(); + } + } + + private class TestComponent : IComponent + { + [PersistentState(AllowUpdates = true)] + public string State { get; set; } + + public void Attach(RenderHandle renderHandle) + { + } + + public Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + return Task.CompletedTask; + } + } + + private class ValueTypeTestComponent : IComponent + { + [PersistentState] + public int IntValue { get; set; } + + [PersistentState] + public int? NullableIntValue { get; set; } + + [PersistentState] + public (string, int) TupleValue { get; set; } + + [PersistentState] + public (string, int)? NullableTupleValue { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class MultiplePropertiesComponent : IComponent + { + [PersistentState] + public string StringValue { get; set; } + + [PersistentState] + public int IntValue { get; set; } + + [PersistentState] + public bool BoolValue { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class CustomSerializerTestComponent : IComponent + { + [PersistentState] + public CustomData CustomValue { get; set; } + + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class CustomData + { + public string Value { get; set; } + } + + private class TestCustomDataSerializer : PersistentComponentStateSerializer + { + public override void Persist(CustomData value, IBufferWriter writer) + { + var json = JsonSerializer.SerializeToUtf8Bytes($"CUSTOM:{value.Value}"); + writer.Write(json); + } + + public override CustomData Restore(ReadOnlySequence data) + { + var json = JsonSerializer.Deserialize(data.ToArray()); + var value = json.StartsWith("CUSTOM:", StringComparison.Ordinal) ? json.Substring(7) : json; + return new CustomData { Value = value }; + } + } + + private class ParentComponent : IComponent + { + public void Attach(RenderHandle renderHandle) => throw new NotImplementedException(); + public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); + } + + private class TestStore(IDictionary state) : IPersistentComponentStateStore + { + public Task> GetPersistedStateAsync() => Task.FromResult(state); + public Task PersistStateAsync(IReadOnlyDictionary state) => throw new NotImplementedException(); + } +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 82b33fb6afd5..4d95f68c0fc5 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -126,7 +126,7 @@ internal async Task InitializeStandardComponentServicesAsync( // (which will obviously not work, but should not fail) var componentApplicationLifetime = httpContext.RequestServices.GetRequiredService(); componentApplicationLifetime.SetPlatformRenderMode(RenderMode.InteractiveAuto); - await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore()); + await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore(), RestoreContext.InitialValue); if (componentType != null) { diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 9801ae5715c1..0c086f054c03 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -76,7 +76,7 @@ public async ValueTask CreateCircuitHostAsync( // 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); + await appLifetime.RestoreStateAsync(store, RestoreContext.InitialValue); } var serverComponentDeserializer = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 11dcdb3e5234..b02317a4f12b 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -311,7 +311,7 @@ public async Task OnConnectionUpAsync(CancellationToken cancellationToken) public async Task OnConnectionDownAsync(CancellationToken cancellationToken) { - if(_onConnectionDownFired) + if (_onConnectionDownFired) { return; } @@ -760,7 +760,8 @@ private async Task TryNotifyClientErrorAsync(IClientProxy client, string error, internal Task UpdateRootComponents( RootComponentOperationBatch operationBatch, - ProtectedPrerenderComponentApplicationStore store, + IClearableStore store, + bool isRestore, CancellationToken cancellation) { Log.UpdateRootComponentsStarted(_logger); @@ -771,6 +772,8 @@ internal Task UpdateRootComponents( var shouldWaitForQuiescence = false; var operations = operationBatch.Operations; var batchId = operationBatch.BatchId; + var postRemovalTask = Task.CompletedTask; + TaskCompletionSource? taskCompletionSource = null; try { if (Descriptors.Count > 0) @@ -780,19 +783,41 @@ internal Task UpdateRootComponents( throw new InvalidOperationException("UpdateRootComponents is not supported when components have" + " been provided during circuit start up."); } + + if (store != null) + { + shouldClearStore = true; + // We only do this if we have no root components. Otherwise, the state would have been + // provided during the start up process + var persistenceManager = _scope.ServiceProvider.GetRequiredService(); + if (_isFirstUpdate) + { + persistenceManager.SetPlatformRenderMode(RenderMode.InteractiveServer); + } + + // Use the appropriate scenario based on whether this is a restore operation + var context = (isRestore, _isFirstUpdate) switch + { + (_, false) => RestoreContext.ValueUpdate, + (true, _) => RestoreContext.LastSnapshot, + (false, _) => RestoreContext.InitialValue + }; + if (context == RestoreContext.ValueUpdate) + { + taskCompletionSource = new(); + postRemovalTask = EnqueueRestore(taskCompletionSource, persistenceManager, context, store); + } + else + { + // Trigger the restore of the state right away. + await persistenceManager.RestoreStateAsync(store, context); + } + } + if (_isFirstUpdate) { _isFirstUpdate = false; shouldWaitForQuiescence = true; - if (store != null) - { - shouldClearStore = true; - // 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); - } // Retrieve the circuit handlers at this point. _circuitHandlers = [.. _scope.ServiceProvider.GetServices().OrderBy(h => h.Order)]; @@ -810,7 +835,10 @@ internal Task UpdateRootComponents( } } - await PerformRootComponentOperations(operations, shouldWaitForQuiescence); + var operationsTask = PerformRootComponentOperations(operations, shouldWaitForQuiescence, postRemovalTask); + taskCompletionSource?.SetResult(); + + await operationsTask; await Client.SendAsync("JS.EndUpdateRootComponents", batchId); @@ -830,17 +858,30 @@ internal Task UpdateRootComponents( // At this point all components have successfully produced an initial render and we can clear the contents of the component // application state store. This ensures the memory that was not used during the initial render of these components gets // reclaimed since no-one else is holding on to it any longer. - store.ExistingState.Clear(); + store.Clear(); } } }); } + private static async Task EnqueueRestore( + TaskCompletionSource taskCompletionSource, + ComponentStatePersistenceManager manager, + RestoreContext context, + IPersistentComponentStateStore store) + { + await taskCompletionSource.Task; + await manager.RestoreStateAsync(store, context); + } + private async ValueTask PerformRootComponentOperations( RootComponentOperation[] operations, - bool shouldWaitForQuiescence) + bool shouldWaitForQuiescence, + Task postStateTask) { var webRootComponentManager = Renderer.GetOrCreateWebRootComponentManager(); + webRootComponentManager.SetCurrentUpdateTask(postStateTask); + var pendingTasks = shouldWaitForQuiescence ? new Task[operations.Length] : null; @@ -860,6 +901,7 @@ await HandleInboundActivityAsync(() => operation.Descriptor.ComponentType, operation.Marker.Value.Key, operation.Descriptor.Parameters); + pendingTasks?[i] = task; break; case RootComponentOperationType.Update: diff --git a/src/Components/Server/src/Circuits/RemoteComponentState.cs b/src/Components/Server/src/Circuits/RemoteComponentState.cs index 9ad13cdd40fe..002834e122f6 100644 --- a/src/Components/Server/src/Circuits/RemoteComponentState.cs +++ b/src/Components/Server/src/Circuits/RemoteComponentState.cs @@ -10,8 +10,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; /// internal sealed class RemoteComponentState : ComponentState { - private readonly RemoteRenderer _renderer; - public RemoteComponentState( RemoteRenderer renderer, int componentId, @@ -19,12 +17,11 @@ public RemoteComponentState( ComponentState? parentComponentState) : base(renderer, componentId, component, parentComponentState) { - _renderer = renderer; } protected override object? GetComponentKey() { - var markerKey = _renderer.GetMarkerKey(this); + var markerKey = ((RemoteRenderer)Renderer).GetMarkerKey(this); // If we have a ComponentMarkerKey, return it for state persistence consistency if (markerKey != default) diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 1506d9bd61bd..7455ef8c6f85 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -205,7 +205,7 @@ public async Task UpdateRootComponents(string serializedComponentOperations, str new ProtectedPrerenderComponentApplicationStore(_dataProtectionProvider); } - _ = circuitHost.UpdateRootComponents(operations, store, Context.ConnectionAborted); + _ = circuitHost.UpdateRootComponents(operations, store, persistedState != null, Context.ConnectionAborted); } public async ValueTask ConnectCircuit(string circuitIdSecret) diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index e661387d1619..670ba4427247 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -693,6 +693,95 @@ public async Task UpdateRootComponents_CanRemoveExistingRootComponent() ((TestRemoteRenderer)circuitHost.Renderer).GetTestComponentState(0)); } + [Fact] + public async Task UpdateRootComponents_ValidatesOperationSequencingDuringValueUpdateRestore() + { + // Arrange + var testRenderer = GetRemoteRenderer(); + var circuitHost = TestCircuitHost.Create( + remoteRenderer: testRenderer); + + // Set up initial components for subsequent operations + await AddComponentAsync(circuitHost, 0, new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "Component 0" + }); + await AddComponentAsync(circuitHost, 1, new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "Component 1" + }); + + Assert.Equal(2, testRenderer.GetOrCreateWebRootComponentManager().GetRootComponents().Count()); + var store = new TestComponentApplicationStore( + new Dictionary { ["test"] = [1, 2, 3] }); + + var operations = new RootComponentOperation[] + { + new() + { + Type = RootComponentOperationType.Add, + SsrComponentId = 2, + Marker = CreateMarker(typeof(DynamicallyAddedComponent), "2", new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "New Component 2" + }), + Descriptor = new( + componentType: typeof(DynamicallyAddedComponent), + parameters: CreateWebRootComponentParameters(new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "New Component 2" + })), + }, + + new() + { + Type = RootComponentOperationType.Remove, + SsrComponentId = 0, + }, + + new() + { + Type = RootComponentOperationType.Update, + SsrComponentId = 1, + Marker = CreateMarker(typeof(DynamicallyAddedComponent), "1", new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "Replaced Component 1" + }), + Descriptor = new( + componentType: typeof(DynamicallyAddedComponent), + parameters: CreateWebRootComponentParameters(new Dictionary + { + [nameof(DynamicallyAddedComponent.Message)] = "Replaced Component 1" + })), + }, + }; + + var batch = new RootComponentOperationBatch + { + BatchId = 1, + Operations = operations + }; + + var updateTask = circuitHost.UpdateRootComponents(batch, store, false, CancellationToken.None); + Assert.Equal(2, testRenderer.GetOrCreateWebRootComponentManager().GetRootComponents().Count()); + var dynamicallyAddedComponent1 = Assert.IsType(testRenderer.GetTestComponentState(3).Component); + dynamicallyAddedComponent1.Updated = new ManualResetEvent(false); + Assert.Equal("Default message", dynamicallyAddedComponent1.Message); + var dynamicallyAddedComponent2 = Assert.IsType(testRenderer.GetTestComponentState(2).Component); + dynamicallyAddedComponent2.Updated = new ManualResetEvent(false); + Assert.Equal("Default message", dynamicallyAddedComponent2.Message); + store.Continue(); + await updateTask; + + dynamicallyAddedComponent1.Updated.WaitOne(); + dynamicallyAddedComponent2.Updated.WaitOne(); + + Assert.Equal("Replaced Component 1", Assert.IsType(testRenderer.GetTestComponentState(3).Component).Message); + Assert.Equal("New Component 2", Assert.IsType(testRenderer.GetTestComponentState(2).Component).Message); + + Assert.Equal(2, testRenderer.GetOrCreateWebRootComponentManager().GetRootComponents().Count()); + } + private async Task AddComponentAsync(CircuitHost circuitHost, int ssrComponentId, Dictionary parameters = null, string componentKey = "") where TComponent : IComponent { @@ -707,7 +796,7 @@ private async Task AddComponentAsync(CircuitHost circuitHost, int ss }; // Add component - await circuitHost.UpdateRootComponents(new() { Operations = [addOperation] }, null, CancellationToken.None); + await circuitHost.UpdateRootComponents(new() { Operations = [addOperation] }, null, false, CancellationToken.None); } private async Task UpdateComponentAsync(CircuitHost circuitHost, int ssrComponentId, Dictionary parameters = null, string componentKey = "") @@ -723,7 +812,7 @@ private async Task UpdateComponentAsync(CircuitHost circuitHost, int }; // Update component - await circuitHost.UpdateRootComponents(new() { Operations = [updateOperation] }, null, CancellationToken.None); + await circuitHost.UpdateRootComponents(new() { Operations = [updateOperation] }, null, false, CancellationToken.None); } private async Task RemoveComponentAsync(CircuitHost circuitHost, int ssrComponentId) @@ -735,7 +824,7 @@ private async Task RemoveComponentAsync(CircuitHost circuitHost, int ssrComponen }; // Remove component - await circuitHost.UpdateRootComponents(new() { Operations = [removeOperation] }, null, CancellationToken.None); + await circuitHost.UpdateRootComponents(new() { Operations = [removeOperation] }, null, false, CancellationToken.None); } private ProtectedPrerenderComponentApplicationStore CreateStore() @@ -823,7 +912,7 @@ public TestRemoteRenderer(IServiceProvider serviceProvider, ISingleClientProxy c NullLogger.Instance, CreateJSRuntime(new CircuitOptions()), new CircuitJSComponentInterop(new CircuitOptions())) - { + { } public ComponentState GetTestComponentState(int id) @@ -981,6 +1070,8 @@ private class DynamicallyAddedComponent : IComponent, IDisposable [Parameter] public string Message { get; set; } = "Default message"; + public ManualResetEvent Updated { get; set; } + private void Render(RenderTreeBuilder builder) { builder.AddContent(0, Message); @@ -999,6 +1090,7 @@ public Task SetParametersAsync(ParameterView parameters) } TriggerRender(); + Updated?.Set(); return Task.CompletedTask; } @@ -1051,4 +1143,20 @@ public void TriggerRender() Assert.True(task.IsCompletedSuccessfully); } } + + private class TestComponentApplicationStore(Dictionary dictionary) : IPersistentComponentStateStore, IClearableStore + { + private readonly TaskCompletionSource _tcs = new(); + + public void Clear() => dictionary.Clear(); + + public async Task> GetPersistedStateAsync() + { + await _tcs.Task; + return dictionary; + } + + public Task PersistStateAsync(IReadOnlyDictionary state) => throw new NotImplementedException(); + internal void Continue() => _tcs.SetResult(); + } } diff --git a/src/Components/Server/test/Circuits/CircuitPersistenceManagerTest.cs b/src/Components/Server/test/Circuits/CircuitPersistenceManagerTest.cs index 8f75e9d52154..6bd9be6e9a7d 100644 --- a/src/Components/Server/test/Circuits/CircuitPersistenceManagerTest.cs +++ b/src/Components/Server/test/Circuits/CircuitPersistenceManagerTest.cs @@ -412,6 +412,7 @@ private async Task CreateCircuitHostAsync( .AddSingleton(dataProtectionProvider) .AddSingleton() .AddSupplyValueFromPersistentComponentStateProvider() + .AddFakeLogging() .AddSingleton( sp => new ComponentStatePersistenceManager( NullLoggerFactory.Instance.CreateLogger(), @@ -464,6 +465,7 @@ await circuitHost.InitializeAsync( await circuitHost.UpdateRootComponents( CreateBatch(components, deserializer, dataProtectionProvider), store, + false, default); return circuitHost; diff --git a/src/Components/Server/test/Circuits/TestCircuitHost.cs b/src/Components/Server/test/Circuits/TestCircuitHost.cs index a5750196114b..558503aa2ffd 100644 --- a/src/Components/Server/test/Circuits/TestCircuitHost.cs +++ b/src/Components/Server/test/Circuits/TestCircuitHost.cs @@ -30,26 +30,29 @@ public static CircuitHost Create( CircuitHandler[] handlers = null, CircuitClientProxy clientProxy = null) { - serviceScope = serviceScope ?? new AsyncServiceScope(Mock.Of()); clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of(), Guid.NewGuid().ToString()); var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Options.Create(new HubOptions()), Mock.Of>()); var navigationManager = new RemoteNavigationManager(Mock.Of>()); var componentsActivitySource = new ComponentsActivitySource(); var circuitActivitySource = new CircuitActivitySource(); - var serviceProvider = new Mock(); - serviceProvider - .Setup(services => services.GetService(typeof(IJSRuntime))) - .Returns(jsRuntime); - serviceProvider - .Setup(services => services.GetService(typeof(ComponentsActivitySource))) - .Returns(componentsActivitySource); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + var serviceProvider = new ServiceCollection() + .AddSingleton(jsRuntime) + .AddSingleton(componentsActivitySource) + .AddSingleton(persistenceManager) + .AddSingleton(circuitActivitySource) + .BuildServiceProvider(); + serviceScope ??= serviceProvider.CreateAsyncScope(); + var serverComponentDeserializer = Mock.Of(); var circuitMetrics = new CircuitMetrics(new TestMeterFactory()); if (remoteRenderer == null) { remoteRenderer = new RemoteRenderer( - serviceProvider.Object, + serviceProvider, NullLoggerFactory.Instance, new CircuitOptions(), clientProxy, diff --git a/src/Components/Shared/src/WebRootComponentManager.cs b/src/Components/Shared/src/WebRootComponentManager.cs index 43de04bd6b81..1d52a56e6af0 100644 --- a/src/Components/Shared/src/WebRootComponentManager.cs +++ b/src/Components/Shared/src/WebRootComponentManager.cs @@ -31,6 +31,7 @@ public WebRootComponentManager GetOrCreateWebRootComponentManager() public sealed class WebRootComponentManager(Renderer renderer) { private readonly Dictionary _webRootComponents = new(); + private Task _currentUpdateTask = Task.CompletedTask; public async Task AddRootComponentAsync( int ssrComponentId, @@ -52,7 +53,7 @@ public async Task AddRootComponentAsync( var component = WebRootComponent.Create(renderer, componentType, ssrComponentId, key, parameters); _webRootComponents.Add(ssrComponentId, component); - + await _currentUpdateTask; await component.RenderAsync(renderer); } @@ -63,7 +64,7 @@ public Task UpdateRootComponentAsync( WebRootComponentParameters newParameters) { var component = GetRequiredWebRootComponent(ssrComponentId); - return component.UpdateAsync(renderer, newComponentType, newKey, newParameters); + return component.UpdateAsync(renderer, newComponentType, newKey, newParameters, _currentUpdateTask); } public void RemoveRootComponent(int ssrComponentId) @@ -97,7 +98,7 @@ internal ComponentMarkerKey GetRootComponentKey(int componentId) { foreach (var (_, candidate) in _webRootComponents) { - var(id, key, _, _) = candidate; + var (id, key, _, _) = candidate; if (id == componentId) { return key; @@ -107,6 +108,11 @@ internal ComponentMarkerKey GetRootComponentKey(int componentId) return default; } + internal void SetCurrentUpdateTask(Task postStateTask) + { + _currentUpdateTask = postStateTask; + } + private sealed class WebRootComponent { [DynamicallyAccessedMembers(Component)] @@ -165,7 +171,8 @@ public Task UpdateAsync( Renderer renderer, [DynamicallyAccessedMembers(Component)] Type newComponentType, ComponentMarkerKey? newKey, - WebRootComponentParameters newParameters) + WebRootComponentParameters newParameters, + Task currentUpdateTask) { if (_componentType != newComponentType) { @@ -189,7 +196,7 @@ public Task UpdateAsync( // We can supply new parameters if the key has a @key value, because that means the client // opted in to dynamic parameter updates. _latestParameters = newParameters; - return RenderAsync(renderer); + return RenderAsync(renderer, currentUpdateTask); } else { @@ -207,13 +214,21 @@ public Task UpdateAsync( renderer.RemoveRootComponent(_interactiveComponentId); _interactiveComponentId = renderer.AddRootComponent(_componentType, _ssrComponentIdString); _latestParameters = newParameters; - return RenderAsync(renderer); + return RenderAsync(renderer, currentUpdateTask); } } } public Task RenderAsync(Renderer renderer) - => renderer.RenderRootComponentAsync(_interactiveComponentId, _latestParameters.Parameters); + { + return renderer.RenderRootComponentAsync(_interactiveComponentId, _latestParameters.Parameters); + } + + public async Task RenderAsync(Renderer renderer, Task updateTask) + { + await updateTask; + await renderer.RenderRootComponentAsync(_interactiveComponentId, _latestParameters.Parameters); + } public void Remove(Renderer renderer) { diff --git a/src/Components/Web.JS/src/Boot.Server.Common.ts b/src/Components/Web.JS/src/Boot.Server.Common.ts index 7b1846b267fc..6f8d20d19d05 100644 --- a/src/Components/Web.JS/src/Boot.Server.Common.ts +++ b/src/Components/Web.JS/src/Boot.Server.Common.ts @@ -186,16 +186,16 @@ export function isCircuitAvailable(): boolean { return circuit && !circuit.isDisposedOrDisposing(); } -export function updateServerRootComponents(operations: string): Promise | undefined { +export function updateServerRootComponents(operations: string, serverState: string): Promise | undefined { if (circuit && !circuit.isDisposedOrDisposing()) { - return circuit.updateRootComponents(operations); + return circuit.updateRootComponents(operations, serverState); } else { - scheduleWhenReady(operations); + scheduleWhenReady(operations, serverState); } } -async function scheduleWhenReady(operations: string) { +async function scheduleWhenReady(operations: string, serverState: string) { await serverStartPromise; if (await startCircuit()) { - return circuit.updateRootComponents(operations); + return circuit.updateRootComponents(operations, serverState); } } diff --git a/src/Components/Web.JS/src/Boot.WebAssembly.Common.ts b/src/Components/Web.JS/src/Boot.WebAssembly.Common.ts index 4240be05c307..46311e7f1c8f 100644 --- a/src/Components/Web.JS/src/Boot.WebAssembly.Common.ts +++ b/src/Components/Web.JS/src/Boot.WebAssembly.Common.ts @@ -156,8 +156,9 @@ async function startCore(components: RootComponentManager initialUpdatePromise; - Blazor._internal.updateRootComponents = (operations: string) => - Blazor._internal.dotNetExports?.UpdateRootComponentsCore(operations); + Blazor._internal.updateRootComponents = (operations: string, webAssemblyState: string) => { + Blazor._internal.dotNetExports?.UpdateRootComponentsCore(operations, webAssemblyState); + }; Blazor._internal.endUpdateRootComponents = (batchId: number) => components.onAfterUpdateRootComponents?.(batchId); @@ -227,7 +228,7 @@ export function hasLoadedWebAssemblyPlatform(): boolean { return loadedWebAssemblyPlatform; } -export function updateWebAssemblyRootComponents(operations: string): void { +export function updateWebAssemblyRootComponents(operations: string, webAssemblyState: string): void { if (!startPromise) { throw new Error('Blazor WebAssembly has not started.'); } @@ -237,20 +238,20 @@ export function updateWebAssemblyRootComponents(operations: string): void { } if (!started) { - scheduleAfterStarted(operations); + scheduleAfterStarted(operations, webAssemblyState); } else { - Blazor._internal.updateRootComponents(operations); + Blazor._internal.updateRootComponents(operations, webAssemblyState); } } -async function scheduleAfterStarted(operations: string): Promise { +async function scheduleAfterStarted(operations: string, webAssemblyState: string): Promise { await startPromise; if (!Blazor._internal.updateRootComponents) { throw new Error('Blazor WebAssembly has not initialized.'); } - Blazor._internal.updateRootComponents(operations); + Blazor._internal.updateRootComponents(operations, webAssemblyState); } function invokeJSJson(identifier: string, targetInstanceId: number, resultType: number, argsJson: string, asyncHandle: number, callType: number): string | null { diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 89d5d5535fd5..4e3bf21e6fe4 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -59,7 +59,7 @@ export interface IBlazor { receiveByteArray?: (id: number, data: Uint8Array) => void; getPersistedState?: () => string; getInitialComponentsUpdate?: () => Promise; - updateRootComponents?: (operations: string) => void; + updateRootComponents?: (operations: string, webAssemblyState: string) => void; endUpdateRootComponents?: (batchId: number) => void; attachRootComponentToElement?: (arg0: any, arg1: any, arg2: any, arg3: any) => void; registeredComponents?: { @@ -87,7 +87,7 @@ export interface IBlazor { EndInvokeJS: (argsJson: string) => void; BeginInvokeDotNet: (callId: string | null, assemblyNameOrDotNetObjectId: string, methodIdentifier: string, argsJson: string) => void; ReceiveByteArrayFromJS: (id: number, data: Uint8Array) => void; - UpdateRootComponentsCore: (operationsJson: string) => void; + UpdateRootComponentsCore: (operationsJson: string, appState: string) => void; } // APIs invoked by hot reload diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index 36911f4ce3bb..cc2a536a4129 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -3,7 +3,7 @@ import { internalFunctions as navigationManagerFunctions } from '../../Services/NavigationManager'; import { toLogicalRootCommentElement, LogicalElement, toLogicalElement } from '../../Rendering/LogicalElements'; -import { ServerComponentDescriptor, descriptorToMarker } from '../../Services/ComponentDescriptorDiscovery'; +import { ServerComponentDescriptor, descriptorToMarker, discoverServerPersistedState } from '../../Services/ComponentDescriptorDiscovery'; import { HttpTransportType, HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'; import { getAndRemovePendingRootComponentContainer } from '../../Rendering/JSRootComponents'; import { RootComponentManager } from '../../Services/RootComponentManager'; @@ -41,8 +41,6 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { private _startPromise?: Promise; - private _firstUpdate = true; - private _renderingFailed = false; private _disposePromise?: Promise; @@ -57,6 +55,8 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { private _persistedCircuitState?: { components: string, applicationState: string }; + private _isFirstRender = true; + public constructor( componentManager: RootComponentManager, appState: string, @@ -84,13 +84,12 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { return this._startPromise; } - public updateRootComponents(operations: string): Promise | undefined { - if (this._firstUpdate) { - // Only send the application state on the first update. - this._firstUpdate = false; + public updateRootComponents(operations: string, serverState: string): Promise | undefined { + if (this._isFirstRender) { + this._isFirstRender = false; return this._connection?.send('UpdateRootComponents', operations, this._applicationState); } else { - return this._connection?.send('UpdateRootComponents', operations, ''); + return this._connection?.send('UpdateRootComponents', operations, serverState); } } diff --git a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts index b72712295cf8..bc5f5b233592 100644 --- a/src/Components/Web.JS/src/Services/WebRootComponentManager.ts +++ b/src/Components/Web.JS/src/Services/WebRootComponentManager.ts @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { ComponentDescriptor, ComponentMarker, descriptorToMarker, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; +import { ComponentDescriptor, ComponentMarker, descriptorToMarker, discoverServerPersistedState, discoverWebAssemblyPersistedState, WebAssemblyServerOptions } from './ComponentDescriptorDiscovery'; import { isRendererAttached, registerRendererAttachedListener } from '../Rendering/WebRendererInteropMethods'; import { WebRendererId } from '../Rendering/WebRendererId'; import { DescriptorHandler } from '../Rendering/DomMerging/DomSync'; @@ -93,7 +93,8 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent public onEnhancedNavigationCompleted() { // Root components may now be ready for activation if they had been previously // skipped for activation due to an enhanced navigation being underway. - this.rootComponentsMayRequireRefresh(); + // Only look for state after the page has finished loading. + this.rootComponentsMayRequireRefresh(true); } public setWebAssemblyOptions(webAssemblyOptions: WebAssemblyServerOptions | undefined): void { @@ -196,7 +197,7 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent // should be reflected in an interactive component renderer. // Examples include component descriptors updating, document content changing, // or an interactive renderer attaching for the first time. - private rootComponentsMayRequireRefresh() { + private rootComponentsMayRequireRefresh(discoverNewState = false) { if (this._isComponentRefreshPending) { return; } @@ -208,7 +209,7 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent // refreshRootComponents. queueMicrotask(() => { this._isComponentRefreshPending = false; - this.refreshRootComponents(this._rootComponentsBySsrComponentId.values()); + this.refreshRootComponents(this._rootComponentsBySsrComponentId.values(), discoverNewState); }); } @@ -267,10 +268,16 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent return false; } - private refreshRootComponents(components: Iterable) { + private refreshRootComponents(components: Iterable, discoverNewState = false) { const operationsByRendererId = new Map(); - + const rendererIds: Set = new Set(); for (const component of components) { + if (discoverNewState && component.assignedRendererId !== undefined) { + // Capture the renderer IDs for the available components to determine the + // effective render modes in the document for discovering new persisted state. + rendererIds.add(component.assignedRendererId); + } + const operation = this.determinePendingOperation(component); if (!operation) { continue; @@ -290,6 +297,35 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent operations.push(operation); } + let serverState = ''; + let webAssemblyState = ''; + if (discoverNewState) { + for (const rendererId of rendererIds) { + if (rendererId === WebRendererId.Server) { + // We have server components. Try to discover the persisted state for them + // and if there are no updates on this batch, push an empty set of operations + // to ensure we send the state over. Same for wasm below. + serverState = discoverServerPersistedState(document) || ''; + if (serverState && serverState !== '') { + const ops = operationsByRendererId.get(WebRendererId.Server); + if (!ops) { + operationsByRendererId.set(WebRendererId.Server, []); + } + } + } else if (rendererId === WebRendererId.WebAssembly) { + webAssemblyState = discoverWebAssemblyPersistedState(document) || ''; + if (webAssemblyState && webAssemblyState !== '') { + const ops = operationsByRendererId.get(WebRendererId.WebAssembly); + if (!ops) { + operationsByRendererId.set(WebRendererId.WebAssembly, []); + } + } + } else { + throw new Error(`Unexpected renderer ID '${rendererId}' encountered while discovering new state.`); + } + } + } + for (const [rendererId, operations] of operationsByRendererId) { const batch: RootComponentOperationBatch = { batchId: this._nextOperationBatchId++, @@ -299,20 +335,20 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent const batchJson = JSON.stringify(batch); if (rendererId === WebRendererId.Server) { - updateServerRootComponents(batchJson); + updateServerRootComponents(batchJson, serverState); } else { - this.updateWebAssemblyRootComponents(batchJson); + this.updateWebAssemblyRootComponents(batchJson, webAssemblyState); } } this.circuitMayHaveNoRootComponents(); } - private updateWebAssemblyRootComponents(operationsJson: string) { + private updateWebAssemblyRootComponents(operationsJson: string, webAssemblyState: string) { if (isFirstUpdate()) { resolveInitialUpdate(operationsJson); } else { - updateWebAssemblyRootComponents(operationsJson); + updateWebAssemblyRootComponents(operationsJson, webAssemblyState); } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index 5cb848752449..afc21cf445ab 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -135,7 +135,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl new PrerenderComponentApplicationStore(); manager.SetPlatformRenderMode(RenderMode.InteractiveWebAssembly); - await manager.RestoreStateAsync(store); + await manager.RestoreStateAsync(store, RestoreContext.InitialValue); var tcs = new TaskCompletionSource(); using (cancellationToken.Register(() => tcs.TrySetResult())) diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs index 08bf6a23a278..242e28ed7ba2 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices.JavaScript; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; @@ -26,6 +27,7 @@ internal sealed partial class WebAssemblyRenderer : WebRenderer private readonly Dispatcher _dispatcher; private readonly ResourceAssetCollection _resourceCollection; private readonly IInternalJSImportMethods _jsMethods; + private readonly ComponentStatePersistenceManager _componentStatePersistenceManager; private static readonly RendererInfo _componentPlatform = new("WebAssembly", isInteractive: true); public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollection resourceCollection, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop) @@ -33,6 +35,7 @@ public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollec { _logger = loggerFactory.CreateLogger(); _jsMethods = serviceProvider.GetRequiredService(); + _componentStatePersistenceManager = serviceProvider.GetRequiredService(); // if SynchronizationContext.Current is null, it means we are on the single-threaded runtime _dispatcher = WebAssemblyDispatcher._mainSynchronizationContext == null @@ -46,9 +49,20 @@ public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollec } [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "These are root components which belong to the user and are in assemblies that don't get trimmed.")] - private void OnUpdateRootComponents(RootComponentOperationBatch batch) + private void OnUpdateRootComponents(RootComponentOperationBatch batch, string appState) { var webRootComponentManager = GetOrCreateWebRootComponentManager(); + TaskCompletionSource? taskCompletionSource = null; + var stateUpdateTask = Task.CompletedTask; + var store = !string.IsNullOrEmpty(appState) ? new PrerenderComponentApplicationStore(appState) : null; + if (store != null) + { + taskCompletionSource = new TaskCompletionSource(); + stateUpdateTask = EnqueueRestore(taskCompletionSource.Task, _componentStatePersistenceManager, store); + } + + webRootComponentManager.SetCurrentUpdateTask(stateUpdateTask); + for (var i = 0; i < batch.Operations.Length; i++) { var operation = batch.Operations[i]; @@ -73,10 +87,21 @@ private void OnUpdateRootComponents(RootComponentOperationBatch batch) break; } } + taskCompletionSource?.SetResult(); + store?.ExistingState.Clear(); NotifyEndUpdateRootComponents(batch.BatchId); } + private static async Task EnqueueRestore( + Task task, + ComponentStatePersistenceManager componentStatePersistenceManager, + PrerenderComponentApplicationStore store) + { + await task; + await componentStatePersistenceManager.RestoreStateAsync(store, RestoreContext.ValueUpdate); + } + protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) => RenderMode.InteractiveWebAssembly; public void NotifyEndUpdateRootComponents(long batchId) diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs index b033a3fd5849..9e82364dd6d2 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs @@ -24,7 +24,7 @@ internal sealed partial class DefaultWebAssemblyJSRuntime : WebAssemblyJSRuntime public ElementReferenceContext ElementReferenceContext { get; } - public event Action? OnUpdateRootComponents; + public event Action? OnUpdateRootComponents; [DynamicDependency(nameof(InvokeDotNet))] [DynamicDependency(nameof(EndInvokeJS))] @@ -94,12 +94,12 @@ public static void BeginInvokeDotNet(string? callId, string assemblyNameOrDotNet [SupportedOSPlatform("browser")] [JSExport] - public static void UpdateRootComponentsCore(string operationsJson) + public static void UpdateRootComponentsCore(string operationsJson, string appState) { try { var operations = DeserializeOperations(operationsJson); - Instance.OnUpdateRootComponents?.Invoke(operations); + Instance.OnUpdateRootComponents?.Invoke(operations, appState); } catch (Exception ex) { diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs index a095b6bafc67..1b76f3adf2ad 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs @@ -38,41 +38,69 @@ protected override void InitializeAsyncCore() [Fact] public void CanResumeCircuitAfterDisconnection() { + // Initial state: NonPersistedCounter should be 5 + Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Increment both counters Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + var javascript = (IJavaScriptExecutor)Browser; javascript.ExecuteScript("window.replaceReconnectCallback()"); TriggerReconnectAndInteract(javascript); - // Can dispatch events after reconnect + // After first reconnection: + // - Persistent counter should be incremented + // - Non-persisted counter should be reset to 0 Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Increment non-persisted counter again + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text); javascript.ExecuteScript("resetReconnect()"); TriggerReconnectAndInteract(javascript); - // Ensure that reconnection events are repeatable + // After second reconnection: Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); } [Fact] public void CanResumeCircuitFromJavaScript() { + // Initial state: NonPersistedCounter should be 5 + Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Increment both counters Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + var javascript = (IJavaScriptExecutor)Browser; TriggerClientPauseAndInteract(javascript); - // Can dispatch events after reconnect + // After first reconnection: Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Increment non-persisted counter again + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text); TriggerClientPauseAndInteract(javascript); - // Ensure that reconnection events are repeatable + // After second reconnection: Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); } [Fact] diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index c17a07065691..3322c184ef7a 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1061,6 +1061,7 @@ public void CanPersistPrerenderedStateDeclaratively_Server() Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text); Browser.Equal("42", () => Browser.FindElement(By.Id("custom-server")).Text); Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text); + Browser.Equal("prerender-disabled-not-restored", () => Browser.FindElement(By.Id("prerendering-disabled-server")).Text); } [Fact] @@ -1080,6 +1081,7 @@ public void CanPersistPrerenderedStateDeclaratively_WebAssembly() Browser.Equal("restored", () => Browser.FindElement(By.Id("wasm")).Text); Browser.Equal("42", () => Browser.FindElement(By.Id("custom-wasm")).Text); Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-wasm")).Text); + Browser.Equal("prerender-disabled-not-restored", () => Browser.FindElement(By.Id("prerendering-disabled-wasm")).Text); } [Fact] @@ -1099,6 +1101,7 @@ public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnWebAssembly() Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); Browser.Equal("42", () => Browser.FindElement(By.Id("custom-auto")).Text); Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto")).Text); + Browser.Equal("prerender-disabled-not-restored", () => Browser.FindElement(By.Id("prerendering-disabled-auto")).Text); } [Fact] @@ -1129,8 +1132,13 @@ public void CanPersistPrerenderedState_ServicesState_PersistsOnServer(string mod 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); + + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-server-state")).Text); + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-auto-state")).Text); + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-wasm-state")).Text); } [Theory] @@ -1147,6 +1155,10 @@ public void CanPersistPrerenderedState_ServicesState_PersistsOnWasm(string mode, 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); + + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-server-state")).Text); + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-auto-state")).Text); + Browser.Equal("not restored", () => Browser.FindElement(By.Id("filtered-wasm-state")).Text); } [Fact] @@ -1161,6 +1173,7 @@ public void CanPersistPrerenderedStateDeclaratively_Auto_PersistsOnServer() Browser.Equal("restored", () => Browser.FindElement(By.Id("auto")).Text); Browser.Equal("42", () => Browser.FindElement(By.Id("custom-auto")).Text); Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-auto")).Text); + Browser.Equal("prerender-disabled-not-restored", () => Browser.FindElement(By.Id("prerendering-disabled-auto")).Text); } [Fact] diff --git a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs index d49e4fbc5704..a05fbfa05979 100644 --- a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs +++ b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs @@ -107,6 +107,130 @@ public void CanRenderComponentWithPersistedState(bool suppressEnhancedNavigation } } + // Validates that we can use persisted state across server, webassembly, and auto modes, with and without + // streaming rendering. + // For streaming rendering, we validate that the state is captured and restored after streaming completes. + // For enhanced navigation we validate that the state is captured at the time components are rendered for + // the first time on the page. + // For auto mode, we validate that the state is captured and restored for both server and wasm runtimes. + // In each case, we validate that the state is available until the initial set of components first render reaches quiescence. Similar to how it works for Server and WebAssembly. + // For server we validate that the state is provided every time a circuit is initialized. + [Theory] + [InlineData(typeof(InteractiveServerRenderMode), (string)null)] + [InlineData(typeof(InteractiveServerRenderMode), "ServerStreaming")] + [InlineData(typeof(InteractiveWebAssemblyRenderMode), (string)null)] + [InlineData(typeof(InteractiveWebAssemblyRenderMode), "WebAssemblyStreaming")] + [InlineData(typeof(InteractiveAutoRenderMode), (string)null)] + [InlineData(typeof(InteractiveAutoRenderMode), "AutoStreaming")] + public void CanUpdateComponentsWithPersistedStateAndEnhancedNavUpdates( + Type renderMode, + string streaming) + { + var mode = renderMode switch + { + var t when t == typeof(InteractiveServerRenderMode) => "server", + var t when t == typeof(InteractiveWebAssemblyRenderMode) => "wasm", + var t when t == typeof(InteractiveAutoRenderMode) => "auto", + _ => throw new ArgumentException($"Unknown render mode: {renderMode.Name}") + }; + + // Navigate to a page without components first to make sure that we exercise rendering components + // with enhanced navigation on. + NavigateToInitialPage(streaming, mode); + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + Browser.Click(By.Id("call-blazor-start")); + Browser.Click(By.Id("page-with-components-link-and-declarative-state")); + + if (mode != "auto") + { + RenderComponentsWithDeclarativePersistentStateAndValidate(mode, renderMode, streaming, stateValue: "other"); + } + else + { + // For auto mode, validate that the state is persisted for both runtimes and is able + // to be loaded on server and wasm. + RenderComponentsWithDeclarativePersistentStateAndValidate(mode, renderMode, streaming, interactiveRuntime: "server", stateValue: "other"); + + UnblockWebAssemblyResourceLoad(); + Browser.Navigate().Refresh(); + NavigateToInitialPage(streaming, mode); + Browser.Click(By.Id("call-blazor-start")); + Browser.Click(By.Id("page-with-components-link-and-declarative-state")); + + RenderComponentsWithDeclarativePersistentStateAndValidate(mode, renderMode, streaming, interactiveRuntime: "wasm", stateValue: "other"); + } + + void NavigateToInitialPage(string streaming, string mode) + { + if (streaming == null) + { + Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&suppress-autostart"); + } + else + { + Navigate($"subdir/persistent-state/page-no-components?render-mode={mode}&streaming-id={streaming}&suppress-autostart"); + } + } + } + + private void RenderComponentsWithDeclarativePersistentStateAndValidate( + string mode, + Type renderMode, + string streaming, + string interactiveRuntime = null, + string stateValue = "restored") + { + AssertDeclarativePageState( + mode: mode, + renderMode: renderMode.Name, + interactive: streaming == null, + stateValue: stateValue, + streamingId: streaming, + streamingCompleted: false, + interactiveRuntime: interactiveRuntime); + + if (streaming == null) + { + Browser.Click(By.Id("enhanced-nav-update")); + AssertDeclarativePageState( + mode: mode, + renderMode: renderMode.Name, + interactive: streaming == null, + stateValue: "updated", + streamingId: streaming, + streamingCompleted: false, + interactiveRuntime: interactiveRuntime); + return; + } + + Browser.Click(By.Id("end-streaming")); + + AssertDeclarativePageState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + stateValue: stateValue, + streamingId: streaming, + streamingCompleted: true, + interactiveRuntime: interactiveRuntime); + + Browser.Click(By.Id("enhanced-nav-update")); + Browser.Click(By.Id("end-streaming")); + AssertDeclarativePageState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + stateValue: "updated", + streamingId: streaming, + streamingCompleted: true, + interactiveRuntime: interactiveRuntime); + + return; + } + [Theory] [InlineData((string)null)] [InlineData("ServerStreaming")] @@ -236,4 +360,29 @@ private void AssertPageState( Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text); } } + + private void AssertDeclarativePageState( + string mode, + string renderMode, + bool interactive, + string stateValue, + string streamingId = null, + bool streamingCompleted = false, + string interactiveRuntime = null) + { + Browser.Equal($"Render mode: {renderMode}", () => Browser.FindElement(By.Id("render-mode")).Text); + Browser.Equal($"Streaming id:{streamingId}", () => Browser.FindElement(By.Id("streaming-id")).Text); + Browser.Equal($"Interactive: {interactive}", () => Browser.FindElement(By.Id("interactive")).Text); + if (streamingId == null || streamingCompleted) + { + interactiveRuntime = !interactive ? "none" : mode == "server" || mode == "wasm" ? mode : (interactiveRuntime ?? throw new InvalidOperationException("Specify interactiveRuntime for auto mode")); + + Browser.Equal($"Interactive runtime: {interactiveRuntime}", () => Browser.FindElement(By.Id("interactive-runtime")).Text); + Browser.Equal($"State value:{stateValue}", () => Browser.FindElement(By.Id("state-value")).Text); + } + else + { + Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text); + } + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithDeclarativeEnhancedNavigationPersistentComponents.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithDeclarativeEnhancedNavigationPersistentComponents.razor new file mode 100644 index 000000000000..c314dac85843 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithDeclarativeEnhancedNavigationPersistentComponents.razor @@ -0,0 +1,64 @@ +@page "/persistent-state/page-with-declarative-state-components" +@using TestContentPackage.PersistentComponents + +

Declarative persistent component with enhanced navigation updates

+ +

+ This page can render one or more components with different render modes and different streaming rendering modes. + It accepts a render-mode query parameter which is used to determine the render mode for the components on the page. + It also accepts a streaming-id query parameter which is used to select whether to render a component that uses streaming rendering or not. + The rendered components display the behavior of [UpdateOnEnhancedNavigation(true)] properties of [PersistentState] +

+ +

Render mode: @_renderMode?.GetType()?.Name

+

Streaming id:@StreamingId

+@if (_renderMode != null) +{ + @if (!string.IsNullOrEmpty(StreamingId)) + { + + } + else + { + + } +} +@if (!string.IsNullOrEmpty(StreamingId)) +{ + End streaming +} + +Go to page with no components + + +@code { + + private IComponentRenderMode _renderMode; + + [SupplyParameterFromQuery(Name = "render-mode")] public string RenderMode { get; set; } + + [SupplyParameterFromQuery(Name = "streaming-id")] public string StreamingId { get; set; } + + [SupplyParameterFromQuery(Name = "server-state")] public string ServerState { get; set; } + + protected override void OnInitialized() + { + if (!string.IsNullOrEmpty(RenderMode)) + { + switch (RenderMode) + { + case "server": + _renderMode = new InteractiveServerRenderMode(true); + break; + case "wasm": + _renderMode = new InteractiveWebAssemblyRenderMode(true); + break; + case "auto": + _renderMode = new InteractiveAutoRenderMode(true); + break; + default: + throw new ArgumentException($"Invalid render mode: {RenderMode}"); + } + } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor index fe7693c59523..a72bdbe97778 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/PageWithoutComponents.razor @@ -6,6 +6,7 @@ Go to page with components and state +Go to page with declarative state components @code { [SupplyParameterFromQuery(Name = "render-mode")] public string RenderMode { get; set; } diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor index a0a8400c17cb..d62de87a3bc8 100644 --- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -1,6 +1,7 @@ 

Application state is @Value

Custom value is @CustomValue

Render mode: @_renderMode

+

Prerendering disabled: @PrerenderingDisabledValue

@code { [Parameter, EditorRequired] @@ -15,6 +16,9 @@ [PersistentState] public int CustomValue { get; set; } + [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)] + public string PrerenderingDisabledValue { get; set; } + private string _renderMode = "SSR"; protected override void OnInitialized() @@ -25,5 +29,7 @@ CustomValue = !RendererInfo.IsInteractive ? 42 : 0; } _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server"; + + PrerenderingDisabledValue ??= !RendererInfo.IsInteractive ? "prerender-disabled-initial" : "prerender-disabled-not-restored"; } } diff --git a/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor b/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor index fb32e52f36a8..e8d45fd9958f 100644 --- a/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor +++ b/src/Components/test/testassets/TestContentPackage/PersistServicesState.razor @@ -5,8 +5,11 @@ @inject InteractiveAutoService InteractiveAutoState

Interactive server state is @InteractiveServerState.State

+

Filtered server state is @InteractiveServerState.FilteredState

Interactive webassembly state is @InteractiveWebAssemblyState.State

+

Filtered webassembly state is @InteractiveWebAssemblyState.FilteredState

Interactive auto state is @InteractiveAutoState.State

+

Filtered auto state is @InteractiveAutoState.FilteredState

Render mode: @_renderMode

@@ -20,12 +23,18 @@ if (!RendererInfo.IsInteractive) { InteractiveServerState.State = "Server state"; + InteractiveServerState.FilteredState = "Filtered server state"; InteractiveWebAssemblyState.State = "WebAssembly state"; + InteractiveWebAssemblyState.FilteredState = "Filtered webassembly state"; InteractiveAutoState.State = "Auto state"; + InteractiveAutoState.FilteredState = "Filtered auto state"; }else { InteractiveServerState.State ??= "not restored"; + InteractiveServerState.FilteredState ??= "not restored"; InteractiveWebAssemblyState.State ??= "not restored"; + InteractiveWebAssemblyState.FilteredState ??= "not restored"; + InteractiveAutoState.FilteredState ??= "not restored"; InteractiveAutoState.State ??= "not restored"; } } diff --git a/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithDeclarativePersistentState.razor b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithDeclarativePersistentState.razor new file mode 100644 index 000000000000..56dff42b75cf --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithDeclarativePersistentState.razor @@ -0,0 +1,40 @@ +

Non streaming component with persistent state

+ +

This component demonstrates state persistence in the absence of streaming rendering. +When the component renders it will try to restore the state and if present display that +it succeeded in doing so and the restored value. +If the state is not present, it will indicate it didn't find it and display a "fresh" +value.

+ +

Interactive: @(RendererInfo.IsInteractive)

+

Interactive runtime: @_interactiveRuntime

+

State value:@EnhancedNavState

+ +With updated server state +
+ +@code { + private string _interactiveRuntime; + + [Inject] public PersistentComponentState PersistentComponentState { get; set; } + [Inject] public NavigationManager Navigation { get; set; } + + [Parameter] public string ServerState { get; set; } + + [PersistentState(AllowUpdates = true)] + public string EnhancedNavState { get; set; } + + protected override void OnInitialized() + { + if (!RendererInfo.IsInteractive) + { + _interactiveRuntime = "none"; + EnhancedNavState = ServerState; + } + else + { + EnhancedNavState ??= "not found"; + _interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server"; + } + } +} diff --git a/src/Components/test/testassets/TestContentPackage/PersistentComponents/StreamingComponentWithDeclarativePersistentState.razor b/src/Components/test/testassets/TestContentPackage/PersistentComponents/StreamingComponentWithDeclarativePersistentState.razor new file mode 100644 index 000000000000..d9dcd8b18f8c --- /dev/null +++ b/src/Components/test/testassets/TestContentPackage/PersistentComponents/StreamingComponentWithDeclarativePersistentState.razor @@ -0,0 +1,59 @@ +@using Components.TestServer.Services +@attribute [StreamRendering] +

Streaming component with persistent state

+ +

This component demonstrates state persistence alongside streaming rendering. When the component first renders, it'll emit a message "streaming" and yield until its notified via a call to /persistent-state/end-streaming. When the component renders it will try to restore the state and if present display that it succeded in doing so and the restored value. If the state is not present, it will indicate it didn't find it and display a "fresh" value.

+ +

Interactive: @(RendererInfo.IsInteractive)

+@if (_streaming) +{ +

Streaming: @_streaming

+} +else +{ +

Interactive runtime: @_interactiveRuntime

+

State value:@EnhancedNavState

+} + +With updated server state +
+ + +@code { + + private bool _streaming; + private string _interactiveRuntime; + + [Inject] public AsyncOperationService StreamingManager { get; set; } + + [Inject] public NavigationManager Navigation { get; set; } + + [Parameter] public string StreamingId { get; set; } + + [Parameter] public string ServerState { get; set; } + + [PersistentState(AllowUpdates = true)] + public string EnhancedNavState { get; set; } + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(StreamingId)) + { + throw new InvalidOperationException("StreamingId is required."); + } + + if (!RendererInfo.IsInteractive) + { + _interactiveRuntime = "none"; + _streaming = true; + await StreamingManager.Start(StreamingId); + _streaming = false; + EnhancedNavState = ServerState; + } + else + { + EnhancedNavState ??= "not found"; + _interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server"; + } + } +} diff --git a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor index 49f840e487f4..9b7302adb783 100644 --- a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor +++ b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor @@ -4,17 +4,24 @@ @if (RendererInfo.IsInteractive) { -

Interactive @RendererInfo.IsInteractive

+

Interactive @RendererInfo.IsInteractive

}

Current render GUID: @Guid.NewGuid().ToString()

+

Current count: @State.Count

+

Non-persisted counter: @NonPersistedCounter

+ @code { - [PersistentState] public CounterState State { get; set; } + [PersistentState] + public CounterState State { get; set; } + + [PersistentState(RestoreBehavior = RestoreBehavior.SkipLastSnapshot)] + public int NonPersistedCounter { get; set; } public class CounterState { @@ -25,10 +32,21 @@ { // State is preserved across disconnections State ??= new CounterState(); + + // Initialize non-persisted counter to 5 during SSR (before interactivity) + if (!RendererInfo.IsInteractive) + { + NonPersistedCounter = 5; + } } private void IncrementCount() { State.Count = State.Count + 1; } + + private void IncrementNonPersistedCount() + { + NonPersistedCounter = NonPersistedCounter + 1; + } } diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs index 00be5ba2275c..963427a4bb9c 100644 --- a/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveAutoService.cs @@ -12,4 +12,7 @@ public class InteractiveAutoService { [PersistentState] public string State { get; set; } + + [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)] + public string FilteredState { get; set; } } diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs index c3405d258090..8ee8dfc049bd 100644 --- a/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveServerService.cs @@ -11,4 +11,7 @@ public class InteractiveServerService { [PersistentState] public string State { get; set; } + + [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)] + public string FilteredState { get; set; } } diff --git a/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs b/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs index 2909b4bdd959..a44e038b23a2 100644 --- a/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs +++ b/src/Components/test/testassets/TestContentPackage/Services/InteractiveWebAssemblyService.cs @@ -12,4 +12,7 @@ public class InteractiveWebAssemblyService { [PersistentState] public string State { get; set; } + + [PersistentState(RestoreBehavior = RestoreBehavior.SkipInitialValue)] + public string FilteredState { get; set; } } diff --git a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs index d564233552c9..8e3f16969ac1 100644 --- a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components; -internal sealed class ProtectedPrerenderComponentApplicationStore : PrerenderComponentApplicationStore +internal sealed class ProtectedPrerenderComponentApplicationStore : PrerenderComponentApplicationStore, IClearableStore { private IDataProtector _protector = default!; // Assigned in all constructor paths @@ -39,4 +39,14 @@ private void CreateProtector(IDataProtectionProvider dataProtectionProvider) => public override bool SupportsRenderMode(IComponentRenderMode renderMode) => renderMode is null || renderMode is InteractiveServerRenderMode || renderMode is InteractiveAutoRenderMode; + + public void Clear() + { + ExistingState.Clear(); + } +} + +internal interface IClearableStore : IPersistentComponentStateStore +{ + void Clear(); } From b51e0289207fb200e56899f16b62fa50669bbf79 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 4 Aug 2025 11:11:16 +0200 Subject: [PATCH 2/2] Disable AVX512 vectorization on the test pipeline --- .azure/pipelines/components-e2e-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.azure/pipelines/components-e2e-tests.yml b/.azure/pipelines/components-e2e-tests.yml index fc0acb70ba41..2386635fa813 100644 --- a/.azure/pipelines/components-e2e-tests.yml +++ b/.azure/pipelines/components-e2e-tests.yml @@ -98,6 +98,8 @@ jobs: exit 1 fi displayName: Run E2E tests + env: + DOTNET_EnableAVX512: 0 - script: .dotnet/dotnet test ./src/Components/test/E2ETest -c $(BuildConfiguration) --no-build --filter 'Quarantined=true' -p:RunQuarantinedTests=true -p:VsTestUseMSBuildOutput=false --logger:"trx%3BLogFileName=Microsoft.AspNetCore.Components.E2ETests.trx" @@ -105,6 +107,8 @@ jobs: --results-directory $(Build.SourcesDirectory)/artifacts/TestResults/$(BuildConfiguration)/Quarantined displayName: Run Quarantined E2E tests continueOnError: true + env: + DOTNET_EnableAVX512: 0 - task: PublishTestResults@2 displayName: Publish E2E Test Results inputs: