diff --git a/activate.sh b/activate.sh old mode 100644 new mode 100755 diff --git a/src/Components/Components/src/IPersistentComponentStateScenario.cs b/src/Components/Components/src/IPersistentComponentStateScenario.cs new file mode 100644 index 000000000000..2d67c2a86a81 --- /dev/null +++ b/src/Components/Components/src/IPersistentComponentStateScenario.cs @@ -0,0 +1,16 @@ +// 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 scenario for persistent component state restoration. +/// +public interface IPersistentComponentStateScenario +{ + /// + /// Gets a value indicating whether callbacks for this scenario can be invoked multiple times. + /// If false, callbacks are automatically unregistered after first invocation. + /// + bool IsRecurring { get; } +} \ No newline at end of file diff --git a/src/Components/Components/src/IPersistentStateFilter.cs b/src/Components/Components/src/IPersistentStateFilter.cs new file mode 100644 index 000000000000..44c33faaad29 --- /dev/null +++ b/src/Components/Components/src/IPersistentStateFilter.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components; + +/// +/// Defines filtering logic for persistent component state restoration. +/// +public interface IPersistentStateFilter +{ + /// + /// Determines whether state should be restored for the given scenario. + /// + /// The restoration scenario. + /// True if state should be restored; otherwise false. + bool ShouldRestore(IPersistentComponentStateScenario scenario); +} \ No newline at end of file diff --git a/src/Components/Components/src/PersistentComponentState.cs b/src/Components/Components/src/PersistentComponentState.cs index a3dd2fdddc81..936f74cb7ec9 100644 --- a/src/Components/Components/src/PersistentComponentState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -16,6 +16,7 @@ public class PersistentComponentState private readonly IDictionary _currentState; private readonly List _registeredCallbacks; + private readonly List _restoringCallbacks = new(); internal PersistentComponentState( IDictionary currentState, @@ -27,6 +28,11 @@ internal PersistentComponentState( internal bool PersistingState { get; set; } + /// + /// Gets the current restoration scenario, if any. + /// + public IPersistentComponentStateScenario? CurrentScenario { get; internal set; } + internal void InitializeExistingState(IDictionary existingState) { if (_existingState != null) @@ -155,6 +161,73 @@ internal bool TryTakeFromJson(string key, [DynamicallyAccessedMembers(JsonSerial } } + /// + /// Registers a callback to be invoked when state is restored and the filter allows the current scenario. + /// + /// The filter to determine if the callback should be invoked for a scenario. + /// The callback to invoke during restoration. + /// A subscription that can be disposed to unregister the callback. + public RestoringComponentStateSubscription RegisterOnRestoring( + IPersistentStateFilter filter, + Action callback) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(callback); + + var registration = new RestoreComponentStateRegistration(filter, callback); + _restoringCallbacks.Add(registration); + + // If we already have a current scenario and the filter matches, invoke immediately + if (CurrentScenario != null && filter.ShouldRestore(CurrentScenario)) + { + callback(); + } + + return new RestoringComponentStateSubscription(_restoringCallbacks, filter, callback); + } + + /// + /// Updates the existing state with new state for subsequent restoration calls. + /// Only allowed when existing state is empty (fully consumed). + /// + /// New state dictionary to replace existing state. + /// The restoration scenario context. + internal void UpdateExistingState(IDictionary newState, IPersistentComponentStateScenario scenario) + { + ArgumentNullException.ThrowIfNull(newState); + ArgumentNullException.ThrowIfNull(scenario); + + if (_existingState != null && _existingState.Count > 0) + { + throw new InvalidOperationException("Cannot update existing state when state dictionary is not empty. State must be fully consumed before updating."); + } + + _existingState = newState; + CurrentScenario = scenario; + + // Invoke matching restoration callbacks + InvokeRestoringCallbacks(scenario); + } + + private void InvokeRestoringCallbacks(IPersistentComponentStateScenario scenario) + { + for (int i = _restoringCallbacks.Count - 1; i >= 0; i--) + { + var registration = _restoringCallbacks[i]; + + if (registration.Filter.ShouldRestore(scenario)) + { + registration.Callback(); + + // Remove callback if scenario is not recurring (one-time scenarios) + if (!scenario.IsRecurring) + { + _restoringCallbacks.RemoveAt(i); + } + } + } + } + private bool TryTake(string key, out byte[]? value) { ArgumentNullException.ThrowIfNull(key); diff --git a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs index 72c1ca666411..e9bba77856db 100644 --- a/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs +++ b/src/Components/Components/src/PersistentState/ComponentStatePersistenceManager.cs @@ -17,6 +17,7 @@ public class ComponentStatePersistenceManager private bool _stateIsPersisted; private readonly PersistentServicesRegistry? _servicesRegistry; private readonly Dictionary _currentState = new(StringComparer.Ordinal); + private bool _isFirstRestore = true; /// /// Initializes a new instance of . @@ -55,9 +56,43 @@ 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, scenario: null); + } + + /// + /// Restores component state from the given store with scenario context. + /// + /// The store to restore state from. + /// The restoration scenario context. + /// A task that completes when state restoration is finished. + public async Task RestoreStateAsync( + IPersistentComponentStateStore store, + IPersistentComponentStateScenario? scenario) { var data = await store.GetPersistedStateAsync(); - State.InitializeExistingState(data); + + if (_isFirstRestore) + { + // First-time initialization + State.InitializeExistingState(data); + _isFirstRestore = false; + } + else + { + // Scenario-based update - only if we have a scenario + if (scenario != null) + { + State.UpdateExistingState(data, scenario); + } + else + { + // This is a second call without a scenario, which should fail + // (maintaining the original behavior for backward compatibility) + throw new InvalidOperationException("PersistentComponentState already initialized."); + } + } + _servicesRegistry?.Restore(State); } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 33bf7c236923..94c2438ca8a6 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -21,3 +21,14 @@ static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCo static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.GetComponentKey() -> object? +Microsoft.AspNetCore.Components.IPersistentComponentStateScenario +Microsoft.AspNetCore.Components.IPersistentComponentStateScenario.IsRecurring.get -> bool +Microsoft.AspNetCore.Components.IPersistentStateFilter +Microsoft.AspNetCore.Components.IPersistentStateFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool +Microsoft.AspNetCore.Components.PersistentComponentState.CurrentScenario.get -> Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? +*REMOVED*Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnRestoring(Microsoft.AspNetCore.Components.IPersistentStateFilter! filter, System.Action! callback) -> Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.RestoringComponentStateSubscription() -> void +Microsoft.AspNetCore.Components.RestoringComponentStateSubscription.Dispose() -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.RestoreStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.IPersistentComponentStateScenario? scenario) -> System.Threading.Tasks.Task! diff --git a/src/Components/Components/src/Reflection/PropertyGetter.cs b/src/Components/Components/src/Reflection/PropertyGetter.cs index 03fa596cbc5c..e4f2289f0dd4 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/RestoreComponentStateRegistration.cs b/src/Components/Components/src/RestoreComponentStateRegistration.cs new file mode 100644 index 000000000000..669ad6fec00a --- /dev/null +++ b/src/Components/Components/src/RestoreComponentStateRegistration.cs @@ -0,0 +1,19 @@ +// 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 registration for state restoration callbacks. +/// +internal readonly struct RestoreComponentStateRegistration +{ + public RestoreComponentStateRegistration(IPersistentStateFilter filter, Action callback) + { + Filter = filter; + Callback = callback; + } + + public IPersistentStateFilter Filter { get; } + public Action Callback { get; } +} \ No newline at end of file diff --git a/src/Components/Components/src/RestoringComponentStateSubscription.cs b/src/Components/Components/src/RestoringComponentStateSubscription.cs new file mode 100644 index 000000000000..eb2bd115ba24 --- /dev/null +++ b/src/Components/Components/src/RestoringComponentStateSubscription.cs @@ -0,0 +1,41 @@ +// 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 state restoration notifications. +/// +public readonly struct RestoringComponentStateSubscription : IDisposable +{ + private readonly List? _callbacks; + private readonly IPersistentStateFilter? _filter; + private readonly Action? _callback; + + internal RestoringComponentStateSubscription( + List callbacks, + IPersistentStateFilter filter, + Action callback) + { + _callbacks = callbacks; + _filter = filter; + _callback = callback; + } + + /// + public void Dispose() + { + if (_callbacks != null && _filter != null && _callback != null) + { + for (int i = _callbacks.Count - 1; i >= 0; i--) + { + var registration = _callbacks[i]; + if (ReferenceEquals(registration.Filter, _filter) && ReferenceEquals(registration.Callback, _callback)) + { + _callbacks.RemoveAt(i); + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs index d157dfbd3bb4..9977bba00ec2 100644 --- a/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs +++ b/src/Components/Components/src/SupplyParameterFromPersistentComponentStateValueProvider.cs @@ -21,6 +21,7 @@ internal sealed class SupplyParameterFromPersistentComponentStateValueProvider(P private static readonly ConcurrentDictionary<(Type, string), PropertyGetter> _propertyGetterCache = new(); private readonly Dictionary _subscriptions = []; + private readonly Dictionary<(ComponentState, string), object?> _scenarioRestoredValues = []; public bool IsFixed => false; // For testing purposes only @@ -40,8 +41,15 @@ public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo) { var componentState = (ComponentState)key!; + + // Check if we have a scenario-restored value first + var valueKey = (componentState, parameterInfo.PropertyName); + if (_scenarioRestoredValues.TryGetValue(valueKey, out var scenarioValue)) + { + return scenarioValue; + } + var storageKey = ComputeKey(componentState, parameterInfo.PropertyName); - return state.TryTakeFromJson(storageKey, parameterInfo.PropertyType, out var value) ? value : null; } @@ -52,10 +60,12 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param { var propertyName = parameterInfo.PropertyName; var propertyType = parameterInfo.PropertyType; + var propertyGetter = ResolvePropertyGetter(subscriber.Component.GetType(), propertyName); + + // Register persistence callback _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) { @@ -64,6 +74,9 @@ public void Subscribe(ComponentState subscriber, in CascadingParameterInfo param state.PersistAsJson(storageKey, property, propertyType); return Task.CompletedTask; }, subscriber.Renderer.GetComponentRenderMode(subscriber.Component)); + + // Register scenario-based restoration callback using PropertyGetter's PropertyInfo + RegisterScenarioRestorationCallback(subscriber, parameterInfo, propertyGetter.PropertyInfo); } private static PropertyGetter ResolvePropertyGetter(Type type, string propertyName) @@ -281,4 +294,35 @@ private static bool IsSerializableKey(object key) return result; } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicConstructors', 'DynamicallyAccessedMemberTypes.PublicFields', 'DynamicallyAccessedMemberTypes.PublicProperties' in call to target method. The return value of the source method does not have matching annotations.", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + private void RegisterScenarioRestorationCallback(ComponentState subscriber, in CascadingParameterInfo parameterInfo, PropertyInfo propertyInfo) + { + // Check for IPersistentStateFilter attributes + var filterAttributes = propertyInfo.GetCustomAttributes(typeof(IPersistentStateFilter), inherit: true); + + // Register restoration callbacks for each filter + foreach (IPersistentStateFilter filter in filterAttributes) + { + RegisterRestorationCallback(subscriber, parameterInfo, filter); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + [UnconditionalSuppressMessage("Trimming", "IL2072:'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicConstructors', 'DynamicallyAccessedMemberTypes.PublicFields', 'DynamicallyAccessedMemberTypes.PublicProperties' in call to target method. The return value of the source method does not have matching annotations.", Justification = "Property types of rendered components are preserved through other means and won't get trimmed.")] + private void RegisterRestorationCallback(ComponentState subscriber, in CascadingParameterInfo parameterInfo, IPersistentStateFilter filter) + { + var storageKey = ComputeKey(subscriber, parameterInfo.PropertyName); + var propertyType = parameterInfo.PropertyType; + var valueKey = (subscriber, parameterInfo.PropertyName); + + state.RegisterOnRestoring(filter, () => + { + if (state.TryTakeFromJson(storageKey, propertyType, out var value)) + { + _scenarioRestoredValues[valueKey] = value; + } + }); + } } diff --git a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs index 4e5708c10f4d..b6cba536f0d7 100644 --- a/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs +++ b/src/Components/Components/test/PersistentState/ComponentStatePersistenceManagerTest.cs @@ -78,6 +78,132 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() await Assert.ThrowsAsync(() => persistenceManager.RestoreStateAsync(store)); } + [Fact] + public async Task RestoreStateAsync_WithScenario_FirstCallInitializesState() + { + // Arrange + var data = new byte[] { 0, 1, 2, 3, 4 }; + var state = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var scenario = new TestScenario(true); + + // Act + await persistenceManager.RestoreStateAsync(store, scenario); + + // Assert + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.Equal(data, retrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithoutScenario_FirstCallInitializesState() + { + // Arrange + var data = new byte[] { 0, 1, 2, 3, 4 }; + var state = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(data) + }; + var store = new TestStore(state); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // Act + await persistenceManager.RestoreStateAsync(store, scenario: null); + + // Assert + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var retrieved)); + Assert.Equal(data, retrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithScenario_SecondCallUpdatesExistingState() + { + // Arrange + var initialData = new byte[] { 0, 1, 2, 3, 4 }; + var updatedData = new byte[] { 5, 6, 7, 8, 9 }; + var initialState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(initialData) + }; + var updatedState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(updatedData) + }; + var initialStore = new TestStore(initialState); + var updatedStore = new TestStore(updatedState); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + var scenario = new TestScenario(true); + + // Act - First call initializes state + await persistenceManager.RestoreStateAsync(initialStore, scenario); + + // Consume the initial state to verify it was loaded + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var initialRetrieved)); + Assert.Equal(initialData, initialRetrieved); + + // Act - Second call with scenario should update existing state + await persistenceManager.RestoreStateAsync(updatedStore, scenario); + + // Assert - Should be able to retrieve updated data + Assert.True(persistenceManager.State.TryTakeFromJson("MyState", out var updatedRetrieved)); + Assert.Equal(updatedData, updatedRetrieved); + } + + [Fact] + public async Task RestoreStateAsync_WithoutScenario_SecondCallThrowsInvalidOperationException() + { + // Arrange + var initialData = new byte[] { 0, 1, 2, 3, 4 }; + var initialState = new Dictionary + { + ["MyState"] = JsonSerializer.SerializeToUtf8Bytes(initialData) + }; + var store = new TestStore(initialState); + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + CreateServiceProvider()); + + // Act - First call initializes state + await persistenceManager.RestoreStateAsync(store, scenario: null); + + // Assert - Second call without scenario should throw + await Assert.ThrowsAsync(() => + persistenceManager.RestoreStateAsync(store, scenario: null)); + } + + [Fact] + public async Task RestoreStateAsync_WithScenario_RestoresServicesRegistry() + { + // Arrange + var serviceProvider = new ServiceCollection() + .AddScoped(sp => new TestStore([])) + .AddPersistentService(new TestRenderMode()) + .BuildServiceProvider(); + + var persistenceManager = new ComponentStatePersistenceManager( + NullLogger.Instance, + serviceProvider); + + var testStore = new TestStore([]); + var scenario = new TestScenario(true); + + // Act + await persistenceManager.RestoreStateAsync(testStore, scenario); + + // Assert + Assert.NotNull(persistenceManager.ServicesRegistry); + } + private IServiceProvider CreateServiceProvider() => new ServiceCollection().BuildServiceProvider(); @@ -422,6 +548,16 @@ private class TestRenderMode : IComponentRenderMode { } + private class TestScenario : IPersistentComponentStateScenario + { + public bool IsRecurring { get; } + + public TestScenario(bool isRecurring) + { + IsRecurring = isRecurring; + } + } + private class PersistentService : IPersistentServiceRegistration { public string Assembly { get; set; } diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index ba684e1984cd..dda895e071fb 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -783,7 +783,14 @@ internal Task UpdateRootComponents( // provided during the start up process var appLifetime = _scope.ServiceProvider.GetRequiredService(); appLifetime.SetPlatformRenderMode(RenderMode.InteractiveServer); - await appLifetime.RestoreStateAsync(store); + + // For the first update, this could be either prerendering or reconnection + // If we have persisted circuit state, it means this is a reconnection scenario + var scenario = HasPendingPersistedCircuitState + ? WebPersistenceScenario.Reconnection() + : WebPersistenceScenario.Prerendering(); + + await appLifetime.RestoreStateAsync(store, scenario); } // Retrieve the circuit handlers at this point. @@ -801,6 +808,16 @@ internal Task UpdateRootComponents( } } } + else + { + // This is a subsequent update (enhanced navigation) + if (store != null) + { + var appLifetime = _scope.ServiceProvider.GetRequiredService(); + var scenario = WebPersistenceScenario.EnhancedNavigation(RenderMode.InteractiveServer); + await appLifetime.RestoreStateAsync(store, scenario); + } + } await PerformRootComponentOperations(operations, shouldWaitForQuiescence); diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 99365e10804e..f70d096d0123 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,3 +1,21 @@ #nullable enable Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! -virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file +virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool +Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute +Microsoft.AspNetCore.Components.Web.RestoreStateOnPrerenderingAttribute.RestoreStateOnPrerenderingAttribute(bool restore = true) -> void +Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute +Microsoft.AspNetCore.Components.Web.RestoreStateOnReconnectionAttribute.RestoreStateOnReconnectionAttribute(bool restore = true) -> void +Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute +Microsoft.AspNetCore.Components.Web.UpdateStateOnEnhancedNavigationAttribute.UpdateStateOnEnhancedNavigationAttribute() -> void +Microsoft.AspNetCore.Components.Web.WebPersistenceScenario +Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode? +override Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Equals(object? obj) -> bool +override Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.GetHashCode() -> int +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.EnhancedNavigation(Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode = null) -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Prerendering() -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +static Microsoft.AspNetCore.Components.Web.WebPersistenceScenario.Reconnection() -> Microsoft.AspNetCore.Components.Web.WebPersistenceScenario! +Microsoft.AspNetCore.Components.Web.WebPersistenceFilter +Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.ShouldRestore(Microsoft.AspNetCore.Components.IPersistentComponentStateScenario! scenario) -> bool +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.EnhancedNavigation.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.Prerendering.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! +static Microsoft.AspNetCore.Components.Web.WebPersistenceFilter.Reconnection.get -> Microsoft.AspNetCore.Components.Web.WebPersistenceFilter! \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.cs new file mode 100644 index 000000000000..9a2671586068 --- /dev/null +++ b/src/Components/Web/src/RestoreStateOnPrerenderingAttribute.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.Web; + +/// +/// Indicates that state should be restored during prerendering. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class RestoreStateOnPrerenderingAttribute : Attribute, IPersistentStateFilter +{ + internal WebPersistenceFilter? WebPersistenceFilter { get; } + + /// + /// Initializes a new instance of . + /// + /// Whether to restore state during prerendering. Default is true. + public RestoreStateOnPrerenderingAttribute(bool restore = true) + { + WebPersistenceFilter = restore ? Components.Web.WebPersistenceFilter.Prerendering : null; + } + + /// + bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario) + { + return WebPersistenceFilter?.ShouldRestore(scenario) ?? false; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.cs new file mode 100644 index 000000000000..612461fa6332 --- /dev/null +++ b/src/Components/Web/src/RestoreStateOnReconnectionAttribute.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.Web; + +/// +/// Indicates that state should be restored after server reconnection. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class RestoreStateOnReconnectionAttribute : Attribute, IPersistentStateFilter +{ + internal WebPersistenceFilter? WebPersistenceFilter { get; } + + /// + /// Initializes a new instance of . + /// + /// Whether to restore state after reconnection. Default is true. + public RestoreStateOnReconnectionAttribute(bool restore = true) + { + WebPersistenceFilter = restore ? Components.Web.WebPersistenceFilter.Reconnection : null; + } + + /// + bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario) + { + return WebPersistenceFilter?.ShouldRestore(scenario) ?? false; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs new file mode 100644 index 000000000000..5ea03fda8bd2 --- /dev/null +++ b/src/Components/Web/src/UpdateStateOnEnhancedNavigationAttribute.cs @@ -0,0 +1,19 @@ +// 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.Web; + +/// +/// Indicates that state should be restored during enhanced navigation. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class UpdateStateOnEnhancedNavigationAttribute : Attribute, IPersistentStateFilter +{ + internal WebPersistenceFilter WebPersistenceFilter { get; } = WebPersistenceFilter.EnhancedNavigation; + + /// + bool IPersistentStateFilter.ShouldRestore(IPersistentComponentStateScenario scenario) + { + return WebPersistenceFilter.ShouldRestore(scenario); + } +} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceFilter.cs b/src/Components/Web/src/WebPersistenceFilter.cs new file mode 100644 index 000000000000..5fd868321f8f --- /dev/null +++ b/src/Components/Web/src/WebPersistenceFilter.cs @@ -0,0 +1,38 @@ +// 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.Web; + +/// +/// Provides filters for web-based persistent component state restoration scenarios. +/// +public sealed class WebPersistenceFilter : IPersistentStateFilter +{ + /// + /// Gets a filter that matches enhanced navigation scenarios. + /// + public static WebPersistenceFilter EnhancedNavigation { get; } = new(WebPersistenceScenario.ScenarioType.EnhancedNavigation); + + /// + /// Gets a filter that matches prerendering scenarios. + /// + public static WebPersistenceFilter Prerendering { get; } = new(WebPersistenceScenario.ScenarioType.Prerendering); + + /// + /// Gets a filter that matches reconnection scenarios. + /// + public static WebPersistenceFilter Reconnection { get; } = new(WebPersistenceScenario.ScenarioType.Reconnection); + + private readonly WebPersistenceScenario.ScenarioType _targetScenario; + + private WebPersistenceFilter(WebPersistenceScenario.ScenarioType targetScenario) + { + _targetScenario = targetScenario; + } + + /// + public bool ShouldRestore(IPersistentComponentStateScenario scenario) + { + return scenario is WebPersistenceScenario webScenario && webScenario.Type == _targetScenario; + } +} \ No newline at end of file diff --git a/src/Components/Web/src/WebPersistenceScenario.cs b/src/Components/Web/src/WebPersistenceScenario.cs new file mode 100644 index 000000000000..366f352ace83 --- /dev/null +++ b/src/Components/Web/src/WebPersistenceScenario.cs @@ -0,0 +1,86 @@ +// 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.Web; + +/// +/// Provides scenario context for web-based persistent component state restoration. +/// +public sealed class WebPersistenceScenario : IPersistentComponentStateScenario +{ + /// + /// Gets the render mode context for this restoration. + /// + public IComponentRenderMode? RenderMode { get; } + + /// + /// Gets the scenario type. + /// + internal ScenarioType Type { get; } + + /// + bool IPersistentComponentStateScenario.IsRecurring => Type == ScenarioType.EnhancedNavigation; + + private WebPersistenceScenario(ScenarioType type, IComponentRenderMode? renderMode) + { + Type = type; + RenderMode = renderMode; + } + + /// + /// Creates a scenario for enhanced navigation with optional render mode. + /// + /// The render mode context for this restoration. + /// A new enhanced navigation scenario. + public static WebPersistenceScenario EnhancedNavigation(IComponentRenderMode? renderMode = null) + => new(ScenarioType.EnhancedNavigation, renderMode); + + /// + /// Creates a scenario for prerendering. + /// + /// A new prerendering scenario. + public static WebPersistenceScenario Prerendering() + => new(ScenarioType.Prerendering, renderMode: null); + + /// + /// Creates a scenario for server reconnection. + /// + /// A new reconnection scenario. + public static WebPersistenceScenario Reconnection() + => new(ScenarioType.Reconnection, renderMode: null); + + /// + public override bool Equals(object? obj) + { + return obj is WebPersistenceScenario other && + Type == other.Type && + RenderMode?.GetType() == other.RenderMode?.GetType(); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Type, RenderMode?.GetType()); + } + + /// + /// Defines the types of web persistence scenarios. + /// + internal enum ScenarioType + { + /// + /// State restoration during prerendering. + /// + Prerendering, + + /// + /// State restoration during enhanced navigation. + /// + EnhancedNavigation, + + /// + /// State restoration after server reconnection. + /// + Reconnection + } +} \ No newline at end of file diff --git a/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs new file mode 100644 index 000000000000..8ee2e428036b --- /dev/null +++ b/src/Components/Web/test/ScenarioBasedPersistentComponentStateTest.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Web; +using Xunit; + +namespace Microsoft.AspNetCore.Components; + +public class ScenarioBasedPersistentComponentStateTest +{ + [Fact] + public void WebPersistenceScenario_Properties_SetCorrectly() + { + // Arrange & Act + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); + + // Assert + Assert.Equal(WebPersistenceScenario.ScenarioType.EnhancedNavigation, enhancedNavScenario.Type); + Assert.True(((IPersistentComponentStateScenario)enhancedNavScenario).IsRecurring); + + Assert.Equal(WebPersistenceScenario.ScenarioType.Prerendering, prerenderingScenario.Type); + Assert.False(((IPersistentComponentStateScenario)prerenderingScenario).IsRecurring); + + Assert.Equal(WebPersistenceScenario.ScenarioType.Reconnection, reconnectionScenario.Type); + Assert.False(((IPersistentComponentStateScenario)reconnectionScenario).IsRecurring); + } + + [Fact] + public void WebPersistenceScenario_EnhancedNavigation_WithRenderMode() + { + // Arrange + var serverRenderMode = new InteractiveServerRenderMode(); + var wasmRenderMode = new InteractiveWebAssemblyRenderMode(); + + // Act + var serverScenario = WebPersistenceScenario.EnhancedNavigation(serverRenderMode); + var wasmScenario = WebPersistenceScenario.EnhancedNavigation(wasmRenderMode); + var defaultScenario = WebPersistenceScenario.EnhancedNavigation(); + + // Assert + Assert.Equal(serverRenderMode, serverScenario.RenderMode); + Assert.Equal(wasmRenderMode, wasmScenario.RenderMode); + Assert.Null(defaultScenario.RenderMode); + } + + [Fact] + public void WebPersistenceScenario_Equals_WorksCorrectly() + { + // Arrange + var scenario1 = WebPersistenceScenario.EnhancedNavigation(); + var scenario2 = WebPersistenceScenario.EnhancedNavigation(); + var scenario3 = WebPersistenceScenario.Prerendering(); + + // Act & Assert + Assert.True(scenario1.Equals(scenario2)); + Assert.False(scenario1.Equals(scenario3)); + Assert.False(scenario1.Equals(null)); + } + + [Fact] + public void WebPersistenceScenario_GetHashCode_WorksCorrectly() + { + // Arrange + var scenario1 = WebPersistenceScenario.EnhancedNavigation(); + var scenario2 = WebPersistenceScenario.EnhancedNavigation(); + var scenario3 = WebPersistenceScenario.Prerendering(); + + // Act & Assert + Assert.Equal(scenario1.GetHashCode(), scenario2.GetHashCode()); + Assert.NotEqual(scenario1.GetHashCode(), scenario3.GetHashCode()); + } + + [Fact] + public void WebPersistenceFilter_ShouldRestore_WorksCorrectly() + { + // Arrange + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); + + var enhancedNavFilter = WebPersistenceFilter.EnhancedNavigation; + var prerenderingFilter = WebPersistenceFilter.Prerendering; + var reconnectionFilter = WebPersistenceFilter.Reconnection; + + // Act & Assert + Assert.True(enhancedNavFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(prerenderingScenario)); + Assert.False(enhancedNavFilter.ShouldRestore(reconnectionScenario)); + + Assert.False(prerenderingFilter.ShouldRestore(enhancedNavScenario)); + Assert.True(prerenderingFilter.ShouldRestore(prerenderingScenario)); + Assert.False(prerenderingFilter.ShouldRestore(reconnectionScenario)); + + Assert.False(reconnectionFilter.ShouldRestore(enhancedNavScenario)); + Assert.False(reconnectionFilter.ShouldRestore(prerenderingScenario)); + Assert.True(reconnectionFilter.ShouldRestore(reconnectionScenario)); + } + + [Fact] + public void FilterAttributes_ShouldRestore_WorksCorrectly() + { + // Arrange + var enhancedNavScenario = WebPersistenceScenario.EnhancedNavigation(); + var prerenderingScenario = WebPersistenceScenario.Prerendering(); + var reconnectionScenario = WebPersistenceScenario.Reconnection(); + + var enhancedNavFilter = new UpdateStateOnEnhancedNavigationAttribute(); + var prerenderingFilter = new RestoreStateOnPrerenderingAttribute(); + var reconnectionFilter = new RestoreStateOnReconnectionAttribute(); + + // Act & Assert + Assert.True(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(enhancedNavScenario)); + Assert.False(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(prerenderingScenario)); + Assert.False(((IPersistentStateFilter)enhancedNavFilter).ShouldRestore(reconnectionScenario)); + + Assert.False(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(enhancedNavScenario)); + Assert.True(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(prerenderingScenario)); + Assert.False(((IPersistentStateFilter)prerenderingFilter).ShouldRestore(reconnectionScenario)); + + Assert.False(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(enhancedNavScenario)); + Assert.False(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(prerenderingScenario)); + Assert.True(((IPersistentStateFilter)reconnectionFilter).ShouldRestore(reconnectionScenario)); + } +} \ No newline at end of file diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs index 08bf6a23a278..39f3f06735aa 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,13 +27,16 @@ 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); + private bool _isFirstUpdate = true; public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollection resourceCollection, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop) : base(serviceProvider, loggerFactory, DefaultWebAssemblyJSRuntime.Instance.ReadJsonSerializerOptions(), jsComponentInterop) { _logger = loggerFactory.CreateLogger(); _jsMethods = serviceProvider.GetRequiredService(); + _componentStatePersistenceManager = serviceProvider.GetService(); // if SynchronizationContext.Current is null, it means we are on the single-threaded runtime _dispatcher = WebAssemblyDispatcher._mainSynchronizationContext == null @@ -46,8 +50,35 @@ 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 async void OnUpdateRootComponents(RootComponentOperationBatch batch, IDictionary? persistentState) { + PrerenderComponentApplicationStore? store = null; + + // Handle persistent state restoration if available + if (_componentStatePersistenceManager != null && persistentState != null) + { + store = new PrerenderComponentApplicationStore(); + store.ExistingState.Clear(); + foreach (var kvp in persistentState) + { + store.ExistingState[kvp.Key] = kvp.Value; + } + + var scenario = _isFirstUpdate + ? WebPersistenceScenario.Prerendering() + : WebPersistenceScenario.EnhancedNavigation(RenderMode.InteractiveWebAssembly); + + try + { + await _componentStatePersistenceManager.RestoreStateAsync(store, scenario); + _isFirstUpdate = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error restoring component state during root component update"); + } + } + var webRootComponentManager = GetOrCreateWebRootComponentManager(); for (var i = 0; i < batch.Operations.Length; i++) { @@ -74,6 +105,12 @@ private void OnUpdateRootComponents(RootComponentOperationBatch batch) } } + // Clear state after processing operations when it's not the first update + if (!_isFirstUpdate && store != null) + { + store.ExistingState.Clear(); + } + NotifyEndUpdateRootComponents(batch.BatchId); } diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs index b033a3fd5849..29b9bb20fcf0 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,20 @@ public static void BeginInvokeDotNet(string? callId, string assemblyNameOrDotNet [SupportedOSPlatform("browser")] [JSExport] - public static void UpdateRootComponentsCore(string operationsJson) + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Persistent state dictionary is preserved by design.")] + public static void UpdateRootComponentsCore(string operationsJson, string? persistentStateJson = null) { try { var operations = DeserializeOperations(operationsJson); - Instance.OnUpdateRootComponents?.Invoke(operations); + IDictionary? persistentState = null; + + if (!string.IsNullOrEmpty(persistentStateJson)) + { + persistentState = JsonSerializer.Deserialize>(persistentStateJson); + } + + Instance.OnUpdateRootComponents?.Invoke(operations, persistentState); } catch (Exception ex) { diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs index f79d5064c8b4..6adf3364980e 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs @@ -188,6 +188,53 @@ private void TriggerClientPauseAndInteract(IJavaScriptExecutor javascript) Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); } + + [Fact] + public void NonPersistedStateIsNotRestoredAfterDisconnection() + { + // Verify initial state during/after SSR - NonPersistedCounter should be 5 + Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Wait for interactivity - the value should still be 5 + Browser.Exists(By.Id("render-mode-interactive")); + Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Increment the non-persisted counter to 6 to show it works during interactive session + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); + Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Also increment the persistent counter to show the contrast + Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + + // Force disconnection and reconnection + var javascript = (IJavaScriptExecutor)Browser; + javascript.ExecuteScript("window.replaceReconnectCallback()"); + TriggerReconnectAndInteract(javascript); + + // After reconnection: + // - Persistent counter should be 2 (was 1, incremented by TriggerReconnectAndInteract) + Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + + // - Non-persisted counter should be 0 (default value) because RestoreStateOnPrerendering + // prevented it from being restored after disconnection + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Verify the non-persisted counter can still be incremented in the new session + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + // Test repeatability - trigger another disconnection cycle + javascript.ExecuteScript("resetReconnect()"); + TriggerReconnectAndInteract(javascript); + + // After second reconnection: + // - Persistent counter should be 3 + Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + + // - Non-persisted counter should be 0 again (reset to default) + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + } } public class CustomUIServerResumeTests : ServerResumeTests diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 130b518aa210..b06013b46a6d 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1060,6 +1060,8 @@ public void CanPersistPrerenderedStateDeclaratively_Server() Browser.Equal("restored", () => Browser.FindElement(By.Id("server")).Text); Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-server")).Text); + Browser.Equal("restored-prerendering-enabled", () => Browser.FindElement(By.Id("prerendering-enabled-server")).Text); + Browser.Equal("restored-prerendering-disabled", () => Browser.FindElement(By.Id("prerendering-disabled-server")).Text); } [Fact] diff --git a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs index d49e4fbc5704..0c8601119ea2 100644 --- a/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs +++ b/src/Components/test/E2ETest/Tests/StatePersistenceTest.cs @@ -236,4 +236,117 @@ private void AssertPageState( Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text); } } + + [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 ComponentWithUpdateStateOnEnhancedNavigationReceivesStateUpdates(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}") + }; + + // Step 1: Navigate to page without components first to establish initial state + 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"); + } + + if (mode == "auto") + { + BlockWebAssemblyResourceLoad(); + } + + Browser.Click(By.Id("call-blazor-start")); + Browser.Click(By.Id("page-with-components-link")); + + // Step 2: Validate initial state - no enhanced nav state should be found + ValidateEnhancedNavState( + mode: mode, + renderMode: renderMode.Name, + interactive: streaming == null, + enhancedNavStateFound: false, + enhancedNavStateValue: "no-enhanced-nav-state", + streamingId: streaming, + streamingCompleted: false); + + if (streaming != null) + { + Browser.Click(By.Id("end-streaming")); + ValidateEnhancedNavState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + enhancedNavStateFound: false, + enhancedNavStateValue: "no-enhanced-nav-state", + streamingId: streaming, + streamingCompleted: true); + } + + // Step 3: Navigate back to page without components (this persists state) + Browser.Click(By.Id("page-no-components-link")); + + // Step 4: Navigate back to page with components via enhanced navigation + // This should trigger [UpdateStateOnEnhancedNavigation] and update the state + Browser.Click(By.Id("page-with-components-link")); + + // Step 5: Validate that enhanced navigation state was updated + ValidateEnhancedNavState( + mode: mode, + renderMode: renderMode.Name, + interactive: streaming == null, + enhancedNavStateFound: true, + enhancedNavStateValue: "enhanced-nav-updated", + streamingId: streaming, + streamingCompleted: streaming == null); + + if (streaming != null) + { + Browser.Click(By.Id("end-streaming")); + ValidateEnhancedNavState( + mode: mode, + renderMode: renderMode.Name, + interactive: true, + enhancedNavStateFound: true, + enhancedNavStateValue: "enhanced-nav-updated", + streamingId: streaming, + streamingCompleted: true); + } + } + + private void ValidateEnhancedNavState( + string mode, + string renderMode, + bool interactive, + bool enhancedNavStateFound, + string enhancedNavStateValue, + string streamingId = null, + bool streamingCompleted = false) + { + 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) + { + Browser.Equal($"Enhanced nav state found:{enhancedNavStateFound}", () => Browser.FindElement(By.Id("enhanced-nav-state-found")).Text); + Browser.Equal($"Enhanced nav state value:{enhancedNavStateValue}", () => Browser.FindElement(By.Id("enhanced-nav-state-value")).Text); + } + else + { + Browser.Equal("Streaming: True", () => Browser.FindElement(By.Id("streaming")).Text); + } + } } diff --git a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor index bbdbcc43dccd..2a0e60f051b0 100644 --- a/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor +++ b/src/Components/test/testassets/TestContentPackage/DeclarativePersistStateComponent.razor @@ -1,5 +1,8 @@ -

Application state is @Value

+@using Microsoft.AspNetCore.Components.Web +

Application state is @Value

Render mode: @_renderMode

+

Prerendering enabled state: @PrerenderingEnabledValue

+

Prerendering disabled state: @PrerenderingDisabledValue

@code { [Parameter, EditorRequired] @@ -11,11 +14,21 @@ [SupplyParameterFromPersistentComponentState] public string Value { get; set; } + [SupplyParameterFromPersistentComponentState] + [RestoreStateOnPrerendering] + public string PrerenderingEnabledValue { get; set; } + + [SupplyParameterFromPersistentComponentState] + [RestoreStateOnPrerendering(false)] + public string PrerenderingDisabledValue { get; set; } + private string _renderMode = "SSR"; protected override void OnInitialized() { Value ??= !RendererInfo.IsInteractive ? InitialValue : "not restored"; + PrerenderingEnabledValue ??= !RendererInfo.IsInteractive ? $"{InitialValue}-prerendering-enabled" : "not restored"; + PrerenderingDisabledValue ??= !RendererInfo.IsInteractive ? $"{InitialValue}-prerendering-disabled" : "not restored"; _renderMode = OperatingSystem.IsBrowser() ? "WebAssembly" : "Server"; } } diff --git a/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor index 2c8ea740d0a0..8e0ec7b1accf 100644 --- a/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor +++ b/src/Components/test/testassets/TestContentPackage/PersistentComponents/NonStreamingComponentWithPersistentState.razor @@ -1,4 +1,5 @@ -

Non streaming component with persistent state

+@using Microsoft.AspNetCore.Components.Web +

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.

@@ -6,18 +7,27 @@

Interactive runtime: @_interactiveRuntime

State found:@_stateFound

State value:@_stateValue

+

Enhanced nav state found:@_enhancedNavStateFound

+

Enhanced nav state value:@_enhancedNavState

@code { private bool _stateFound; private string _stateValue; private string _interactiveRuntime; + private bool _enhancedNavStateFound; + private string _enhancedNavState; [Inject] public PersistentComponentState PersistentComponentState { get; set; } [CascadingParameter(Name = nameof(RunningOnServer))] public bool RunningOnServer { get; set; } [Parameter] public string ServerState { get; set; } + [Parameter] + [SupplyParameterFromPersistentComponentState] + [UpdateStateOnEnhancedNavigation] + public string EnhancedNavState { get; set; } + protected override void OnInitialized() { PersistentComponentState.RegisterOnPersisting(PersistState); @@ -39,11 +49,23 @@ { _interactiveRuntime = OperatingSystem.IsBrowser() ? "wasm" : "server"; } + + // Track enhanced navigation state updates + _enhancedNavState = EnhancedNavState ?? "no-enhanced-nav-state"; + _enhancedNavStateFound = !string.IsNullOrEmpty(EnhancedNavState); + } + + protected override void OnParametersSet() + { + // This will be called during enhanced navigation when [UpdateStateOnEnhancedNavigation] triggers + _enhancedNavState = EnhancedNavState ?? "no-enhanced-nav-state"; + _enhancedNavStateFound = !string.IsNullOrEmpty(EnhancedNavState); } Task PersistState() { PersistentComponentState.PersistAsJson("NonStreamingComponentWithPersistentState", _stateValue); + PersistentComponentState.PersistAsJson("EnhancedNavState", "enhanced-nav-updated"); return Task.CompletedTask; } } diff --git a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor index fef4f1ecaa3f..4aaa85052ab8 100644 --- a/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor +++ b/src/Components/test/testassets/TestContentPackage/PersistentCounter.razor @@ -1,4 +1,5 @@ @using Microsoft.JSInterop +@using Microsoft.AspNetCore.Components.Web @inject IJSRuntime JSRuntime @@ -9,13 +10,19 @@

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

Current count: @State.Count

+

Non-persisted counter: @NonPersistedCounter

+ @code { [SupplyParameterFromPersistentComponentState] public CounterState State { get; set; } + [SupplyParameterFromPersistentComponentState] + [RestoreStateOnReconnection(false)] + public int NonPersistedCounter { get; set; } + public class CounterState { public int Count { get; set; } = 0; @@ -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; + } }