diff --git a/Source/Lib/Fluxor.Blazor.Web/Components/FluxorComponent.cs b/Source/Lib/Fluxor.Blazor.Web/Components/FluxorComponent.cs index 35c7378e..24544b61 100644 --- a/Source/Lib/Fluxor.Blazor.Web/Components/FluxorComponent.cs +++ b/Source/Lib/Fluxor.Blazor.Web/Components/FluxorComponent.cs @@ -1,6 +1,8 @@ using Fluxor.UnsupportedClasses; using Microsoft.AspNetCore.Components; +using System.Threading; using System; +using System.Threading.Tasks; namespace Fluxor.Blazor.Web.Components { @@ -13,6 +15,9 @@ public abstract class FluxorComponent : ComponentBase, IDisposable [Inject] private IActionSubscriber ActionSubscriber { get; set; } + [Inject] + private IStore Store { get; set; } + private bool Disposed; private IDisposable StateSubscription; private readonly ThrottledInvoker StateHasChangedThrottler; @@ -20,7 +25,7 @@ public abstract class FluxorComponent : ComponentBase, IDisposable /// /// Creates a new instance /// - public FluxorComponent() + protected FluxorComponent() { StateHasChangedThrottler = new ThrottledInvoker(() => { @@ -63,17 +68,25 @@ public void Dispose() Disposed = true; } } - + /// /// Subscribes to state properties /// protected override void OnInitialized() { - base.OnInitialized(); StateSubscription = StateSubscriber.Subscribe(this, _ => { StateHasChangedThrottler.Invoke(MaximumStateChangedNotificationsPerSecond); }); + + base.OnInitialized(); + } + + protected override async Task OnInitializedAsync() + { + //Attempt to initialize the store knowing that if it's already been initialized, this won't do anything. + await Store.InitializeAsync(); + await base.OnInitializedAsync(); } protected virtual void Dispose(bool disposing) diff --git a/Source/Lib/Fluxor.Blazor.Web/Fluxor.Blazor.Web.csproj b/Source/Lib/Fluxor.Blazor.Web/Fluxor.Blazor.Web.csproj index ee56a2b5..6c8c529a 100644 --- a/Source/Lib/Fluxor.Blazor.Web/Fluxor.Blazor.Web.csproj +++ b/Source/Lib/Fluxor.Blazor.Web/Fluxor.Blazor.Web.csproj @@ -12,9 +12,11 @@ + + diff --git a/Source/Lib/Fluxor.Blazor.Web/Persistence/PersistenceEffects.cs b/Source/Lib/Fluxor.Blazor.Web/Persistence/PersistenceEffects.cs new file mode 100644 index 00000000..56a600b3 --- /dev/null +++ b/Source/Lib/Fluxor.Blazor.Web/Persistence/PersistenceEffects.cs @@ -0,0 +1,59 @@ +using Fluxor.Persistence; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Fluxor.Blazor.Web.Persistence +{ +#if NET6_0_OR_GREATER + public sealed class PersistenceEffects + { + private readonly IPersistenceManager _persistenceManager; + private readonly IServiceProvider _serviceProvider; + + public PersistenceEffects(IPersistenceManager persistenceManager, IServiceProvider serviceProvider) + { + _persistenceManager = persistenceManager; + _serviceProvider = serviceProvider; + } + + /// + /// Maintains a reference to IStore - injected this way to avoid a circular dependency during the effect method registration + /// + private readonly Lazy _store = new(_serviceProvider.GetRequiredService); + + [EffectMethod(typeof(StorePersistingAction))] + public async Task PersistStoreData(IDispatcher dispatcher) + { + //Serialize the store + var json = _store.Value.SerializeToJson(); + + //Save to the persistence manager + await _persistenceManager.PersistStoreToStateAsync(json); + + //Completed + dispatcher.Dispatch(new StorePersistedAction()); + } + + [EffectMethod(typeof(StoreRehydratingAction))] + public async Task RehydrateStoreData(IDispatcher dispatcher) + { + //Read from the persistence manager + var serializedStore = await _persistenceManager.RehydrateStoreFromStateAsync(); + if (serializedStore is null) + { + //Nothing to rehydrate - leave as-is + dispatcher.Dispatch(new StoreRehydratedAction()); + return; + } + + _store.Value.RehydrateFromJson(serializedStore); + + //Completed + dispatcher.Dispatch(new StoreRehydratedAction()); + } + } +#endif +} diff --git a/Source/Lib/Fluxor.Blazor.Web/StoreInitializer.cs b/Source/Lib/Fluxor.Blazor.Web/StoreInitializer.cs index fa244d78..01bc364f 100644 --- a/Source/Lib/Fluxor.Blazor.Web/StoreInitializer.cs +++ b/Source/Lib/Fluxor.Blazor.Web/StoreInitializer.cs @@ -49,6 +49,14 @@ protected override void OnInitialized() base.OnInitialized(); } +#if NET8 + protected override async Task OnInitializedAsync() + { + await Store.InitializeAsync(); + await base.OnInitializedAsync(); + } +#endif + protected override void OnAfterRender(bool firstRender) { base.OnAfterRender(firstRender); @@ -73,7 +81,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (!string.IsNullOrWhiteSpace(MiddlewareInitializationScripts)) await JSRuntime.InvokeVoidAsync("eval", MiddlewareInitializationScripts); +#if !NET8 await Store.InitializeAsync(); +#endif } catch (JSException err) { diff --git a/Source/Lib/Fluxor/DependencyInjection/FluxorOptions.cs b/Source/Lib/Fluxor/DependencyInjection/FluxorOptions.cs index d0c0e848..a6c8427d 100644 --- a/Source/Lib/Fluxor/DependencyInjection/FluxorOptions.cs +++ b/Source/Lib/Fluxor/DependencyInjection/FluxorOptions.cs @@ -1,4 +1,5 @@ using Fluxor.Extensions; +using Fluxor.Persistence; using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; @@ -138,5 +139,32 @@ public FluxorOptions AddMiddleware() .ToArray(); return this; } + +#if NET6_0_OR_GREATER + + /// + /// Enables automatic store persistence between location state changes. + /// + /// The type of persistence implementation to use. + /// Options + public FluxorOptions WithPersistence() where T : IPersistenceManager + { + Services.AddScoped(typeof(IPersistenceManager), typeof(T)); + return this; + } + + /// + /// Enables automatic store persistence between location state changes. + /// + /// The type of persistence implementation to use. + /// Options + public FluxorOptions WithPersistence(IServiceCollection serviceCollection) where T : IPersistenceManager + { + WithPersistence(); + foreach (var serviceDescriptor in serviceCollection) Services.Add(serviceDescriptor); + return this; + } + +#endif } } diff --git a/Source/Lib/Fluxor/DependencyInjection/ServiceRegistration/StoreRegistration.cs b/Source/Lib/Fluxor/DependencyInjection/ServiceRegistration/StoreRegistration.cs index 50929e2d..9d43d6d3 100644 --- a/Source/Lib/Fluxor/DependencyInjection/ServiceRegistration/StoreRegistration.cs +++ b/Source/Lib/Fluxor/DependencyInjection/ServiceRegistration/StoreRegistration.cs @@ -1,5 +1,6 @@ using Fluxor.DependencyInjection.WrapperFactories; using Fluxor.Extensions; +using Fluxor.Persistence; using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; @@ -41,7 +42,13 @@ public static void Register( services.Add(typeof(Store), serviceProvider => { var dispatcher = serviceProvider.GetService(); + +#if NET6_0_OR_GREATER + var persistenceManager = serviceProvider.GetService(); + var store = new Store(dispatcher, persistenceManager); +#else var store = new Store(dispatcher); +#endif foreach (FeatureClassInfo featureClassInfo in featureClassInfos) { var feature = (IFeature)serviceProvider.GetService(featureClassInfo.FeatureInterfaceGenericType); diff --git a/Source/Lib/Fluxor/Fluxor.csproj b/Source/Lib/Fluxor/Fluxor.csproj index f607192c..03ffba61 100644 --- a/Source/Lib/Fluxor/Fluxor.csproj +++ b/Source/Lib/Fluxor/Fluxor.csproj @@ -4,7 +4,7 @@ A zero boilerplate Redux/Flux framework for .NET fluxor-small-logo.png Redux Flux DotNet CSharp - True + True @@ -15,9 +15,11 @@ + + diff --git a/Source/Lib/Fluxor/IStore.cs b/Source/Lib/Fluxor/IStore.cs index 03b1cde5..03913a21 100644 --- a/Source/Lib/Fluxor/IStore.cs +++ b/Source/Lib/Fluxor/IStore.cs @@ -93,5 +93,18 @@ public interface IStore : IActionSubscriber /// Executed when an exception is not handled /// event EventHandler UnhandledException; + + /// + /// Persists the features in the store to a serialized string. + /// + /// + string SerializeToJson(); + + /// + /// Deserializes a previously serialized store and rehydrates each feature using the + /// provided values. + /// + /// The serialized store. + void RehydrateFromJson(string json); } } diff --git a/Source/Lib/Fluxor/Persistence/IPersistenceManager.cs b/Source/Lib/Fluxor/Persistence/IPersistenceManager.cs new file mode 100644 index 00000000..ea7f0ddc --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/IPersistenceManager.cs @@ -0,0 +1,27 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Fluxor.Persistence +{ + public interface IPersistenceManager + { + /// + /// Persists the store to a persisted state. + /// + /// The serialized store data being persisted. + public Task PersistStoreToStateAsync(string serializedStore); + + /// + /// Rehydrates the store from a persisted state. + /// + public Task RehydrateStoreFromStateAsync(); + + /// + /// Clears the store from the persisted state. + /// + public Task ClearStoreFromStateAsync(); + } +} diff --git a/Source/Lib/Fluxor/Persistence/PersistenceEffects.cs b/Source/Lib/Fluxor/Persistence/PersistenceEffects.cs new file mode 100644 index 00000000..bfd24a92 --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/PersistenceEffects.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Fluxor.Persistence +{ + public sealed class PersistenceEffects + { + private readonly IPersistenceManager _persistenceManager; + private readonly IServiceProvider _serviceProvider; + + public PersistenceEffects(IPersistenceManager persistenceManager, IServiceProvider serviceProvider) + { + _persistenceManager = persistenceManager; + _serviceProvider = serviceProvider; + } + + /// + /// Maintains a reference to IStore - injected this way to avoid a circular dependency during the effect method registration + /// + private readonly Lazy _store = new(_serviceProvider.GetRequiredService); + + [EffectMethod(typeof(StorePersistingAction))] + public async Task PersistStoreData(IDispatcher dispatcher) + { + //Serialize the store + var json = _store.Value.SerializeToJson(); + + //Save to the persistence manager + await _persistenceManager.PersistStoreToStateAsync(json); + + //Completed + dispatcher.Dispatch(new StorePersistedAction()); + } + + [EffectMethod(typeof(StoreRehydratingAction))] + public async Task RehydrateStoreData(IDispatcher dispatcher) + { + //Read from the persistence manager + var serializedStore = await _persistenceManager.RehydrateStoreFromStateAsync(); + if (serializedStore is null) + { + //Nothing to rehydrate - leave as-is + dispatcher.Dispatch(new StoreRehydratedAction()); + return; + } + + _store.Value.RehydrateFromJson(serializedStore); + + //Completed + dispatcher.Dispatch(new StoreRehydratedAction()); + } + } +} diff --git a/Source/Lib/Fluxor/Persistence/StorePersistedAction.cs b/Source/Lib/Fluxor/Persistence/StorePersistedAction.cs new file mode 100644 index 00000000..99288929 --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/StorePersistedAction.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Fluxor.Persistence +{ + /// + /// Dispatched by the store once it has been successfully persisted to state. + /// + public sealed class StorePersistedAction + { + } +} diff --git a/Source/Lib/Fluxor/Persistence/StorePersistingAction.cs b/Source/Lib/Fluxor/Persistence/StorePersistingAction.cs new file mode 100644 index 00000000..f37943c1 --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/StorePersistingAction.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Fluxor.Persistence +{ + /// + /// Dispatched by the store once it has started persisting to state. + /// + public sealed class StorePersistingAction + { + } +} diff --git a/Source/Lib/Fluxor/Persistence/StoreRehydratedAction.cs b/Source/Lib/Fluxor/Persistence/StoreRehydratedAction.cs new file mode 100644 index 00000000..478c9098 --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/StoreRehydratedAction.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Fluxor.Persistence +{ + /// + /// Dispatched by the store once it has been successfully rehydrated from state. + /// + public sealed class StoreRehydratedAction + { + } +} diff --git a/Source/Lib/Fluxor/Persistence/StoreRehydratingAction.cs b/Source/Lib/Fluxor/Persistence/StoreRehydratingAction.cs new file mode 100644 index 00000000..62d0404f --- /dev/null +++ b/Source/Lib/Fluxor/Persistence/StoreRehydratingAction.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Fluxor.Persistence +{ + /// + /// Dispatched by the store once it has started rehydrating from state. + /// + public sealed class StoreRehydratingAction + { + } +} diff --git a/Source/Lib/Fluxor/Store.cs b/Source/Lib/Fluxor/Store.cs index 56943278..476969a7 100644 --- a/Source/Lib/Fluxor/Store.cs +++ b/Source/Lib/Fluxor/Store.cs @@ -1,7 +1,14 @@ -using System; +#nullable enable +using Fluxor.Persistence; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +#if NET6_0_OR_GREATER +using System.Text.Json; +using System.Text.Json.Nodes; +using Fluxor.Persistence; +#endif using System.Threading.Tasks; namespace Fluxor @@ -14,6 +21,10 @@ public class Store : IStore, IActionSubscriber, IDisposable /// public Task Initialized => InitializedCompletionSource.Task; +#if NET6_0_OR_GREATER + private readonly IPersistenceManager? _persistenceManager; +#endif + private object SyncRoot = new object(); private bool Disposed; private readonly IDispatcher Dispatcher; @@ -30,16 +41,35 @@ public class Store : IStore, IActionSubscriber, IDisposable private volatile bool HasActivatedStore; private bool IsInsideMiddlewareChange => BeginMiddlewareChangeCount > 0; +#if NET6_0_OR_GREATER + + /// + /// Creates an instance of the store + /// + public Store(IDispatcher dispatcher, IPersistenceManager persistenceManager = null) + { + + _persistenceManager = persistenceManager; + ActionSubscriber = new ActionSubscriber(); + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + Dispatcher.ActionDispatched += ActionDispatched; + Dispatcher.Dispatch(new StoreInitializedAction()); + } + +#else + /// /// Creates an instance of the store /// public Store(IDispatcher dispatcher) { + ActionSubscriber = new ActionSubscriber(); Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); Dispatcher.ActionDispatched += ActionDispatched; Dispatcher.Dispatch(new StoreInitializedAction()); } +#endif /// public IEnumerable GetMiddlewares() => Middlewares; @@ -265,8 +295,16 @@ private async Task ActivateStoreAsync() lock (SyncRoot) { +#if NET6_0_OR_GREATER + if (_persistenceManager is not null) + { + //Rehydrate as necessary + Dispatcher.Dispatch(new StoreRehydratingAction()); + } +#endif HasActivatedStore = true; DequeueActions(); + InitializedCompletionSource.SetResult(true); } } @@ -279,6 +317,13 @@ private void DequeueActions() IsDispatching = true; try { + //Only persist the store state if the action(s) in the queue don't consist solely of any combination of the following + if (!QueuedActions.IsEmpty && !QueuedActions.All(action => action is StoreInitializedAction or StoreRehydratingAction or StoreRehydratedAction or StorePersistingAction or StorePersistedAction)) + { + //Add an action to the end of the queue that persists the results of the actions to state, skipping the dispatcher approach (which might lead to an infinite loop) + QueuedActions.Enqueue(new StorePersistingAction()); + } + while (QueuedActions.TryDequeue(out object nextActionToProcess)) { // Only process the action if no middleware vetos it @@ -301,5 +346,42 @@ private void DequeueActions() IsDispatching = false; } } + +#if NET6_0_OR_GREATER + + public string SerializeToJson() + { + var rootObj = new JsonObject(); + foreach (var kv in FeaturesByName) + { + var featureName = kv.Value.GetName(); + var featureValue = kv.Value.GetState(); + rootObj[featureName] = JsonSerializer.SerializeToNode(featureValue); + } + + return JsonSerializer.Serialize(rootObj); + } + + public void RehydrateFromJson(string json) + { + var obj = JsonDocument.Parse(json); + + foreach (var feature in obj.RootElement.EnumerateObject()) + { + //Replace the state in the named feature with what's in the serialized data + if (Features.ContainsKey(feature.Name)) + { + var stateType = Features[feature.Name].GetStateType(); + var featureValue = feature.Value.Deserialize(stateType); + + if (featureValue is null) + continue; + + FeaturesByName[feature.Name].RestoreState(featureValue); + } + } + } + +#endif } }