diff --git a/src/Components/Components/src/Lifetime/IComponentApplicationStateStore.cs b/src/Components/Components/src/IPersistentComponentStateStore.cs similarity index 69% rename from src/Components/Components/src/Lifetime/IComponentApplicationStateStore.cs rename to src/Components/Components/src/IPersistentComponentStateStore.cs index 68312633c5c8..9b015dad6748 100644 --- a/src/Components/Components/src/Lifetime/IComponentApplicationStateStore.cs +++ b/src/Components/Components/src/IPersistentComponentStateStore.cs @@ -1,27 +1,26 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; -using System.Threading.Tasks; +using System.Buffers; -namespace Microsoft.AspNetCore.Components.Lifetime +namespace Microsoft.AspNetCore.Components { /// /// Manages the storage for components and services that are part of a Blazor application. /// - public interface IComponentApplicationStateStore + public interface IPersistentComponentStateStore { /// /// Gets the persisted state from the store. /// /// The persisted state. - Task> GetPersistedStateAsync(); + Task>> GetPersistedStateAsync(); /// /// Persists the serialized state into the storage. /// /// The serialized state to persist. /// A that completes when the state is persisted to disk. - Task PersistStateAsync(IReadOnlyDictionary state); + Task PersistStateAsync(IReadOnlyDictionary> state); } } diff --git a/src/Components/Components/src/Lifetime/ComponentApplicationLifetime.cs b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs similarity index 58% rename from src/Components/Components/src/Lifetime/ComponentApplicationLifetime.cs rename to src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs index 13e4cd308081..3bfaf93b28fd 100644 --- a/src/Components/Components/src/Lifetime/ComponentApplicationLifetime.cs +++ b/src/Components/Components/src/Infrastructure/ComponentStatePersistenceManager.cs @@ -2,56 +2,60 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.Logging; -namespace Microsoft.AspNetCore.Components.Lifetime +namespace Microsoft.AspNetCore.Components.Infrastructure { /// - /// Manages the lifetime of a component application. + /// Manages the persistent state of components in an application. /// - public class ComponentApplicationLifetime + public class ComponentStatePersistenceManager : IDisposable { private bool _stateIsPersisted; - private readonly List _pauseCallbacks = new(); - private readonly Dictionary _currentState = new(); - private readonly ILogger _logger; + private readonly List> _pauseCallbacks = new(); + private readonly Dictionary _currentState = new(StringComparer.Ordinal); + private readonly ILogger _logger; /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// - public ComponentApplicationLifetime(ILogger logger) + public ComponentStatePersistenceManager(ILogger logger) { - State = new ComponentApplicationState(_currentState, _pauseCallbacks); + State = new PersistentComponentState(_currentState, _pauseCallbacks); _logger = logger; } /// - /// Gets the associated with the . + /// Gets the associated with the . /// - public ComponentApplicationState State { get; } + public PersistentComponentState State { get; } /// - /// Restores the component application state from the given . + /// Restores the component application state from the given . /// - /// The to restore the application state from. + /// The to restore the application state from. /// A that will complete when the state has been restored. - public async Task RestoreStateAsync(IComponentApplicationStateStore store) + public async Task RestoreStateAsync(IPersistentComponentStateStore store) { var data = await store.GetPersistedStateAsync(); State.InitializeExistingState(data); } /// - /// Persists the component application state into the given . + /// Persists the component application state into the given . /// - /// The to restore the application state from. + /// The to restore the application state from. /// The that components are being rendered. /// A that will complete when the state has been restored. - public Task PersistStateAsync(IComponentApplicationStateStore store, Renderer renderer) + public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer) { if (_stateIsPersisted) { @@ -64,10 +68,23 @@ public Task PersistStateAsync(IComponentApplicationStateStore store, Renderer re async Task PauseAndPersistState() { + State.PersistingState = true; await PauseAsync(); + State.PersistingState = false; + + var data = new Dictionary>(StringComparer.Ordinal); + foreach (var (key, value) in _currentState) + { + data[key] = new ReadOnlySequence(value.WrittenMemory); + } - var data = new ReadOnlyDictionary(_currentState); await store.PersistStateAsync(data); + + foreach (var value in _currentState.Values) + { + value.Dispose(); + } + _currentState.Clear(); } } @@ -75,7 +92,7 @@ internal Task PauseAsync() { List? pendingCallbackTasks = null; - for (int i = 0; i < _pauseCallbacks.Count; i++) + for (var i = 0; i < _pauseCallbacks.Count; i++) { var callback = _pauseCallbacks[i]; var result = ExecuteCallback(callback, _logger); @@ -95,7 +112,7 @@ internal Task PauseAsync() return Task.CompletedTask; } - static Task ExecuteCallback(ComponentApplicationState.OnPersistingCallback callback, ILogger logger) + static Task ExecuteCallback(Func callback, ILogger logger) { try { @@ -115,7 +132,7 @@ static Task ExecuteCallback(ComponentApplicationState.OnPersistingCallback callb return Task.CompletedTask; } - static async Task Awaited(Task task, ILogger logger) + static async Task Awaited(Task task, ILogger logger) { try { @@ -129,5 +146,14 @@ static async Task Awaited(Task task, ILogger logge } } } + + void IDisposable.Dispose() + { + foreach (var value in _currentState.Values) + { + value.Dispose(); + } + _currentState.Clear(); + } } } diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index f68c4e4bfcf1..0c1e2d985d6f 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Components/Components/src/Lifetime/ComponentApplicationState.cs b/src/Components/Components/src/PersistentComponentState.cs similarity index 56% rename from src/Components/Components/src/Lifetime/ComponentApplicationState.cs rename to src/Components/Components/src/PersistentComponentState.cs index 1b25d22c8328..5ab75f106a1a 100644 --- a/src/Components/Components/src/Lifetime/ComponentApplicationState.cs +++ b/src/Components/Components/src/PersistentComponentState.cs @@ -1,11 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; +using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Infrastructure; using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components @@ -13,70 +12,59 @@ namespace Microsoft.AspNetCore.Components /// /// The state for the components and services of a components application. /// - public class ComponentApplicationState + public class PersistentComponentState { - private IDictionary? _existingState; - private readonly IDictionary _currentState; - private readonly List _registeredCallbacks; + private IDictionary>? _existingState; + private readonly IDictionary _currentState; - internal ComponentApplicationState( - IDictionary currentState, - List pauseCallbacks) + private readonly List> _registeredCallbacks; + + internal PersistentComponentState( + IDictionary currentState, + List> pauseCallbacks) { _currentState = currentState; _registeredCallbacks = pauseCallbacks; } - internal void InitializeExistingState(IDictionary existingState) + internal bool PersistingState { get; set; } + + internal void InitializeExistingState(IDictionary> existingState) { if (_existingState != null) { - throw new InvalidOperationException("ComponentApplicationState already initialized."); + throw new InvalidOperationException("PersistentComponentState already initialized."); } _existingState = existingState ?? throw new ArgumentNullException(nameof(existingState)); } /// - /// Represents the method that performs operations when is raised and the application is about to be paused. + /// Register a callback to persist the component state when the application is about to be paused. + /// Registered callbacks can use this opportunity to persist their state so that it can be retrieved when the application resumes. /// - /// A that will complete when the method is done preparing for the application pause. - public delegate Task OnPersistingCallback(); - - /// - /// An event that is raised when the application is about to be paused. - /// Registered handlers can use this opportunity to persist their state so that it can be retrieved when the application resumes. - /// - public event OnPersistingCallback OnPersisting + /// The callback to invoke when the application is being paused. + /// A subscription that can be used to unregister the callback when disposed. + public PersistingComponentStateSubscription RegisterOnPersisting(Func callback) { - add + if (callback == null) { - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } - - _registeredCallbacks.Add(value); + throw new ArgumentNullException(nameof(callback)); } - remove - { - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } - _registeredCallbacks.Remove(value); - } + _registeredCallbacks.Add(callback); + + return new PersistingComponentStateSubscription(_registeredCallbacks, callback); } /// /// Tries to retrieve the persisted state with the given . /// When the key is present, the state is successfully returned via - /// and removed from the . + /// and removed from the . /// /// The key used to persist the state. /// The persisted state. /// true if the state was found; false otherwise. - public bool TryTakePersistedState(string key, [MaybeNullWhen(false)] out byte[]? value) + public bool TryTake(string key, out ReadOnlySequence value) { if (key is null) { @@ -89,7 +77,7 @@ public bool TryTakePersistedState(string key, [MaybeNullWhen(false)] out byte[]? // and we don't want to fail in that case. // When a service is prerendering there is no state to restore and in other cases the host // is responsible for initializing the state before services or components can access it. - value = null; + value = default; return false; } @@ -105,27 +93,35 @@ public bool TryTakePersistedState(string key, [MaybeNullWhen(false)] out byte[]? } /// - /// Persists the serialized state for the given . + /// Persists the serialized state for the given . /// /// The key to use to persist the state. - /// The state to persist. - public void PersistState(string key, byte[] value) + /// The state to persist. + public void Persist(string key, Action> valueWriter) { if (key is null) { throw new ArgumentNullException(nameof(key)); } - if (value is null) + if (valueWriter is null) + { + throw new ArgumentNullException(nameof(valueWriter)); + } + + if (!PersistingState) { - throw new ArgumentNullException(nameof(value)); + throw new InvalidOperationException("Persisting state is only allowed during an OnPersisting callback."); } if (_currentState.ContainsKey(key)) { throw new ArgumentException($"There is already a persisted object under the same key '{key}'"); } - _currentState.Add(key, value); + + var writer = new PooledByteBufferWriter(); + _currentState.Add(key, writer); + valueWriter(writer); } /// @@ -142,29 +138,47 @@ public void PersistState(string key, byte[] value) throw new ArgumentNullException(nameof(key)); } - PersistState(key, JsonSerializer.SerializeToUtf8Bytes(instance, JsonSerializerOptionsProvider.Options)); + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (!PersistingState) + { + throw new InvalidOperationException("Persisting state is only allowed during an OnPersisting callback."); + } + + if (_currentState.ContainsKey(key)) + { + throw new ArgumentException($"There is already a persisted object under the same key '{key}'"); + } + + var writer = new PooledByteBufferWriter(); + _currentState.Add(key, writer); + JsonSerializer.Serialize(new Utf8JsonWriter(writer), instance, JsonSerializerOptionsProvider.Options); } /// /// Tries to retrieve the persisted state as JSON with the given and deserializes it into an /// instance of type . /// When the key is present, the state is successfully returned via - /// and removed from the . + /// and removed from the . /// /// The key used to persist the instance. /// The persisted instance. /// true if the state was found; false otherwise. [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")] - public bool TryTakeAsJson<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string key, [MaybeNullWhen(false)] out TValue? instance) + public bool TryTakeFromJson<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(string key, [MaybeNullWhen(false)] out TValue? instance) { if (key is null) { throw new ArgumentNullException(nameof(key)); } - if (TryTakePersistedState(key, out var data)) + if (TryTake(key, out var data)) { - instance = JsonSerializer.Deserialize(data, JsonSerializerOptionsProvider.Options)!; + var reader = new Utf8JsonReader(data); + instance = JsonSerializer.Deserialize(ref reader, JsonSerializerOptionsProvider.Options)!; return true; } else diff --git a/src/Components/Components/src/PersistingComponentStateSubscription.cs b/src/Components/Components/src/PersistingComponentStateSubscription.cs new file mode 100644 index 000000000000..7ae08a24ed92 --- /dev/null +++ b/src/Components/Components/src/PersistingComponentStateSubscription.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. + +using Microsoft.AspNetCore.Components.Infrastructure; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Represents a subscription to the OnPersisting callback that callback will trigger + /// when the application is being persisted. + /// + public readonly struct PersistingComponentStateSubscription : IDisposable + { + private readonly List>? _callbacks; + private readonly Func? _callback; + + internal PersistingComponentStateSubscription(List> callbacks, Func callback) + { + _callbacks = callbacks; + _callback = callback; + } + + /// + public void Dispose() + { + if (_callback != null) + { + _callbacks?.Remove(_callback); + } + } + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 9256e9071f44..2d96fbcd4675 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -4,13 +4,6 @@ *REMOVED*readonly Microsoft.AspNetCore.Components.RenderTree.RenderTreeEdit.RemovedAttributeName -> string! *REMOVED*Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad = false) -> void *REMOVED*abstract Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, bool forceLoad) -> void -Microsoft.AspNetCore.Components.ComponentApplicationState -Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersisting -> Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersistingCallback! -Microsoft.AspNetCore.Components.ComponentApplicationState.OnPersistingCallback -Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson(string! key, TValue instance) -> void -Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void -Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson(string! key, out TValue? instance) -> bool -Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool Microsoft.AspNetCore.Components.EditorRequiredAttribute Microsoft.AspNetCore.Components.EditorRequiredAttribute.EditorRequiredAttribute() -> void Microsoft.AspNetCore.Components.ErrorBoundaryBase @@ -23,14 +16,6 @@ Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.set -> void Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.get -> int Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.set -> void Microsoft.AspNetCore.Components.ErrorBoundaryBase.Recover() -> void -Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime -Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.ComponentApplicationLifetime(Microsoft.Extensions.Logging.ILogger! logger) -> void -Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.PersistStateAsync(Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.RestoreStateAsync(Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore! store) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.State.get -> Microsoft.AspNetCore.Components.ComponentApplicationState! -Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore -Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore.GetPersistedStateAsync() -> System.Threading.Tasks.Task!>! -Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore.PersistStateAsync(System.Collections.Generic.IReadOnlyDictionary! state) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, Microsoft.AspNetCore.Components.NavigationOptions options) -> void Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad = false, bool replace = false) -> void Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad) -> void @@ -43,6 +28,22 @@ Microsoft.AspNetCore.Components.NavigationOptions.ReplaceHistoryEntry.get -> boo Microsoft.AspNetCore.Components.NavigationOptions.ReplaceHistoryEntry.init -> void Microsoft.AspNetCore.Components.RenderHandle.IsRenderingOnMetadataUpdate.get -> bool Microsoft.AspNetCore.Components.RenderTree.Renderer.RemoveRootComponent(int componentId) -> void +Microsoft.AspNetCore.Components.IPersistentComponentStateStore +Microsoft.AspNetCore.Components.IPersistentComponentStateStore.GetPersistedStateAsync() -> System.Threading.Tasks.Task>!>! +Microsoft.AspNetCore.Components.IPersistentComponentStateStore.PersistStateAsync(System.Collections.Generic.IReadOnlyDictionary>! state) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger) -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.RestoreStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.State.get -> Microsoft.AspNetCore.Components.PersistentComponentState! +Microsoft.AspNetCore.Components.PersistentComponentState +Microsoft.AspNetCore.Components.PersistentComponentState.Persist(string! key, System.Action!>! valueWriter) -> void +Microsoft.AspNetCore.Components.PersistentComponentState.PersistAsJson(string! key, TValue instance) -> void +Microsoft.AspNetCore.Components.PersistentComponentState.RegisterOnPersisting(System.Func! callback) -> Microsoft.AspNetCore.Components.PersistingComponentStateSubscription +Microsoft.AspNetCore.Components.PersistentComponentState.TryTake(string! key, out System.Buffers.ReadOnlySequence value) -> bool +Microsoft.AspNetCore.Components.PersistentComponentState.TryTakeFromJson(string! key, out TValue? instance) -> bool +Microsoft.AspNetCore.Components.PersistingComponentStateSubscription +Microsoft.AspNetCore.Components.PersistingComponentStateSubscription.Dispose() -> void Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.Dispose() -> void Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void diff --git a/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs b/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs index d7046bd12a69..8866e3a312be 100644 --- a/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs +++ b/src/Components/Components/test/Lifetime/ComponentApplicationLifetimeTest.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -20,19 +22,19 @@ public class ComponentApplicationLifetimeTest public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore() { // Arrange - byte[] data = new byte[] { 0, 1, 2, 3, 4 }; - var state = new Dictionary + var data = new ReadOnlySequence(new byte[] { 0, 1, 2, 3, 4 }); + var state = new Dictionary> { ["MyState"] = data }; var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); // Act await lifetime.RestoreStateAsync(store); // Assert - Assert.True(lifetime.State.TryTakePersistedState("MyState", out var retrieved)); + Assert.True(lifetime.State.TryTake("MyState", out var retrieved)); Assert.Empty(state); Assert.Equal(data, retrieved); } @@ -41,12 +43,12 @@ public async Task RestoreStateAsync_InitializesStateWithDataFromTheProvidedStore public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() { // Arrange - var state = new Dictionary + var state = new Dictionary> { - ["MyState"] = new byte[] { 0, 1, 2, 3, 4 } + ["MyState"] = new ReadOnlySequence(new byte[] { 0, 1, 2, 3, 4 }) }; var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); await lifetime.RestoreStateAsync(store); @@ -58,35 +60,39 @@ public async Task RestoreStateAsync_ThrowsOnDoubleInitialization() public async Task PersistStateAsync_SavesPersistedStateToTheStore() { // Arrange - var state = new Dictionary(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; - lifetime.State.PersistState("MyState", new byte[] { 1, 2, 3, 4 }); + lifetime.State.RegisterOnPersisting(() => + { + lifetime.State.Persist("MyState", writer => writer.Write(new byte[] { 1, 2, 3, 4 })); + return Task.CompletedTask; + }); // Act await lifetime.PersistStateAsync(store, renderer); // Assert Assert.True(store.State.TryGetValue("MyState", out var persisted)); - Assert.Equal(data, persisted); + Assert.Equal(data, persisted.ToArray()); } [Fact] public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() { // Arrange - var state = new Dictionary(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; - lifetime.State.OnPersisting += () => { invoked = true; return default; }; + lifetime.State.RegisterOnPersisting(() => { invoked = true; return default; }); // Act await lifetime.PersistStateAsync(store, renderer); @@ -99,9 +105,9 @@ public async Task PersistStateAsync_InvokesPauseCallbacksDuringPersist() public async Task PersistStateAsync_FiresCallbacksInParallel() { // Arrange - var state = new Dictionary(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); var renderer = new TestRenderer(); var sequence = new List { }; @@ -109,8 +115,8 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() var tcs = new TaskCompletionSource(); var tcs2 = new TaskCompletionSource(); - lifetime.State.OnPersisting += async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }; - lifetime.State.OnPersisting += async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }; + lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }); + lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }); // Act var persistTask = lifetime.PersistStateAsync(store, renderer); @@ -123,22 +129,53 @@ public async Task PersistStateAsync_FiresCallbacksInParallel() Assert.Equal(new[] { 1, 2, 3, 4 }, sequence); } + [Fact] + public async Task PersistStateAsync_CallbacksAreRemovedWhenSubscriptionsAreDisposed() + { + // Arrange + var state = new Dictionary>(); + var store = new TestStore(state); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); + var renderer = new TestRenderer(); + + var sequence = new List { }; + + var tcs = new TaskCompletionSource(); + var tcs2 = new TaskCompletionSource(); + + var subscription1 = lifetime.State.RegisterOnPersisting(async () => { sequence.Add(1); await tcs.Task; sequence.Add(3); }); + var subscription2 = lifetime.State.RegisterOnPersisting(async () => { sequence.Add(2); await tcs2.Task; sequence.Add(4); }); + + // Act + subscription1.Dispose(); + subscription2.Dispose(); + + var persistTask = lifetime.PersistStateAsync(store, renderer); + tcs.SetResult(); + tcs2.SetResult(); + + await persistTask; + + // Assert + Assert.Empty(sequence); + } + [Fact] public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersistIfACallbackThrows() { // Arrange var sink = new TestSink(); var loggerFactory = new TestLoggerFactory(sink, true); - var logger = loggerFactory.CreateLogger(); - var state = new Dictionary(); + var logger = loggerFactory.CreateLogger(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(logger); + var lifetime = new ComponentStatePersistenceManager(logger); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; var invoked = false; - lifetime.State.OnPersisting += () => throw new InvalidOperationException(); - lifetime.State.OnPersisting += () => { invoked = true; return Task.CompletedTask; }; + lifetime.State.RegisterOnPersisting(() => throw new InvalidOperationException()); + lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }); // Act await lifetime.PersistStateAsync(store, renderer); @@ -155,16 +192,16 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist // Arrange var sink = new TestSink(); var loggerFactory = new TestLoggerFactory(sink, true); - var logger = loggerFactory.CreateLogger(); - var state = new Dictionary(); + var logger = loggerFactory.CreateLogger(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(logger); + var lifetime = new ComponentStatePersistenceManager(logger); var renderer = new TestRenderer(); var invoked = false; var tcs = new TaskCompletionSource(); - lifetime.State.OnPersisting += async () => { await tcs.Task; throw new InvalidOperationException(); }; - lifetime.State.OnPersisting += () => { invoked = true; return Task.CompletedTask; }; + lifetime.State.RegisterOnPersisting(async () => { await tcs.Task; throw new InvalidOperationException(); }); + lifetime.State.RegisterOnPersisting(() => { invoked = true; return Task.CompletedTask; }); // Act var persistTask = lifetime.PersistStateAsync(store, renderer); @@ -182,14 +219,18 @@ public async Task PersistStateAsync_ContinuesInvokingPauseCallbacksDuringPersist public async Task PersistStateAsync_ThrowsWhenDeveloperTriesToPersistStateMultipleTimes() { // Arrange - var state = new Dictionary(); + var state = new Dictionary>(); var store = new TestStore(state); - var lifetime = new ComponentApplicationLifetime(NullLogger.Instance); + var lifetime = new ComponentStatePersistenceManager(NullLogger.Instance); var renderer = new TestRenderer(); var data = new byte[] { 1, 2, 3, 4 }; - lifetime.State.PersistState("MyState", new byte[] { 1, 2, 3, 4 }); + lifetime.State.RegisterOnPersisting(() => + { + lifetime.State.Persist("MyState", writer => writer.Write(new byte[] { 1, 2, 3, 4 })); + return Task.CompletedTask; + }); // Act await lifetime.PersistStateAsync(store, renderer); @@ -219,23 +260,24 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) } } - private class TestStore : IComponentApplicationStateStore + private class TestStore : IPersistentComponentStateStore { - public TestStore(IDictionary initialState) + public TestStore(IDictionary> initialState) { State = initialState; } - public IDictionary State { get; set; } + public IDictionary> State { get; set; } - public Task> GetPersistedStateAsync() + public Task>> GetPersistedStateAsync() { return Task.FromResult(State); } - public Task PersistStateAsync(IReadOnlyDictionary state) + public Task PersistStateAsync(IReadOnlyDictionary> state) { - State = new Dictionary(state); + // We copy the data here because it's no longer available after this call completes. + State = state.ToDictionary(kvp => kvp.Key, kvp => new ReadOnlySequence(kvp.Value.ToArray())); return Task.CompletedTask; } } diff --git a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs b/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs index d9a434260efe..53e4a8e764e1 100644 --- a/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs +++ b/src/Components/Components/test/Lifetime/ComponentApplicationStateTest.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Infrastructure; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Microsoft.AspNetCore.Components @@ -15,28 +19,28 @@ public class ComponentApplicationStateTest public void InitializeExistingState_SetupsState() { // Arrange - var applicationState = new ComponentApplicationState(new Dictionary(), new List()); - var existingState = new Dictionary + var applicationState = new PersistentComponentState(new Dictionary(), new List>()); + var existingState = new Dictionary> { - ["MyState"] = new byte[] { 1, 2, 3, 4 } + ["MyState"] = new ReadOnlySequence(new byte[] { 1, 2, 3, 4 }) }; // Act applicationState.InitializeExistingState(existingState); // Assert - Assert.True(applicationState.TryTakePersistedState("MyState", out var existing)); - Assert.Equal(new byte[] { 1, 2, 3, 4 }, existing); + Assert.True(applicationState.TryTake("MyState", out var existing)); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, existing.ToArray()); } [Fact] public void InitializeExistingState_ThrowsIfAlreadyInitialized() { // Arrange - var applicationState = new ComponentApplicationState(new Dictionary(), new List()); - var existingState = new Dictionary + var applicationState = new PersistentComponentState(new Dictionary(), new List>()); + var existingState = new Dictionary> { - ["MyState"] = new byte[] { 1, 2, 3, 4 } + ["MyState"] = new ReadOnlySequence(new byte[] { 1, 2, 3, 4 }) }; applicationState.InitializeExistingState(existingState); @@ -49,57 +53,60 @@ public void InitializeExistingState_ThrowsIfAlreadyInitialized() public void TryRetrieveState_ReturnsStateWhenItExists() { // Arrange - var applicationState = new ComponentApplicationState(new Dictionary(), new List()); - var existingState = new Dictionary + var applicationState = new PersistentComponentState(new Dictionary(), new List>()); + var existingState = new Dictionary> { - ["MyState"] = new byte[] { 1, 2, 3, 4 } + ["MyState"] = new ReadOnlySequence(new byte[] { 1, 2, 3, 4 }) }; // Act applicationState.InitializeExistingState(existingState); // Assert - Assert.True(applicationState.TryTakePersistedState("MyState", out var existing)); - Assert.Equal(new byte[] { 1, 2, 3, 4 }, existing); - Assert.False(applicationState.TryTakePersistedState("MyState", out var gone)); + Assert.True(applicationState.TryTake("MyState", out var existing)); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, existing.ToArray()); + Assert.False(applicationState.TryTake("MyState", out var gone)); } [Fact] - public void PersistState_SavesDataToTheStore() + public void PersistState_SavesDataToTheStoreAsync() { // Arrange - var currentState = new Dictionary(); - var applicationState = new ComponentApplicationState(currentState, new List()); + var currentState = new Dictionary(); + var applicationState = new PersistentComponentState(currentState, new List>()); + applicationState.PersistingState = true; var myState = new byte[] { 1, 2, 3, 4 }; // Act - applicationState.PersistState("MyState", myState); + applicationState.Persist("MyState", writer => writer.Write(myState)); // Assert Assert.True(currentState.TryGetValue("MyState", out var stored)); - Assert.Equal(myState, stored); + Assert.Equal(myState, stored.WrittenMemory.Span.ToArray()); } [Fact] public void PersistState_ThrowsForDuplicateKeys() { // Arrange - var currentState = new Dictionary(); - var applicationState = new ComponentApplicationState(currentState, new List()); + var currentState = new Dictionary(); + var applicationState = new PersistentComponentState(currentState, new List>()); + applicationState.PersistingState = true; var myState = new byte[] { 1, 2, 3, 4 }; - applicationState.PersistState("MyState", myState); + applicationState.Persist("MyState", writer => writer.Write(myState)); // Act & Assert - Assert.Throws(() => applicationState.PersistState("MyState", myState)); + Assert.Throws(() => applicationState.Persist("MyState", writer => writer.Write(myState))); } [Fact] - public void PersistAsJson_SerializesTheDataToJson() + public void PersistAsJson_SerializesTheDataToJsonAsync() { // Arrange - var currentState = new Dictionary(); - var applicationState = new ComponentApplicationState(currentState, new List()); + var currentState = new Dictionary(); + var applicationState = new PersistentComponentState(currentState, new List>()); + applicationState.PersistingState = true; var myState = new byte[] { 1, 2, 3, 4 }; // Act @@ -107,22 +114,23 @@ public void PersistAsJson_SerializesTheDataToJson() // Assert Assert.True(currentState.TryGetValue("MyState", out var stored)); - Assert.Equal(myState, JsonSerializer.Deserialize(stored)); + Assert.Equal(myState, JsonSerializer.Deserialize(stored.WrittenMemory.Span)); } [Fact] - public void PersistAsJson_NullValue() + public void PersistAsJson_NullValueAsync() { // Arrange - var currentState = new Dictionary(); - var applicationState = new ComponentApplicationState(currentState, new List()); + var currentState = new Dictionary(); + var applicationState = new PersistentComponentState(currentState, new List>()); + applicationState.PersistingState = true; // Act applicationState.PersistAsJson("MyState", null); // Assert Assert.True(currentState.TryGetValue("MyState", out var stored)); - Assert.Null(JsonSerializer.Deserialize(stored)); + Assert.Null(JsonSerializer.Deserialize(stored.WrittenMemory.Span)); } [Fact] @@ -131,17 +139,17 @@ public void TryRetrieveFromJson_DeserializesTheDataFromJson() // Arrange var myState = new byte[] { 1, 2, 3, 4 }; var serialized = JsonSerializer.SerializeToUtf8Bytes(myState); - var existingState = new Dictionary() { ["MyState"] = serialized }; - var applicationState = new ComponentApplicationState(new Dictionary(), new List()); + var existingState = new Dictionary>() { ["MyState"] = new ReadOnlySequence(serialized) }; + var applicationState = new PersistentComponentState(new Dictionary(), new List>()); applicationState.InitializeExistingState(existingState); // Act - Assert.True(applicationState.TryTakeAsJson("MyState", out var stored)); + Assert.True(applicationState.TryTakeFromJson("MyState", out var stored)); // Assert Assert.Equal(myState, stored); - Assert.False(applicationState.TryTakeAsJson("MyState", out _)); + Assert.False(applicationState.TryTakeFromJson("MyState", out _)); } [Fact] @@ -149,17 +157,17 @@ public void TryRetrieveFromJson_NullValue() { // Arrange var serialized = JsonSerializer.SerializeToUtf8Bytes(null); - var existingState = new Dictionary() { ["MyState"] = serialized }; - var applicationState = new ComponentApplicationState(new Dictionary(), new List()); + var existingState = new Dictionary>() { ["MyState"] = new ReadOnlySequence(serialized) }; + var applicationState = new PersistentComponentState(new Dictionary(), new List>()); applicationState.InitializeExistingState(existingState); // Act - Assert.True(applicationState.TryTakeAsJson("MyState", out var stored)); + Assert.True(applicationState.TryTakeFromJson("MyState", out var stored)); // Assert Assert.Null(stored); - Assert.False(applicationState.TryTakeAsJson("MyState", out _)); + Assert.False(applicationState.TryTakeFromJson("MyState", out _)); } } } diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index aa5a3e20a1f7..fbc29aadbeb5 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; @@ -43,7 +43,7 @@ public async ValueTask CreateCircuitHostAsync( string baseUri, string uri, ClaimsPrincipal user, - IComponentApplicationStateStore store) + IPersistentComponentStateStore store) { var scope = _scopeFactory.CreateAsyncScope(); var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService(); @@ -63,7 +63,7 @@ public async ValueTask CreateCircuitHostAsync( navigationManager.Initialize(baseUri, uri); } - var appLifetime = scope.ServiceProvider.GetRequiredService(); + var appLifetime = scope.ServiceProvider.GetRequiredService(); await appLifetime.RestoreStateAsync(store); var jsComponentInterop = new CircuitJSComponentInterop(_options); diff --git a/src/Components/Server/src/Circuits/CircuitHandleRegistry.cs b/src/Components/Server/src/Circuits/CircuitHandleRegistry.cs index 3591eda47861..5414e9a031f6 100644 --- a/src/Components/Server/src/Circuits/CircuitHandleRegistry.cs +++ b/src/Components/Server/src/Circuits/CircuitHandleRegistry.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; @@ -18,14 +17,14 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits { internal sealed class CircuitHandleRegistry : ICircuitHandleRegistry { - public CircuitHandle GetCircuitHandle(IDictionarycircuitHandles, object circuitKey) + public CircuitHandle GetCircuitHandle(IDictionary circuitHandles, object circuitKey) { if (circuitHandles.TryGetValue(circuitKey, out var circuitHandle)) { - return (CircuitHandle) circuitHandle; + return (CircuitHandle)circuitHandle; } - return null;; + return null; ; } public CircuitHost GetCircuit(IDictionary circuitHandles, object circuitKey) @@ -38,7 +37,7 @@ public CircuitHost GetCircuit(IDictionary circuitHandles, objec return null; } - public void SetCircuit(IDictionary circuitHandles, object circuitKey, CircuitHost circuitHost) + public void SetCircuit(IDictionary circuitHandles, object circuitKey, CircuitHost circuitHost) { circuitHandles[circuitKey] = circuitHost?.Handle; } diff --git a/src/Components/Server/src/Circuits/ICircuitFactory.cs b/src/Components/Server/src/Circuits/ICircuitFactory.cs index 4c6027248086..75098bae1e39 100644 --- a/src/Components/Server/src/Circuits/ICircuitFactory.cs +++ b/src/Components/Server/src/Circuits/ICircuitFactory.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +24,6 @@ ValueTask CreateCircuitHostAsync( string baseUri, string uri, ClaimsPrincipal user, - IComponentApplicationStateStore store); + IPersistentComponentStateStore store); } } diff --git a/src/Components/Server/src/Circuits/ICircuitHandleRegistry.cs b/src/Components/Server/src/Circuits/ICircuitHandleRegistry.cs index 799ab63853c6..bf43c17eb3a1 100644 --- a/src/Components/Server/src/Circuits/ICircuitHandleRegistry.cs +++ b/src/Components/Server/src/Circuits/ICircuitHandleRegistry.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs index f2a43f6ed124..b2fb641c52da 100644 --- a/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs +++ b/src/Components/Server/src/Circuits/IServerComponentDeserializer.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 5f6bfc09de3a..dd6358b66897 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -89,6 +89,7 @@ + diff --git a/src/Components/Server/test/Circuits/ComponentHubTest.cs b/src/Components/Server/test/Circuits/ComponentHubTest.cs index 210b4eeeef89..9fd79f65126d 100644 --- a/src/Components/Server/test/Circuits/ComponentHubTest.cs +++ b/src/Components/Server/test/Circuits/ComponentHubTest.cs @@ -7,7 +7,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.SignalR; @@ -181,7 +181,7 @@ public ValueTask CreateCircuitHostAsync( string baseUri, string uri, ClaimsPrincipal user, - IComponentApplicationStateStore store) + IPersistentComponentStateStore store) { var serviceScope = new Mock(); var circuitHost = TestCircuitHost.Create(serviceScope: new AsyncServiceScope(serviceScope.Object)); diff --git a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Client/Pages/FetchData.razor b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Client/Pages/FetchData.razor index 1e0b4f51c4c1..5450f197c2ab 100644 --- a/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Client/Pages/FetchData.razor +++ b/src/Components/WebAssembly/Samples/HostedBlazorWebassemblyApp/Client/Pages/FetchData.razor @@ -2,7 +2,7 @@ @implements IDisposable @using HostedBlazorWebassemblyApp.Shared @inject IWeatherForecastService WeatherForecastService -@inject ComponentApplicationState ApplicationState +@inject PersistentComponentState ApplicationState

Weather forecast

@@ -39,13 +39,19 @@ else @code { private WeatherForecast[] forecasts = Array.Empty(); + private PersistingComponentStateSubscription _persistingSubscription; protected override async Task OnInitializedAsync() { - ApplicationState.OnPersisting += PersistForecasts; - forecasts = !ApplicationState.TryTakeAsJson("fetchdata", out var restored) ? - await WeatherForecastService.GetForecastAsync(DateTime.Now) : - restored!; + _persistingSubscription = ApplicationState.RegisterOnPersisting(PersistForecasts); + if(!ApplicationState.TryTakeFromJson("fetchdata", out var restored)) + { + forecasts = await WeatherForecastService.GetForecastAsync(DateTime.Now); + } + else + { + forecasts = restored!; + } } private Task PersistForecasts() @@ -56,6 +62,6 @@ else void IDisposable.Dispose() { - ApplicationState.OnPersisting -= PersistForecasts; + _persistingSubscription.Dispose(); } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index f38ba877a1e7..42522b5a11d3 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -1,9 +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; using System.Reflection.Metadata; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Web.Infrastructure; using Microsoft.AspNetCore.Components.WebAssembly.HotReload; using Microsoft.AspNetCore.Components.WebAssembly.Infrastructure; @@ -129,7 +128,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl // This is the earliest opportunity to fetch satellite assemblies for this selection. await cultureProvider.LoadCurrentCultureResourcesAsync(); - var manager = Services.GetRequiredService(); + var manager = Services.GetRequiredService(); var store = !string.IsNullOrEmpty(_persistedState) ? new PrerenderComponentApplicationStore(_persistedState) : new PrerenderComponentApplicationStore(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 8176d3635b92..53376f14c1f1 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -6,7 +6,8 @@ using System.Globalization; using System.IO; using System.Text.Json; -using Microsoft.AspNetCore.Components.Lifetime; +using System.Linq; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; @@ -254,8 +255,8 @@ internal void InitializeDefaultServices() Services.AddSingleton(WebAssemblyNavigationManager.Instance); Services.AddSingleton(WebAssemblyNavigationInterception.Instance); Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance)); - Services.AddSingleton(); - Services.AddSingleton(sp => sp.GetRequiredService().State); + Services.AddSingleton(); + Services.AddSingleton(sp => sp.GetRequiredService().State); Services.AddSingleton(); Services.AddLogging(builder => { diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index edd1d5ef6c81..69a68e966989 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -33,6 +33,7 @@ + diff --git a/src/Components/test/E2ETest/Tests/CircuitTests.cs b/src/Components/test/E2ETest/Tests/CircuitTests.cs index 39ab552af5a5..e4354bb1d4f0 100644 --- a/src/Components/test/E2ETest/Tests/CircuitTests.cs +++ b/src/Components/test/E2ETest/Tests/CircuitTests.cs @@ -12,7 +12,7 @@ using OpenQA.Selenium; using TestServer; using Xunit; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests @@ -99,4 +99,4 @@ void AssertLogContains(params string[] messages) } } } -} \ No newline at end of file +} diff --git a/src/Components/test/testassets/BasicTestApp/PreserveStateComponent.razor b/src/Components/test/testassets/BasicTestApp/PreserveStateComponent.razor index 62eb0acb5fe9..f8254259f626 100644 --- a/src/Components/test/testassets/BasicTestApp/PreserveStateComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/PreserveStateComponent.razor @@ -1,6 +1,8 @@ -@inject ComponentApplicationState AppState +@inject PersistentComponentState AppState @inject PreserveStateService PreserveService +@implements IDisposable @using System.Text +@using System.Buffers

@State: @_state

@@ -40,30 +42,47 @@ private string _extraState = null; private bool _restored; private bool? _extraStateAvailable; + private PersistingComponentStateSubscription _registration; protected override void OnInitialized() { - if (AppState.TryTakePersistedState(State, out var preserved)) + if (AppState.TryTake(State, out var preserved)) { _state = Encoding.UTF8.GetString(preserved); _restored = true; } else { - _state = Guid.NewGuid().ToString(); - AppState.PersistState(State, Encoding.UTF8.GetBytes(_state)); - _extraState = Guid.NewGuid().ToString(); if(ExtraState != null) { _extraStateAvailable = true; - AppState.PersistState(ExtraState, Encoding.UTF8.GetBytes(_extraState)); } + _state = Guid.NewGuid().ToString(); + _extraState = Guid.NewGuid().ToString(); + _registration = AppState.RegisterOnPersisting(PersistState); + + Task PersistState() + { + AppState.Persist(State, writer => WriteUtf8Bytes(writer, _state)); + if(ExtraState != null) + { + AppState.Persist(ExtraState, writer => WriteUtf8Bytes(writer, _extraState)); + } + + return Task.CompletedTask; + } + } + + static void WriteUtf8Bytes(IBufferWriter writer, string state) + { + var bytes = Encoding.UTF8.GetBytes(state); + writer.Write(bytes); } } public void DisplayExtraState() { - if (AppState.TryTakePersistedState(ExtraState, out var extraState)) + if (AppState.TryTake(ExtraState, out var extraState)) { _extraStateAvailable = true; _extraState = Encoding.UTF8.GetString(extraState); @@ -74,4 +93,9 @@ _extraState = Guid.NewGuid().ToString(); } } + + void IDisposable.Dispose() + { + _registration.Dispose(); + } } diff --git a/src/Components/test/testassets/BasicTestApp/PreserveStateService.cs b/src/Components/test/testassets/BasicTestApp/PreserveStateService.cs index 12b983faaeed..e511a121152e 100644 --- a/src/Components/test/testassets/BasicTestApp/PreserveStateService.cs +++ b/src/Components/test/testassets/BasicTestApp/PreserveStateService.cs @@ -9,14 +9,15 @@ namespace BasicTestApp { public class PreserveStateService : IDisposable { - private readonly ComponentApplicationState _componentApplicationState; + private readonly PersistentComponentState _componentApplicationState; + private PersistingComponentStateSubscription _persistingSubscription; private ServiceState _state = new(); - public PreserveStateService(ComponentApplicationState componentApplicationState) + public PreserveStateService(PersistentComponentState componentApplicationState) { _componentApplicationState = componentApplicationState; - _componentApplicationState.OnPersisting += PersistState; + _persistingSubscription = _componentApplicationState.RegisterOnPersisting(PersistState); TryRestoreState(); } @@ -24,7 +25,7 @@ public PreserveStateService(ComponentApplicationState componentApplicationState) private void TryRestoreState() { - if (_componentApplicationState.TryTakeAsJson("Service", out var state)) + if (_componentApplicationState.TryTakeFromJson("Service", out var state)) { _state = state; } @@ -42,10 +43,7 @@ private Task PersistState() return Task.CompletedTask; } - public void Dispose() - { - _componentApplicationState.OnPersisting -= PersistState; - } + public void Dispose() => _persistingSubscription.Dispose(); private class ServiceState { diff --git a/src/Components/test/testassets/TestServer/Properties/launchSettings.json b/src/Components/test/testassets/TestServer/Properties/launchSettings.json index 363c51bfb4e2..cc8a3eab198e 100644 --- a/src/Components/test/testassets/TestServer/Properties/launchSettings.json +++ b/src/Components/test/testassets/TestServer/Properties/launchSettings.json @@ -14,7 +14,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" + "applicationUrl": "https://localhost:5003;http://localhost:5002" }, "IIS Express": { "commandName": "IISExpress", diff --git a/src/Mvc/Mvc.TagHelpers/src/PersistComponentStateTagHelper.cs b/src/Mvc/Mvc.TagHelpers/src/PersistComponentStateTagHelper.cs index 0ccd9da96f8b..a85cb99630fa 100644 --- a/src/Mvc/Mvc.TagHelpers/src/PersistComponentStateTagHelper.cs +++ b/src/Mvc/Mvc.TagHelpers/src/PersistComponentStateTagHelper.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.IO; +using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Html; @@ -56,7 +58,7 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu } var services = ViewContext.HttpContext.RequestServices; - var manager = services.GetRequiredService(); + var manager = services.GetRequiredService(); var renderer = services.GetRequiredService(); var store = PersistenceMode switch { @@ -88,9 +90,29 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu output.Content.SetHtmlContent( new HtmlContentBuilder() .AppendHtml("")); } } + + private class ComponentStateHtmlContent : IHtmlContent + { + private PrerenderComponentApplicationStore _store; + + public ComponentStateHtmlContent(PrerenderComponentApplicationStore store) + { + _store = store; + } + + public void WriteTo(TextWriter writer, HtmlEncoder encoder) + { + if (_store != null) + { + writer.Write(_store.PersistedState.Span); + _store.Dispose(); + _store = null; + } + } + } } } diff --git a/src/Mvc/Mvc.TagHelpers/test/PersistComponentStateTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/PersistComponentStateTagHelperTest.cs index 40ab053eace5..5de4b1d5fbea 100644 --- a/src/Mvc/Mvc.TagHelpers/test/PersistComponentStateTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/PersistComponentStateTagHelperTest.cs @@ -5,8 +5,7 @@ using System.Collections.Generic; using System.Text.Encodings.Web; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Html; @@ -191,7 +190,7 @@ private ViewContext GetViewContext() { RequestServices = new ServiceCollection() .AddSingleton(renderer) - .AddSingleton(new ComponentApplicationLifetime(NullLogger.Instance)) + .AddSingleton(new ComponentStatePersistenceManager(NullLogger.Instance)) .AddSingleton() .AddSingleton(_ephemeralProvider) .AddSingleton(NullLoggerFactory.Instance) diff --git a/src/Mvc/Mvc.TagHelpers/test/PrerenderComponentApplicationStoreTest.cs b/src/Mvc/Mvc.TagHelpers/test/PrerenderComponentApplicationStoreTest.cs index e6002fe27b43..b78f34145aee 100644 --- a/src/Mvc/Mvc.TagHelpers/test/PrerenderComponentApplicationStoreTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/PrerenderComponentApplicationStoreTest.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Xunit; @@ -17,16 +19,16 @@ public async Task PersistStateAsync_PersistsGivenState() // Arrange var expected = "eyJNeVZhbHVlIjoiQVFJREJBPT0ifQ=="; var store = new PrerenderComponentApplicationStore(); - var state = new Dictionary() + var state = new Dictionary>() { - ["MyValue"] = new byte[] {1,2,3,4} + ["MyValue"] = new ReadOnlySequence(new byte[] {1,2,3,4}) }; // Act await store.PersistStateAsync(state); // Assert - Assert.Equal(expected, store.PersistedState); + Assert.Equal(expected, store.PersistedState.Span.ToString()); } [Fact] @@ -35,16 +37,18 @@ public async Task GetPersistedStateAsync_RestoresPreexistingStateAsync() // Arrange var persistedState = "eyJNeVZhbHVlIjoiQVFJREJBPT0ifQ=="; var store = new PrerenderComponentApplicationStore(persistedState); - var expected = new Dictionary() + var expected = new Dictionary>() { - ["MyValue"] = new byte[] { 1, 2, 3, 4 } + ["MyValue"] = new ReadOnlySequence(new byte [] { 1, 2, 3, 4 }) }; // Act var state = await store.GetPersistedStateAsync(); // Assert - Assert.Equal(expected, state); + Assert.Equal( + expected.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()), + state.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray())); } } } diff --git a/src/Mvc/Mvc.TagHelpers/test/ProtectedPrerenderComponentApplicationStateTest.cs b/src/Mvc/Mvc.TagHelpers/test/ProtectedPrerenderComponentApplicationStateTest.cs index 83c0f7f2a5ae..854950be23ec 100644 --- a/src/Mvc/Mvc.TagHelpers/test/ProtectedPrerenderComponentApplicationStateTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/ProtectedPrerenderComponentApplicationStateTest.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; +using System.Linq; using System.Security.Cryptography; using System.Text.Json; using System.Threading.Tasks; @@ -24,23 +26,23 @@ public async Task PersistStateAsync_ProtectsPersistedState() var expected = @"{""MyValue"":""AQIDBA==""}"; var store = new ProtectedPrerenderComponentApplicationStore(_provider); - var state = new Dictionary() + var state = new Dictionary>() { - ["MyValue"] = new byte[] { 1, 2, 3, 4 } + ["MyValue"] = new ReadOnlySequence(new byte[] { 1, 2, 3, 4 }) }; // Act await store.PersistStateAsync(state); // Assert - Assert.Equal(expected, _protector.Unprotect(store.PersistedState)); + Assert.Equal(expected, _protector.Unprotect(store.PersistedState.Span.ToString())); } [Fact] public async Task GetPersistStateAsync_CanUnprotectPersistedState() { // Arrange - var expectedState = new Dictionary() + var expectedState = new Dictionary() { ["MyValue"] = new byte[] { 1, 2, 3, 4 } }; @@ -52,7 +54,9 @@ public async Task GetPersistStateAsync_CanUnprotectPersistedState() var restored = await store.GetPersistedStateAsync(); // Assert - Assert.Equal(expectedState, restored); + Assert.Equal( + expectedState.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()), + restored.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray())); } [Fact] diff --git a/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs index d23fee1a94e1..5c5300f125b9 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs @@ -5,7 +5,7 @@ using System.Buffers; using System.Linq; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; @@ -239,8 +239,8 @@ internal static void AddViewServices(IServiceCollection services) services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(sp => sp.GetRequiredService().State); + services.TryAddScoped(); + services.TryAddScoped(sp => sp.GetRequiredService().State); services.TryAddScoped(); services.TryAddTransient(); diff --git a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj index f8674f725c6c..e14055dd922a 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj +++ b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs index bc01433b7cdf..6b8c9539fe77 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Http; @@ -88,7 +88,7 @@ async Task InitializeCore(HttpContext httpContext) // It's important that this is initialized since a component might try to restore state during prerendering // (which will obviously not work, but should not fail) - var componentApplicationLifetime = httpContext.RequestServices.GetRequiredService(); + var componentApplicationLifetime = httpContext.RequestServices.GetRequiredService(); await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore()); } } diff --git a/src/Mvc/Mvc.ViewFeatures/test/RazorComponents/ComponentRendererTest.cs b/src/Mvc/Mvc.ViewFeatures/test/RazorComponents/ComponentRendererTest.cs index a2b10b358a90..3b15c2f7def1 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/RazorComponents/ComponentRendererTest.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/RazorComponents/ComponentRendererTest.cs @@ -9,7 +9,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; @@ -881,9 +881,9 @@ private static ServiceCollection CreateDefaultServiceCollection() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, NullLogger>(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService().State); + services.AddSingleton, NullLogger>(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService().State); return services; } diff --git a/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/PreserveComponentStateBenchmark.cs b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/PreserveComponentStateBenchmark.cs new file mode 100644 index 000000000000..7f485bd8a4cc --- /dev/null +++ b/src/Mvc/perf/Microbenchmarks/Microsoft.AspNetCore.Mvc/PreserveComponentStateBenchmark.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.AspNetCore.Mvc.Microbenchmarks +{ + public class PreserveComponentStateBenchmark + { + private readonly PersistComponentStateTagHelper _tagHelper = new() + { + PersistenceMode = PersistenceMode.WebAssembly + }; + + TagHelperAttributeList _attributes = new(); + + private TagHelperContext _context; + private Func> _childContent = + (_, __) => Task.FromResult(new DefaultTagHelperContent() as TagHelperContent); + private IServiceProvider _serviceProvider; + private IServiceScope _serviceScope; + private TagHelperOutput _output; + private Dictionary _entries = new(); + + private byte[] _entryValue; + + public PreserveComponentStateBenchmark() + { + _context = new TagHelperContext(_attributes, new Dictionary(), "asdf"); + _serviceProvider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddScoped(typeof(ILogger<>), typeof(NullLogger<>)) + .AddMvc().Services.BuildServiceProvider(); + } + + // From 30 entries of about 100 bytes (~3K) to 100 entries with 100K per entry (~10MB) + // Sending 10MB of prerendered state is too much, and only used as a way to "stress" the system. + // In general, so long as entries don't exceed the buffer limits we are ok. + // 300 Kb is the upper limit of a reasonable payload for prerendered state + // The 8386 was selected by serializing 100 weather forecast records as a reference + // For regular runs we only enable by default 30 entries and 8386 bytes per entry, which is about 250K of serialized + // state on the limit of the accepted payload size budget for critical resources served from a page. + [Params(30 /*, 100*/)] + public int Entries; + + [Params(/*100,*/ 8386/*, 100_000*/)] + public int EntrySize; + + [GlobalSetup] + public void Setup() + { + _entryValue = new byte[EntrySize]; + RandomNumberGenerator.Fill(_entryValue); + for (int i = 0; i < Entries; i++) + { + _entries.Add(i.ToString(CultureInfo.InvariantCulture), _entryValue); + } + } + + [Benchmark(Description = "Persist component state tag helper webassembly")] + public async Task PersistComponentStateTagHelperWebAssemblyAsync() + { + _tagHelper.ViewContext = GetViewContext(); + var state = _tagHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); + foreach (var (key,value) in _entries) + { + state.Persist(key, writer => writer.Write(value)); + } + + _output = new TagHelperOutput("persist-component-state", _attributes, _childContent); + _output.Content = new DefaultTagHelperContent(); + await _tagHelper.ProcessAsync(_context, _output); + _output.Content.WriteTo(StreamWriter.Null, NullHtmlEncoder.Default); + _serviceScope.Dispose(); + } + + private ViewContext GetViewContext() + { + _serviceScope = _serviceProvider.GetRequiredService().CreateScope(); + var httpContext = new DefaultHttpContext + { + RequestServices = _serviceScope.ServiceProvider + }; + + return new ViewContext + { + HttpContext = httpContext, + }; + } + } +} diff --git a/src/Shared/Components/PooledByteBufferWritter.cs b/src/Shared/Components/PooledByteBufferWritter.cs new file mode 100644 index 000000000000..e931d2c1b1ba --- /dev/null +++ b/src/Shared/Components/PooledByteBufferWritter.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; + +namespace Microsoft.AspNetCore.Components.Infrastructure +{ + internal sealed class PooledByteBufferWriter : IBufferWriter, IDisposable + { + private byte[] _currentBuffer; + private int _index; + private readonly bool _owned; + + private const int MinimumBufferSize = 256; + + public PooledByteBufferWriter(int initialCapacity = 0) + { + _currentBuffer = ArrayPool.Shared.Rent(initialCapacity); + _index = 0; + } + + public PooledByteBufferWriter(byte[] existingBuffer) + { + _currentBuffer = existingBuffer; + _index = existingBuffer.Length; + _owned = true; + } + + public ReadOnlyMemory WrittenMemory => _currentBuffer.AsMemory(0, _index); + + public int WrittenCount => _index; + + public int Capacity => _currentBuffer.Length; + + public int FreeCapacity => _currentBuffer.Length - _index; + + public void Clear() => ClearHelper(); + + private void ClearHelper() + { + _currentBuffer.AsSpan(0, _index).Clear(); + _index = 0; + } + + // Returns the rented buffer back to the pool + public void Dispose() + { + if (_currentBuffer == null || _owned) + { + return; + } + + ClearHelper(); + var currentBuffer = _currentBuffer; + _currentBuffer = null!; + ArrayPool.Shared.Return(currentBuffer); + } + + public void Advance(int count) + { + _index += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _currentBuffer.AsMemory(_index); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _currentBuffer.AsSpan(_index); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + if (_owned) + { + throw new InvalidOperationException("Can't grow a buffer created from a given array."); + } + + if (sizeHint == 0) + { + sizeHint = MinimumBufferSize; + } + + var availableSpace = _currentBuffer.Length - _index; + + if (sizeHint > availableSpace) + { + var currentLength = _currentBuffer.Length; + var growBy = Math.Max(sizeHint, currentLength); + + var newSize = currentLength + growBy; + + if ((uint)newSize > int.MaxValue) + { + newSize = currentLength + sizeHint; + if ((uint)newSize > int.MaxValue) + { + throw new OutOfMemoryException(); + } + } + + var oldBuffer = _currentBuffer; + + _currentBuffer = ArrayPool.Shared.Rent(newSize); + + var previousBuffer = oldBuffer.AsSpan(0, _index); + previousBuffer.CopyTo(_currentBuffer); + ArrayPool.Shared.Return(oldBuffer, clearArray: true); + } + } + } +} diff --git a/src/Shared/Components/PrerenderComponentApplicationStore.cs b/src/Shared/Components/PrerenderComponentApplicationStore.cs index df0de6d9bd47..ba6763890451 100644 --- a/src/Shared/Components/PrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/PrerenderComponentApplicationStore.cs @@ -1,17 +1,20 @@ // 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.Buffers; +using System.Buffers.Text; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Lifetime; +using Microsoft.AspNetCore.Components.Infrastructure; namespace Microsoft.AspNetCore.Components { - internal class PrerenderComponentApplicationStore : IComponentApplicationStateStore + internal class PrerenderComponentApplicationStore : IPersistentComponentStateStore { +#nullable enable + private char[]? _buffer; +#nullable disable + public PrerenderComponentApplicationStore() { ExistingState = new(); @@ -25,34 +28,113 @@ public PrerenderComponentApplicationStore(string existingState) throw new ArgumentNullException(nameof(existingState)); } - ExistingState = JsonSerializer.Deserialize>(Convert.FromBase64String(existingState)) ?? - throw new ArgumentNullException(nameof(existingState)); + DeserializeState(Convert.FromBase64String(existingState)); + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Simple deserialize of primitive types.")] + protected void DeserializeState(byte[] existingState) + { + var state = JsonSerializer.Deserialize>(existingState); + if (state == null) + { + throw new ArgumentException("Could not deserialize state correctly", nameof(existingState)); + } + + var stateDictionary = new Dictionary>(); + foreach (var (key, value) in state) + { + stateDictionary.Add(key, new ReadOnlySequence(value)); + } + + ExistingState = stateDictionary; } #nullable enable - public string? PersistedState { get; private set; } + public ReadOnlyMemory PersistedState { get; private set; } #nullable disable - public Dictionary ExistingState { get; protected set; } + public Dictionary> ExistingState { get; protected set; } - public Task> GetPersistedStateAsync() + public Task>> GetPersistedStateAsync() { - return Task.FromResult((IDictionary)ExistingState); + return Task.FromResult((IDictionary>)ExistingState); } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Simple serialize of primitive types.")] - protected virtual byte[] SerializeState(IReadOnlyDictionary state) + protected virtual PooledByteBufferWriter SerializeState(IReadOnlyDictionary> state) { - return JsonSerializer.SerializeToUtf8Bytes(state); + // System.Text.Json doesn't support serializing ReadonlySequence so we need to buffer + // the data with a memory pool here. We will change our serialization strategy in the future here + // so that we can avoid this step. + var buffer = new PooledByteBufferWriter(); + try + { + var jsonWriter = new Utf8JsonWriter(buffer); + jsonWriter.WriteStartObject(); + foreach (var (key, value) in state) + { + if (value.IsSingleSegment) + { + jsonWriter.WriteBase64String(key, value.First.Span); + } + else + { + WriteMultipleSegments(jsonWriter, key, value); + } + jsonWriter.Flush(); + } + + jsonWriter.WriteEndObject(); + jsonWriter.Flush(); + return buffer; + + } + catch + { + buffer.Dispose(); + throw; + } + static void WriteMultipleSegments(Utf8JsonWriter jsonWriter, string key, ReadOnlySequence value) + { + byte[] unescapedArray = null; + var valueLength = (int)value.Length; + + Span valueSegment = value.Length <= 256 ? + stackalloc byte[valueLength] : + (unescapedArray = ArrayPool.Shared.Rent(valueLength)).AsSpan().Slice(0, valueLength); + + value.CopyTo(valueSegment); + jsonWriter.WriteBase64String(key, valueSegment); + + if (unescapedArray != null) + { + valueSegment.Clear(); + ArrayPool.Shared.Return(unescapedArray); + } + } } - public Task PersistStateAsync(IReadOnlyDictionary state) + public Task PersistStateAsync(IReadOnlyDictionary> state) { - var bytes = SerializeState(state); + using var bytes = SerializeState(state); + var length = Base64.GetMaxEncodedToUtf8Length(bytes.WrittenCount); + // We can do this because the representation in bytes for characters in the base64 alphabet for utf-8 is 1. + _buffer = ArrayPool.Shared.Rent(length); + + var memory = _buffer.AsMemory().Slice(0, length); + Convert.TryToBase64Chars(bytes.WrittenMemory.Span, memory.Span, out _); + PersistedState = memory; - var result = Convert.ToBase64String(bytes); - PersistedState = result; return Task.CompletedTask; } + + public void Dispose() + { + if (_buffer != null) + { + ArrayPool.Shared.Return(_buffer, true); + _buffer = null; + } + } } } diff --git a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs index 7af96de683f4..50e7ecc8b0f1 100644 --- a/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs +++ b/src/Shared/Components/ProtectedPrerenderComponentApplicationStore.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.DataProtection; namespace Microsoft.AspNetCore.Components @@ -20,15 +23,17 @@ public ProtectedPrerenderComponentApplicationStore(IDataProtectionProvider dataP public ProtectedPrerenderComponentApplicationStore(string existingState, IDataProtectionProvider dataProtectionProvider) { CreateProtector(dataProtectionProvider); - ExistingState = JsonSerializer.Deserialize>(_protector.Unprotect(Convert.FromBase64String(existingState))); + DeserializeState(_protector.Unprotect(Convert.FromBase64String(existingState))); } - protected override byte[] SerializeState(IReadOnlyDictionary state) + protected override PooledByteBufferWriter SerializeState(IReadOnlyDictionary> state) { var bytes = base.SerializeState(state); if (_protector != null) { - bytes = _protector.Protect(bytes); + var newBuffer = new PooledByteBufferWriter(_protector.Protect(bytes.WrittenMemory.Span.ToArray())); + bytes.Dispose(); + return newBuffer; } return bytes; diff --git a/src/submodules/googletest b/src/submodules/googletest index ff21b36e1e85..2f80c2ba71c0 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit ff21b36e1e858bf9274ed2bbf4921e415363c393 +Subproject commit 2f80c2ba71c0e8922a03b9b855e5b019ad1f7064