Skip to content

Commit 553877c

Browse files
committed
Add middleware context and persistence options for state management
- Introduced MiddlewareContext<TState> to encapsulate state and execution phase information for middleware. - Enhanced PersistenceMiddleware<TState> to support custom persistence options including transformation on save/load and hydration callbacks. - Created PersistenceOptions<TState> class to configure persistence behavior, including debounce settings and hydration control. - Implemented unit tests for middleware and persistence options to ensure correct behavior and error handling. - Added selector subscription tests to verify correct notifications on state changes.
1 parent 693317c commit 553877c

15 files changed

Lines changed: 1731 additions & 26 deletions

src/EasyAppDev.Blazor.Store/Core/IStateObservable.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,42 @@ IDisposable Subscribe<TSelected>(
140140
Func<TState, TSelected> selector,
141141
Action<TSelected> callback,
142142
IEqualityComparer<TSelected> comparer);
143+
144+
/// <summary>
145+
/// Subscribes to state changes using a memoized selector.
146+
/// The callback is only invoked when the selector result changes.
147+
/// </summary>
148+
/// <typeparam name="TSelected">The type of the selected value from the state.</typeparam>
149+
/// <param name="selector">A memoized selector that computes the derived value.</param>
150+
/// <param name="callback">Callback invoked when the selected value changes.</param>
151+
/// <returns>Disposable subscription. Call Dispose to unsubscribe.</returns>
152+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="selector"/> or <paramref name="callback"/> is null.</exception>
153+
/// <remarks>
154+
/// <para>
155+
/// This overload uses a pre-defined <see cref="Selectors.ISelector{TState, TResult}"/> for efficient
156+
/// memoized state derivation. Useful for complex computed values that should only recompute
157+
/// when their dependencies change.
158+
/// </para>
159+
/// <para>
160+
/// The selector is responsible for memoization; this method simply uses its Select method.
161+
/// </para>
162+
/// </remarks>
163+
/// <example>
164+
/// <code>
165+
/// // Define selectors once (e.g., in a static class)
166+
/// public static class CartSelectors
167+
/// {
168+
/// public static readonly ISelector&lt;CartState, decimal&gt; Total =
169+
/// Selectors.Create&lt;CartState, decimal&gt;(s => s.Items.Sum(i => i.Price * i.Quantity));
170+
/// }
171+
///
172+
/// // Subscribe using the selector
173+
/// var subscription = store.Subscribe(CartSelectors.Total, total => {
174+
/// Console.WriteLine($"Cart total: {total:C}");
175+
/// });
176+
/// </code>
177+
/// </example>
178+
IDisposable Subscribe<TSelected>(
179+
Selectors.ISelector<TState, TSelected> selector,
180+
Action<TSelected> callback);
143181
}

src/EasyAppDev.Blazor.Store/Core/Store.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using EasyAppDev.Blazor.Store.Middleware;
2+
using EasyAppDev.Blazor.Store.Selectors;
23
using Microsoft.Extensions.Logging;
34

45
namespace EasyAppDev.Blazor.Store.Core;
@@ -17,6 +18,7 @@ public class Store<TState> : IStore<TState>, IDisposable where TState : notnull
1718
private readonly ISubscriptionManager<TState> _subscriptionManager;
1819
private readonly MiddlewarePipeline<TState>? _middlewarePipeline;
1920
private readonly ILogger<Store<TState>>? _logger;
21+
private readonly StoreErrorHandler<TState>? _errorHandler;
2022
private bool _disposed;
2123
private readonly AsyncLocal<int> _updateDepth = new();
2224

@@ -30,20 +32,23 @@ public class Store<TState> : IStore<TState>, IDisposable where TState : notnull
3032
/// <param name="middlewarePipelineLogger">Optional logger for middleware pipeline.</param>
3133
/// <param name="logger">Optional logger for store operations.</param>
3234
/// <param name="middlewareOptions">Optional configuration options for middleware pipeline.</param>
35+
/// <param name="errorHandler">Optional centralized error handler for store errors.</param>
3336
public Store(
3437
TState initialState,
3538
ISubscriptionManager<TState> subscriptionManager,
3639
IEqualityComparer<TState>? comparer = null,
3740
IEnumerable<IMiddleware<TState>>? middlewares = null,
3841
ILogger<MiddlewarePipeline<TState>>? middlewarePipelineLogger = null,
3942
ILogger<Store<TState>>? logger = null,
40-
MiddlewarePipelineOptions? middlewareOptions = null)
43+
MiddlewarePipelineOptions? middlewareOptions = null,
44+
StoreErrorHandler<TState>? errorHandler = null)
4145
{
4246
_state = initialState ?? throw new ArgumentNullException(nameof(initialState));
4347
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
4448
_lock = new SemaphoreSlim(1, 1);
4549
_comparer = comparer ?? EqualityComparer<TState>.Default;
4650
_logger = logger;
51+
_errorHandler = errorHandler;
4752

4853
if (middlewares?.Any() == true)
4954
{
@@ -229,6 +234,22 @@ public IDisposable Subscribe<TSelected>(
229234
return _subscriptionManager.Subscribe(selector, callback, () => _state, comparer);
230235
}
231236

237+
/// <inheritdoc />
238+
public IDisposable Subscribe<TSelected>(
239+
ISelector<TState, TSelected> selector,
240+
Action<TSelected> callback)
241+
{
242+
ArgumentNullException.ThrowIfNull(selector);
243+
ArgumentNullException.ThrowIfNull(callback);
244+
ThrowIfDisposed();
245+
246+
return _subscriptionManager.Subscribe(
247+
state => selector.Select(state),
248+
callback,
249+
() => _state,
250+
EqualityComparer<TSelected>.Default);
251+
}
252+
232253
private void NotifySubscribers()
233254
{
234255
_subscriptionManager.NotifyAll();
@@ -240,6 +261,25 @@ private void ThrowIfDisposed()
240261
throw new ObjectDisposedException(nameof(Store<TState>));
241262
}
242263

264+
/// <summary>
265+
/// Reports an error to the registered error handler.
266+
/// </summary>
267+
internal void HandleError(Exception exception, ErrorLocation location, string? action = null)
268+
{
269+
var error = new StoreError<TState>(exception, _state, action, location);
270+
271+
_logger?.LogError(exception, "Store error in {Location}: {Message}", location, exception.Message);
272+
273+
try
274+
{
275+
_errorHandler?.Invoke(error);
276+
}
277+
catch (Exception handlerEx)
278+
{
279+
_logger?.LogError(handlerEx, "Error in store error handler");
280+
}
281+
}
282+
243283
/// <inheritdoc />
244284
public void Dispose()
245285
{

src/EasyAppDev.Blazor.Store/Core/StoreBuilder.cs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class StoreBuilder<TState> where TState : notnull
1818
private ILogger<Store<TState>>? _storeLogger;
1919
private ILogger<SubscriptionManager<TState>>? _subscriptionManagerLogger;
2020
private MiddlewarePipelineOptions? _middlewareOptions;
21+
private StoreErrorHandler<TState>? _errorHandler;
2122

2223
private StoreBuilder(TState initialState)
2324
{
@@ -141,6 +142,49 @@ public StoreBuilder<TState> ConfigureMiddleware(Action<MiddlewarePipelineOptions
141142
return this;
142143
}
143144

145+
/// <summary>
146+
/// Registers a centralized error handler for store operations.
147+
/// Errors from middleware, subscribers, and persistence are routed to this handler.
148+
/// </summary>
149+
/// <param name="errorHandler">The error handler delegate.</param>
150+
/// <returns>The builder instance for chaining.</returns>
151+
/// <example>
152+
/// <code>
153+
/// builder.OnError(error =>
154+
/// {
155+
/// logger.LogError(error.Exception,
156+
/// "Store error in {Location}: {Message}",
157+
/// error.Location,
158+
/// error.Exception.Message);
159+
///
160+
/// // Report to error tracking service
161+
/// errorTracker.CaptureException(error.Exception, new Dictionary&lt;string, object&gt;
162+
/// {
163+
/// ["store"] = typeof(TState).Name,
164+
/// ["action"] = error.Action ?? "unknown",
165+
/// ["location"] = error.Location.ToString()
166+
/// });
167+
/// });
168+
/// </code>
169+
/// </example>
170+
public StoreBuilder<TState> OnError(StoreErrorHandler<TState> errorHandler)
171+
{
172+
_errorHandler = errorHandler ?? throw new ArgumentNullException(nameof(errorHandler));
173+
return this;
174+
}
175+
176+
/// <summary>
177+
/// Registers an error handler using an action that receives the error.
178+
/// </summary>
179+
/// <param name="handler">The error handler action.</param>
180+
/// <returns>The builder instance for chaining.</returns>
181+
public StoreBuilder<TState> OnError(Action<StoreError<TState>> handler)
182+
{
183+
ArgumentNullException.ThrowIfNull(handler);
184+
_errorHandler = handler.Invoke;
185+
return this;
186+
}
187+
144188
/// <summary>
145189
/// Enables Redux DevTools integration with lazy IJSRuntime resolution.
146190
/// Works in all render modes: Server, WebAssembly, and Auto (Server → WASM).
@@ -184,6 +228,35 @@ public StoreBuilder<TState> WithPersistence(
184228
return hydratedBuilder.WithMiddleware(middleware);
185229
}
186230

231+
/// <summary>
232+
/// Adds state persistence with full configuration options.
233+
/// </summary>
234+
/// <param name="provider">The persistence provider.</param>
235+
/// <param name="options">The persistence configuration options.</param>
236+
/// <returns>The builder instance for chaining.</returns>
237+
/// <example>
238+
/// <code>
239+
/// .WithPersistence(provider, new PersistenceOptions&lt;CartState&gt;
240+
/// {
241+
/// Key = "cart-state",
242+
/// DebounceMs = 500,
243+
/// OnHydrationSuccess = state => logger.LogInformation("Loaded {Count} items", state.Items.Count),
244+
/// ShouldPersist = (prev, curr, action) => action != "TEMP_UPDATE",
245+
/// TransformOnLoad = state => state with { CheckoutInProgress = false }
246+
/// })
247+
/// </code>
248+
/// </example>
249+
public StoreBuilder<TState> WithPersistence(
250+
IPersistenceProvider provider,
251+
PersistenceOptions<TState> options)
252+
{
253+
ArgumentNullException.ThrowIfNull(provider);
254+
ArgumentNullException.ThrowIfNull(options);
255+
256+
var middleware = new PersistenceMiddleware<TState>(provider, options);
257+
return WithMiddleware(middleware);
258+
}
259+
187260
/// <summary>
188261
/// Attempts to load persisted state. Since synchronous JS interop is not available
189262
/// without explicit IJSInProcessRuntime, this method now returns the current builder.
@@ -229,7 +302,8 @@ public async Task<StoreBuilder<TState>> WithHydratedStateAsync(
229302
_middlewarePipelineLogger = this._middlewarePipelineLogger,
230303
_storeLogger = this._storeLogger,
231304
_subscriptionManagerLogger = this._subscriptionManagerLogger,
232-
_middlewareOptions = this._middlewareOptions
305+
_middlewareOptions = this._middlewareOptions,
306+
_errorHandler = this._errorHandler
233307
};
234308
builder._middlewares.AddRange(this._middlewares);
235309
return builder;
@@ -261,6 +335,7 @@ public IStore<TState> Build()
261335
middlewares: _middlewares,
262336
middlewarePipelineLogger: _middlewarePipelineLogger,
263337
logger: _storeLogger,
264-
middlewareOptions: _middlewareOptions);
338+
middlewareOptions: _middlewareOptions,
339+
errorHandler: _errorHandler);
265340
}
266341
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
namespace EasyAppDev.Blazor.Store.Core;
2+
3+
/// <summary>
4+
/// Represents an error that occurred during store operations.
5+
/// </summary>
6+
/// <typeparam name="TState">The type of state.</typeparam>
7+
/// <param name="Exception">The exception that was thrown.</param>
8+
/// <param name="State">The state at the time of the error (may be null if not available).</param>
9+
/// <param name="Action">The action name associated with the operation (if any).</param>
10+
/// <param name="Location">Where in the store lifecycle the error occurred.</param>
11+
public record StoreError<TState>(
12+
Exception Exception,
13+
TState? State,
14+
string? Action,
15+
ErrorLocation Location) where TState : notnull
16+
{
17+
/// <summary>
18+
/// Gets a concise error message including location and action.
19+
/// </summary>
20+
public string Message => Action != null
21+
? $"[{Location}] Error during '{Action}': {Exception.Message}"
22+
: $"[{Location}] Error: {Exception.Message}";
23+
}
24+
25+
/// <summary>
26+
/// Indicates where in the store lifecycle an error occurred.
27+
/// </summary>
28+
public enum ErrorLocation
29+
{
30+
/// <summary>
31+
/// Error occurred in middleware (OnBeforeUpdate or OnAfterUpdate).
32+
/// </summary>
33+
Middleware,
34+
35+
/// <summary>
36+
/// Error occurred in the state updater function.
37+
/// </summary>
38+
Updater,
39+
40+
/// <summary>
41+
/// Error occurred in a subscriber callback.
42+
/// </summary>
43+
Subscriber,
44+
45+
/// <summary>
46+
/// Error occurred during state persistence (save or load).
47+
/// </summary>
48+
Persistence,
49+
50+
/// <summary>
51+
/// Error occurred during DevTools integration.
52+
/// </summary>
53+
DevTools,
54+
55+
/// <summary>
56+
/// Error occurred during state hydration.
57+
/// </summary>
58+
Hydration
59+
}
60+
61+
/// <summary>
62+
/// Delegate for handling store errors.
63+
/// </summary>
64+
/// <typeparam name="TState">The type of state.</typeparam>
65+
/// <param name="error">The error that occurred.</param>
66+
public delegate void StoreErrorHandler<TState>(StoreError<TState> error) where TState : notnull;

src/EasyAppDev.Blazor.Store/EasyAppDev.Blazor.Store.csproj

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<PropertyGroup>
1616
<!-- NuGet Package Metadata -->
1717
<PackageId>EasyAppDev.Blazor.Store</PackageId>
18-
<Version>1.2.0</Version>
18+
<Version>2.0.0</Version>
1919
<Authors>Mashrul Haque</Authors>
2020
<Company>EasyAppDev</Company>
2121
<Product>EasyAppDev.Blazor.Store</Product>
@@ -28,29 +28,27 @@
2828
<PackageReadmeFile>README.md</PackageReadmeFile>
2929
<PackageIcon>icon.png</PackageIcon>
3030
<PackageReleaseNotes>
31-
v1.2.0 - Phase 2: Cleanup and Simplification
32-
33-
Breaking Changes:
34-
- Removed deprecated synchronous Update() method from IStateWriter and Store
35-
- Removed deprecated WithJSRuntime() and old WithDevTools() overloads from StoreBuilder
36-
- StoreComponent&lt;T&gt; slimmed down - utility methods moved to StoreComponentWithUtilities&lt;T&gt;
31+
v2.0.0 - Phase 3: Core Enhancements
3732

3833
New Features:
39-
- StoreComponentWithUtilities&lt;T&gt; for components needing debounce, throttle, lazy loading, and async execution
40-
- UpdateWithAsync extension method for simplified async data loading patterns
41-
- DevTools now uses IServiceProvider for lazy IJSRuntime resolution (Blazor Server compatibility)
34+
- ISelector subscription: Subscribe to stores using memoized selectors (store.Subscribe(MySelectors.Total, callback))
35+
- Functional middleware: Add inline middleware with .Use() and conditional .UseWhen() syntax
36+
- MiddlewareContext: Rich context object passed to middleware with phase info, services, and state
37+
- PersistenceOptions: Full configuration for persistence with callbacks (OnHydrationSuccess, OnHydrationFailure, etc.)
38+
- Structured error handling: StoreError&lt;T&gt; record with ErrorLocation enum for centralized error management
39+
- OnError handler: Register error handlers in StoreBuilder for unified error tracking
4240

43-
Upgrade Guide:
44-
- Replace Update() calls with await Update() or await Store.UpdateAsync()
45-
- Components using UpdateDebounced, UpdateThrottled, ExecuteAsync, or LazyLoad should inherit from StoreComponentWithUtilities&lt;T&gt;
46-
- Replace .WithJSRuntime().WithDevTools() with .WithDevTools(sp, "StoreName")
41+
Breaking Changes:
42+
- Middleware now receives MiddlewareContext instead of raw parameters (for functional middleware)
43+
- IStateObservable has new Subscribe overload for ISelector
4744

48-
v1.1.0 - Phase 1: Bug Fixes and Polish
45+
Migration Guide:
46+
- Add .OnError() to StoreBuilder for centralized error handling
47+
- Use new PersistenceOptions for advanced persistence control
48+
- Consider using .Use() for simple inline middleware
4949

50-
- AsyncData&lt;T&gt; converted from class to record for value equality
51-
- MemoizedSelector thread-safety improvements
52-
- Proper logging for persistence hydration errors
53-
- Enhanced XML documentation
50+
v1.2.0 - Phase 2: Cleanup and Simplification
51+
(See previous release notes)
5452
</PackageReleaseNotes>
5553
<PublishRepositoryUrl>true</PublishRepositoryUrl>
5654
<EmbedUntrackedSources>true</EmbedUntrackedSources>

0 commit comments

Comments
 (0)