Skip to content

Commit df555cc

Browse files
committed
Enhance Persistence Middleware and Options
- Introduced a debounce lock in PersistenceMiddleware to prevent memory leaks during state saving. - Added checks for maximum state size and improved error handling for storage quota exceptions. - Implemented a RequireSignature option in PersistenceOptions to enforce state integrity. - Enhanced Query class to use atomic operations for fetching state, preventing race conditions. - Improved cache entry expiration checks using a non-generic interface for better performance. - Updated ServerSyncMiddleware to handle deeply nested JSON payloads and added session validation timeout. - Added cancellation support for pending debounced and throttled actions in respective managers. - Introduced tests for optimistic updates to ensure concurrent modification exceptions are handled correctly. - Enhanced DevToolsMiddleware tests to run only in DEBUG builds for security reasons.
1 parent 219350c commit df555cc

32 files changed

Lines changed: 1299 additions & 282 deletions

src/EasyAppDev.Blazor.Store/Blazor/SelectorStoreComponent.cs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ public abstract class SelectorStoreComponent<TState> : ComponentBase, IDisposabl
3636
{
3737
private IDisposable? _subscription;
3838
private bool _disposed;
39-
private object? _selectedValue;
39+
private volatile object? _selectedValue;
4040
private object? _lastRenderedValue;
41-
private bool _isFirstRender = true;
41+
private int _isFirstRender = 1; // 1 = true, 0 = false (for thread-safe access)
42+
private readonly object _valueLock = new();
4243
#if DEBUG
4344
private Guid _subscriptionId;
4445
#endif
@@ -141,15 +142,19 @@ protected override void OnInitialized()
141142
/// <inheritdoc />
142143
protected override bool ShouldRender()
143144
{
144-
// Always render on first render
145-
if (_isFirstRender)
145+
// Always render on first render (thread-safe read using Volatile)
146+
if (Volatile.Read(ref _isFirstRender) == 1)
146147
{
147148
return true;
148149
}
149150

150151
// Only render if the selected value has actually changed since last render
151152
// This prevents duplicate renders from Blazor's internal rendering mechanism
152-
var currentSelected = _selectedValue;
153+
object? currentSelected;
154+
lock (_valueLock)
155+
{
156+
currentSelected = _selectedValue;
157+
}
153158

154159
if (currentSelected == null && _lastRenderedValue == null)
155160
{
@@ -169,8 +174,12 @@ protected override void OnAfterRender(bool firstRender)
169174
{
170175
base.OnAfterRender(firstRender);
171176

172-
_isFirstRender = false;
173-
_lastRenderedValue = _selectedValue;
177+
// Thread-safe write using Volatile
178+
Volatile.Write(ref _isFirstRender, 0);
179+
lock (_valueLock)
180+
{
181+
_lastRenderedValue = _selectedValue;
182+
}
174183

175184
#if DEBUG
176185
// Record render event for diagnostics
@@ -225,8 +234,20 @@ protected virtual void SubscribeToStore()
225234
// This is now safe with ShouldRender() optimization preventing cascade renders
226235
DiagnosticsService?.RecordSubscriptionNotification(_subscriptionId);
227236
#endif
228-
_selectedValue = value;
229-
InvokeAsync(StateHasChanged);
237+
// Thread-safe write to _selectedValue
238+
lock (_valueLock)
239+
{
240+
_selectedValue = value;
241+
}
242+
// Use try-catch to handle component disposal during async invoke
243+
try
244+
{
245+
InvokeAsync(StateHasChanged);
246+
}
247+
catch (ObjectDisposedException)
248+
{
249+
// Component was disposed during notification - this is expected
250+
}
230251
});
231252
}
232253

src/EasyAppDev.Blazor.Store/Blazor/StoreComponent.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public abstract class StoreComponent<TState> : ComponentBase, IDisposable
2020
where TState : notnull
2121
{
2222
private IDisposable? _subscription;
23-
private bool _disposed;
23+
private volatile bool _disposed;
2424
#if DEBUG
2525
private Guid _subscriptionId;
2626
#endif
@@ -55,6 +55,11 @@ public abstract class StoreComponent<TState> : ComponentBase, IDisposable
5555
/// </summary>
5656
protected TState State => Store.GetState();
5757

58+
/// <summary>
59+
/// Gets whether the component has been disposed.
60+
/// </summary>
61+
protected bool IsDisposed => _disposed;
62+
5863
/// <summary>
5964
/// Updates the state using a transformation function (Zustand-style).
6065
/// </summary>
@@ -162,7 +167,19 @@ protected IDisposable SubscribeToSelector<TSelected>(
162167
value =>
163168
{
164169
callback?.Invoke(value);
165-
InvokeAsync(StateHasChanged);
170+
// Use try-catch to handle component disposal during async invoke
171+
try
172+
{
173+
InvokeAsync(StateHasChanged);
174+
}
175+
catch (ObjectDisposedException)
176+
{
177+
// Component was disposed during notification - this is expected
178+
}
179+
catch (Exception ex)
180+
{
181+
Logger?.LogWarning(ex, "Error invoking StateHasChanged in {ComponentType}", GetType().Name);
182+
}
166183
});
167184
}
168185

src/EasyAppDev.Blazor.Store/Blazor/StoreComponentWithUtilities.cs

Lines changed: 127 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ namespace EasyAppDev.Blazor.Store.Blazor;
1717
public abstract class StoreComponentWithUtilities<TState> : StoreComponent<TState>
1818
where TState : notnull
1919
{
20+
private readonly HashSet<string> _registeredDebounceKeys = new();
21+
private readonly HashSet<string> _registeredThrottleKeys = new();
22+
private readonly object _keysLock = new();
23+
2024
/// <summary>
2125
/// Gets the injected debounce manager.
2226
/// </summary>
@@ -69,12 +73,24 @@ protected Task UpdateDebounced(
6973
ArgumentNullException.ThrowIfNull(updater);
7074

7175
var key = $"{GetType().Name}_{action ?? "update"}";
76+
TrackDebounceKey(key);
7277
return DebounceManager.Debounce(key, async () =>
7378
{
74-
await InvokeAsync(async () =>
79+
// Check if disposed before invoking to prevent operations on disposed component
80+
if (IsDisposed) return;
81+
82+
try
83+
{
84+
await InvokeAsync(async () =>
85+
{
86+
if (IsDisposed) return;
87+
await Store.UpdateAsync(updater, action);
88+
});
89+
}
90+
catch (ObjectDisposedException)
7591
{
76-
await Store.UpdateAsync(updater, action);
77-
});
92+
// Component was disposed during async invoke - expected behavior
93+
}
7894
}, delayMilliseconds);
7995
}
8096

@@ -92,12 +108,24 @@ protected Task UpdateDebouncedAsync(
92108
ArgumentNullException.ThrowIfNull(asyncUpdater);
93109

94110
var key = $"{GetType().Name}_{action ?? "update"}";
111+
TrackDebounceKey(key);
95112
return DebounceManager.Debounce(key, async () =>
96113
{
97-
await InvokeAsync(async () =>
114+
// Check if disposed before invoking to prevent operations on disposed component
115+
if (IsDisposed) return;
116+
117+
try
118+
{
119+
await InvokeAsync(async () =>
120+
{
121+
if (IsDisposed) return;
122+
await Store.UpdateAsync(asyncUpdater, action);
123+
});
124+
}
125+
catch (ObjectDisposedException)
98126
{
99-
await Store.UpdateAsync(asyncUpdater, action);
100-
});
127+
// Component was disposed during async invoke - expected behavior
128+
}
101129
}, delayMilliseconds);
102130
}
103131

@@ -115,12 +143,24 @@ protected Task UpdateThrottled(
115143
ArgumentNullException.ThrowIfNull(updater);
116144

117145
var key = $"{GetType().Name}_{action ?? "update"}";
146+
TrackThrottleKey(key);
118147
return ThrottleManager.Throttle(key, async () =>
119148
{
120-
await InvokeAsync(async () =>
149+
// Check if disposed before invoking to prevent operations on disposed component
150+
if (IsDisposed) return;
151+
152+
try
121153
{
122-
await Store.UpdateAsync(updater, action);
123-
});
154+
await InvokeAsync(async () =>
155+
{
156+
if (IsDisposed) return;
157+
await Store.UpdateAsync(updater, action);
158+
});
159+
}
160+
catch (ObjectDisposedException)
161+
{
162+
// Component was disposed during async invoke - expected behavior
163+
}
124164
}, intervalMilliseconds);
125165
}
126166

@@ -138,12 +178,24 @@ protected Task UpdateThrottledAsync(
138178
ArgumentNullException.ThrowIfNull(asyncUpdater);
139179

140180
var key = $"{GetType().Name}_{action ?? "update"}";
181+
TrackThrottleKey(key);
141182
return ThrottleManager.Throttle(key, async () =>
142183
{
143-
await InvokeAsync(async () =>
184+
// Check if disposed before invoking to prevent operations on disposed component
185+
if (IsDisposed) return;
186+
187+
try
188+
{
189+
await InvokeAsync(async () =>
190+
{
191+
if (IsDisposed) return;
192+
await Store.UpdateAsync(asyncUpdater, action);
193+
});
194+
}
195+
catch (ObjectDisposedException)
144196
{
145-
await Store.UpdateAsync(asyncUpdater, action);
146-
});
197+
// Component was disposed during async invoke - expected behavior
198+
}
147199
}, intervalMilliseconds);
148200
}
149201

@@ -239,4 +291,67 @@ protected Task<T> LazyLoad<T>(
239291

240292
return LazyCache.GetOrLoadAsync(cacheKey, loader, cacheFor);
241293
}
294+
295+
/// <summary>
296+
/// Tracks a debounce key for cleanup on disposal.
297+
/// </summary>
298+
private void TrackDebounceKey(string key)
299+
{
300+
lock (_keysLock)
301+
{
302+
_registeredDebounceKeys.Add(key);
303+
}
304+
}
305+
306+
/// <summary>
307+
/// Tracks a throttle key for cleanup on disposal.
308+
/// </summary>
309+
private void TrackThrottleKey(string key)
310+
{
311+
lock (_keysLock)
312+
{
313+
_registeredThrottleKeys.Add(key);
314+
}
315+
}
316+
317+
/// <inheritdoc />
318+
protected override void Dispose(bool disposing)
319+
{
320+
if (disposing)
321+
{
322+
// Cancel all registered debounce and throttle operations for this component
323+
CancelPendingOperations();
324+
}
325+
326+
base.Dispose(disposing);
327+
}
328+
329+
/// <summary>
330+
/// Cancels all pending debounce and throttle operations registered by this component.
331+
/// </summary>
332+
private void CancelPendingOperations()
333+
{
334+
string[] debounceKeys;
335+
string[] throttleKeys;
336+
337+
lock (_keysLock)
338+
{
339+
debounceKeys = _registeredDebounceKeys.ToArray();
340+
throttleKeys = _registeredThrottleKeys.ToArray();
341+
_registeredDebounceKeys.Clear();
342+
_registeredThrottleKeys.Clear();
343+
}
344+
345+
// Cancel debounce operations (fire-and-forget since we're disposing)
346+
foreach (var key in debounceKeys)
347+
{
348+
_ = DebounceManager.CancelAsync(key);
349+
}
350+
351+
// Cancel throttle operations
352+
foreach (var key in throttleKeys)
353+
{
354+
_ = ThrottleManager.CancelAsync(key);
355+
}
356+
}
242357
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ IDisposable Subscribe<TSelected>(
3939
/// </summary>
4040
void NotifyAll();
4141

42+
/// <summary>
43+
/// Notifies all subscribers of a state change with a captured state snapshot.
44+
/// This ensures subscribers see a consistent state even if concurrent updates occur.
45+
/// </summary>
46+
/// <param name="capturedState">The state snapshot captured before releasing the store lock.</param>
47+
void NotifyAll(TState capturedState);
48+
4249
/// <summary>
4350
/// Clears all active subscriptions. Typically called during store disposal.
4451
/// </summary>

0 commit comments

Comments
 (0)