Skip to content

Commit 347917d

Browse files
committed
feat: enhance AsyncData to record type for value equality and add comprehensive tests for value equality and with expressions
feat: improve logging in LocalStorageProvider and SessionStorageProvider for better error handling feat: implement thread-safe memoization in MemoizedSelector with extensive concurrent access tests chore: update project version to 1.1.0 and refine release notes
1 parent b77ecec commit 347917d

9 files changed

Lines changed: 441 additions & 42 deletions

File tree

src/EasyAppDev.Blazor.Store/AsyncActions/AsyncData.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ namespace EasyAppDev.Blazor.Store.AsyncActions;
2121
/// }
2222
/// </code>
2323
/// </example>
24-
public class AsyncData<T>
24+
/// <remarks>
25+
/// This is a record type providing value equality semantics. Two AsyncData instances
26+
/// are equal if all their properties have the same values. Use <c>with</c> expressions
27+
/// for creating modified copies.
28+
/// </remarks>
29+
public record AsyncData<T>
2530
{
2631
/// <summary>
2732
/// Gets the data if the operation succeeded, otherwise null.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,9 @@ private StoreBuilder<TState> TryLoadPersistedState(
281281
}
282282
catch (Exception ex)
283283
{
284-
_ = ex;
284+
// Log at Debug level - user can enable diagnostics if needed
285+
System.Diagnostics.Debug.WriteLine(
286+
$"[EasyAppDev.Store] Failed to hydrate state for {typeof(TState).Name} from key '{key}': {ex.Message}");
285287
}
286288

287289
return this;
@@ -328,7 +330,9 @@ public async Task<StoreBuilder<TState>> WithHydratedStateAsync(
328330
}
329331
catch (Exception ex)
330332
{
331-
_ = ex;
333+
// Log at Debug level - user can enable diagnostics if needed
334+
System.Diagnostics.Debug.WriteLine(
335+
$"[EasyAppDev.Store] Failed to async hydrate state for {typeof(TState).Name} from key '{key}': {ex.Message}");
332336
}
333337

334338
return this;

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

Lines changed: 8 additions & 4 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.0.8</Version>
18+
<Version>1.1.0</Version>
1919
<Authors>Mashrul Haque</Authors>
2020
<Company>EasyAppDev</Company>
2121
<Product>EasyAppDev.Blazor.Store</Product>
@@ -28,11 +28,15 @@
2828
<PackageReadmeFile>README.md</PackageReadmeFile>
2929
<PackageIcon>icon.png</PackageIcon>
3030
<PackageReleaseNotes>
31-
v1.0.8 - Latest Release
31+
v1.1.0 - Phase 1: Bug Fixes and Polish
3232

33-
Type-safe state management for Blazor using C# records. Features include async helpers, Redux DevTools integration, persistence, and granular selectors. Full support for Blazor Server, WebAssembly, and Auto modes with intelligent lazy initialization.
33+
- AsyncData&lt;T&gt; converted from class to record for value equality and immutability consistency
34+
- MemoizedSelector thread-safety with lock-free volatile pattern for Blazor Server scenarios
35+
- Proper logging for persistence hydration errors (was silently swallowed)
36+
- ILogger integration for LocalStorageProvider and SessionStorageProvider
37+
- Enhanced XML documentation across public APIs
3438

35-
See CHANGELOG.md for full details.
39+
No breaking changes from 1.0.x.
3640
</PackageReleaseNotes>
3741
<PublishRepositoryUrl>true</PublishRepositoryUrl>
3842
<EmbedUntrackedSources>true</EmbedUntrackedSources>
Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,62 @@
11
namespace EasyAppDev.Blazor.Store.Persistence;
22

33
/// <summary>
4-
/// Interface for state persistence providers.
4+
/// Interface for state persistence providers that enable automatic state save/restore.
55
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// Implement this interface to create custom persistence backends (e.g., IndexedDB,
9+
/// remote storage, encrypted storage). Built-in implementations include
10+
/// <see cref="LocalStorageProvider"/> and <see cref="SessionStorageProvider"/>.
11+
/// </para>
12+
/// <para>
13+
/// Implementations should handle errors gracefully and return null/false for missing keys
14+
/// rather than throwing exceptions, as the persistence layer should not crash the application.
15+
/// </para>
16+
/// </remarks>
17+
/// <example>
18+
/// <code>
19+
/// public class IndexedDbProvider : IPersistenceProvider
20+
/// {
21+
/// public async Task&lt;string?&gt; LoadAsync(string key)
22+
/// {
23+
/// return await _jsRuntime.InvokeAsync&lt;string?&gt;("indexedDb.get", key);
24+
/// }
25+
/// // ... other methods
26+
/// }
27+
/// </code>
28+
/// </example>
629
public interface IPersistenceProvider
730
{
831
/// <summary>
932
/// Loads persisted state from storage.
1033
/// </summary>
11-
/// <param name="key">The storage key.</param>
12-
/// <returns>The serialized state or null if not found.</returns>
34+
/// <param name="key">The storage key identifying the state to load.</param>
35+
/// <returns>The JSON-serialized state string, or null if the key does not exist.</returns>
36+
/// <exception cref="ArgumentException">Thrown when <paramref name="key"/> is null or whitespace.</exception>
1337
Task<string?> LoadAsync(string key);
1438

1539
/// <summary>
1640
/// Saves state to storage.
1741
/// </summary>
18-
/// <param name="key">The storage key.</param>
19-
/// <param name="value">The serialized state.</param>
42+
/// <param name="key">The storage key identifying where to save the state.</param>
43+
/// <param name="value">The JSON-serialized state string to persist.</param>
44+
/// <exception cref="ArgumentException">Thrown when <paramref name="key"/> is null or whitespace.</exception>
45+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="value"/> is null.</exception>
2046
Task SaveAsync(string key, string value);
2147

2248
/// <summary>
2349
/// Removes persisted state from storage.
2450
/// </summary>
25-
/// <param name="key">The storage key.</param>
51+
/// <param name="key">The storage key to remove.</param>
52+
/// <exception cref="ArgumentException">Thrown when <paramref name="key"/> is null or whitespace.</exception>
2653
Task RemoveAsync(string key);
2754

2855
/// <summary>
2956
/// Checks if a key exists in storage.
3057
/// </summary>
31-
/// <param name="key">The storage key.</param>
32-
/// <returns>True if the key exists.</returns>
58+
/// <param name="key">The storage key to check.</param>
59+
/// <returns>True if the key exists in storage; otherwise, false.</returns>
60+
/// <exception cref="ArgumentException">Thrown when <paramref name="key"/> is null or whitespace.</exception>
3361
Task<bool> ContainsKeyAsync(string key);
3462
}

src/EasyAppDev.Blazor.Store/Persistence/LocalStorageProvider.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.Extensions.Logging;
12
using Microsoft.JSInterop;
23

34
namespace EasyAppDev.Blazor.Store.Persistence;
@@ -6,17 +7,29 @@ namespace EasyAppDev.Blazor.Store.Persistence;
67
/// Persistence provider using browser LocalStorage.
78
/// Data persists across browser sessions.
89
/// </summary>
10+
/// <remarks>
11+
/// This provider wraps browser localStorage API through JavaScript interop.
12+
/// Operations are async due to Blazor's JS interop requirements.
13+
/// Errors are logged at Warning level and don't throw exceptions to ensure
14+
/// application stability when storage is unavailable or quota exceeded.
15+
/// </remarks>
916
public class LocalStorageProvider : IPersistenceProvider
1017
{
1118
private readonly IJSRuntime _jsRuntime;
19+
private readonly ILogger<LocalStorageProvider>? _logger;
1220

1321
/// <summary>
1422
/// Initializes a new instance of the <see cref="LocalStorageProvider"/> class.
1523
/// </summary>
1624
/// <param name="jsRuntime">The JS runtime for interop.</param>
17-
public LocalStorageProvider(IJSRuntime jsRuntime)
25+
/// <param name="logger">Optional logger for diagnostic output.</param>
26+
/// <exception cref="ArgumentNullException">
27+
/// Thrown when <paramref name="jsRuntime"/> is null.
28+
/// </exception>
29+
public LocalStorageProvider(IJSRuntime jsRuntime, ILogger<LocalStorageProvider>? logger = null)
1830
{
1931
_jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
32+
_logger = logger;
2033
}
2134

2235
/// <inheritdoc />
@@ -31,7 +44,7 @@ public LocalStorageProvider(IJSRuntime jsRuntime)
3144
}
3245
catch (Exception ex)
3346
{
34-
Console.WriteLine($"Error loading from localStorage: {ex.Message}");
47+
_logger?.LogWarning(ex, "Failed to load from localStorage key: {Key}", key);
3548
return null;
3649
}
3750
}
@@ -49,7 +62,7 @@ await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value)
4962
}
5063
catch (Exception ex)
5164
{
52-
Console.WriteLine($"Error saving to localStorage: {ex.Message}");
65+
_logger?.LogWarning(ex, "Failed to save to localStorage key: {Key}", key);
5366
}
5467
}
5568

@@ -65,7 +78,7 @@ await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key)
6578
}
6679
catch (Exception ex)
6780
{
68-
Console.WriteLine($"Error removing from localStorage: {ex.Message}");
81+
_logger?.LogWarning(ex, "Failed to remove localStorage key: {Key}", key);
6982
}
7083
}
7184

src/EasyAppDev.Blazor.Store/Persistence/SessionStorageProvider.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.Extensions.Logging;
12
using Microsoft.JSInterop;
23

34
namespace EasyAppDev.Blazor.Store.Persistence;
@@ -6,17 +7,30 @@ namespace EasyAppDev.Blazor.Store.Persistence;
67
/// Persistence provider using browser SessionStorage.
78
/// Data persists only for the current browser session/tab.
89
/// </summary>
10+
/// <remarks>
11+
/// This provider wraps browser sessionStorage API through JavaScript interop.
12+
/// Data is cleared when the browser tab is closed. For persistent storage across
13+
/// sessions, use <see cref="LocalStorageProvider"/> instead.
14+
/// Errors are logged at Warning level and don't throw exceptions to ensure
15+
/// application stability when storage is unavailable.
16+
/// </remarks>
917
public class SessionStorageProvider : IPersistenceProvider
1018
{
1119
private readonly IJSRuntime _jsRuntime;
20+
private readonly ILogger<SessionStorageProvider>? _logger;
1221

1322
/// <summary>
1423
/// Initializes a new instance of the <see cref="SessionStorageProvider"/> class.
1524
/// </summary>
1625
/// <param name="jsRuntime">The JS runtime for interop.</param>
17-
public SessionStorageProvider(IJSRuntime jsRuntime)
26+
/// <param name="logger">Optional logger for diagnostic output.</param>
27+
/// <exception cref="ArgumentNullException">
28+
/// Thrown when <paramref name="jsRuntime"/> is null.
29+
/// </exception>
30+
public SessionStorageProvider(IJSRuntime jsRuntime, ILogger<SessionStorageProvider>? logger = null)
1831
{
1932
_jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
33+
_logger = logger;
2034
}
2135

2236
/// <inheritdoc />
@@ -31,7 +45,7 @@ public SessionStorageProvider(IJSRuntime jsRuntime)
3145
}
3246
catch (Exception ex)
3347
{
34-
Console.WriteLine($"Error loading from sessionStorage: {ex.Message}");
48+
_logger?.LogWarning(ex, "Failed to load from sessionStorage key: {Key}", key);
3549
return null;
3650
}
3751
}
@@ -49,7 +63,7 @@ await _jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, value)
4963
}
5064
catch (Exception ex)
5165
{
52-
Console.WriteLine($"Error saving to sessionStorage: {ex.Message}");
66+
_logger?.LogWarning(ex, "Failed to save to sessionStorage key: {Key}", key);
5367
}
5468
}
5569

@@ -65,7 +79,7 @@ await _jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key)
6579
}
6680
catch (Exception ex)
6781
{
68-
Console.WriteLine($"Error removing from sessionStorage: {ex.Message}");
82+
_logger?.LogWarning(ex, "Failed to remove sessionStorage key: {Key}", key);
6983
}
7084
}
7185

src/EasyAppDev.Blazor.Store/Selectors/MemoizedSelector.cs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,41 @@ namespace EasyAppDev.Blazor.Store.Selectors;
66
/// <typeparam name="TState">The type of state to select from.</typeparam>
77
/// <typeparam name="TResult">The type of result computed by the selector.</typeparam>
88
/// <remarks>
9+
/// <para>
910
/// This implementation uses reference equality for state comparison and configurable
1011
/// equality comparison for result caching. The selector function is only invoked when
1112
/// the state reference changes or when the cache is reset.
13+
/// </para>
14+
/// <para>
15+
/// Thread-safety: This class is thread-safe. The cache uses a lock-free volatile pattern
16+
/// to ensure consistent reads and writes across multiple threads. In Blazor Server scenarios
17+
/// with multiple concurrent users, this prevents race conditions that could result in
18+
/// corrupted cache values.
19+
/// </para>
1220
/// </remarks>
1321
internal class MemoizedSelector<TState, TResult> : ISelector<TState, TResult>
1422
{
1523
private readonly Func<TState, TResult> _selector;
16-
private readonly IEqualityComparer<TResult> _comparer;
17-
private TState? _lastState;
18-
private TResult? _cachedResult;
19-
private bool _hasCache;
24+
25+
/// <summary>
26+
/// Immutable cache entry holding both state and computed result atomically.
27+
/// Using a single volatile reference ensures thread-safe reads/writes without locks.
28+
/// </summary>
29+
private sealed record CacheEntry(TState State, TResult Result);
30+
31+
/// <summary>
32+
/// Volatile cache entry for thread-safe lock-free access.
33+
/// </summary>
34+
private volatile CacheEntry? _cache;
2035

2136
/// <summary>
2237
/// Initializes a new instance of the <see cref="MemoizedSelector{TState, TResult}"/> class.
2338
/// </summary>
2439
/// <param name="selector">The selector function to compute the result.</param>
25-
/// <param name="comparer">Optional comparer to determine if the result changed. Defaults to default equality comparer.</param>
40+
/// <param name="comparer">
41+
/// Optional comparer parameter retained for API compatibility.
42+
/// The lock-free implementation unconditionally updates the cache on state change.
43+
/// </param>
2644
/// <exception cref="ArgumentNullException">
2745
/// Thrown when <paramref name="selector"/> is null.
2846
/// </exception>
@@ -31,7 +49,9 @@ public MemoizedSelector(
3149
IEqualityComparer<TResult>? comparer = null)
3250
{
3351
_selector = selector ?? throw new ArgumentNullException(nameof(selector));
34-
_comparer = comparer ?? EqualityComparer<TResult>.Default;
52+
// comparer is accepted for API compatibility but not used in the lock-free implementation.
53+
// The cache is always updated when state changes, which is the correct behavior.
54+
_ = comparer;
3555
}
3656

3757
/// <summary>
@@ -43,44 +63,52 @@ public MemoizedSelector(
4363
/// Thrown when <paramref name="state"/> is null.
4464
/// </exception>
4565
/// <remarks>
66+
/// <para>
4667
/// The selector function is only invoked when:
68+
/// </para>
4769
/// <list type="bullet">
48-
/// <item><description>The state reference has changed (using reference equality)</description></item>
70+
/// <item><description>The state reference has changed (using default equality)</description></item>
4971
/// <item><description>The cache has been reset via <see cref="Reset"/></description></item>
5072
/// <item><description>This is the first invocation</description></item>
5173
/// </list>
74+
/// <para>
75+
/// This method is thread-safe and can be called concurrently from multiple threads.
76+
/// </para>
5277
/// </remarks>
5378
public TResult Select(TState state)
5479
{
5580
ArgumentNullException.ThrowIfNull(state);
5681

82+
// Read cache atomically - volatile ensures we see the latest value
83+
var cache = _cache;
84+
5785
// Check if we have a cached result for this state
58-
if (_hasCache && EqualityComparer<TState>.Default.Equals(_lastState, state))
86+
if (cache != null && EqualityComparer<TState>.Default.Equals(cache.State, state))
5987
{
60-
return _cachedResult!;
88+
return cache.Result;
6189
}
6290

6391
// Compute new result
6492
var result = _selector(state);
6593

66-
// Update cache only if result changed or this is first computation
67-
if (!_hasCache || !_comparer.Equals(_cachedResult, result))
68-
{
69-
_lastState = state;
70-
_cachedResult = result;
71-
_hasCache = true;
72-
}
94+
// Update cache atomically - single volatile write ensures thread-safety
95+
// Note: In race conditions, multiple threads may compute the same result,
96+
// but the cache will eventually hold a valid (state, result) pair.
97+
// This is acceptable as the selector should be a pure function.
98+
_cache = new CacheEntry(state, result);
7399

74100
return result;
75101
}
76102

77103
/// <summary>
78104
/// Resets the memoization cache, forcing recomputation on next <see cref="Select"/> call.
79105
/// </summary>
106+
/// <remarks>
107+
/// This method is thread-safe. After calling Reset, the next call to <see cref="Select"/>
108+
/// will recompute the result regardless of the state value.
109+
/// </remarks>
80110
public void Reset()
81111
{
82-
_hasCache = false;
83-
_lastState = default;
84-
_cachedResult = default;
112+
_cache = null;
85113
}
86114
}

0 commit comments

Comments
 (0)