Skip to content

Commit 9973f03

Browse files
mashrulhaqueclaude
andcommitted
fix: hydrate state for options-based WithPersistence and surface hydration errors (fixes #11)
- Add PersistenceMiddleware.TryLoadStateSync() that handles wrapper format, signature verification, validation, and TransformOnLoad in one place - StoreBuilder.WithPersistence(provider, options) and WithPersistence(provider, key) now perform sync hydration via the middleware when the provider supports it, so WithSecurePersistence() and PersistenceOptions users get state restoration - Replace silent Debug.WriteLine fallback with ILogger + Console.Error so deserialization failures are visible in Release WASM builds - Remove duplicated TryHydrateStateSync in StoreBuilderExtensions; unified path Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent e507257 commit 9973f03

3 files changed

Lines changed: 160 additions & 71 deletions

File tree

src/EasyAppDev.Blazor.Store/Blazor/StoreBuilderExtensions.cs

Lines changed: 4 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Text.Json;
21
using EasyAppDev.Blazor.Store.Core;
32
using EasyAppDev.Blazor.Store.Diagnostics;
43
using EasyAppDev.Blazor.Store.Persistence;
@@ -98,76 +97,13 @@ public static StoreBuilder<TState> WithPersistence<TState>(
9897

9998
var provider = new LocalStorageProvider(jsRuntime);
10099

101-
// In WebAssembly mode, synchronous JS interop is available
102-
// Load persisted state before building the store
103-
if (provider.SupportsSyncOperations)
104-
{
105-
builder = TryHydrateStateSync(builder, provider, key);
106-
}
107-
100+
// Hydration is now performed inside StoreBuilder.WithPersistence via the middleware
101+
// when the provider supports sync operations (WebAssembly). This unifies the
102+
// hydration path so wrapper handling, signature verification, validation, and
103+
// transforms apply consistently regardless of which WithPersistence overload is used.
108104
return builder.WithPersistence(provider, key);
109105
}
110106

111-
/// <summary>
112-
/// Attempts to hydrate state synchronously from localStorage (WebAssembly only).
113-
/// </summary>
114-
private static StoreBuilder<TState> TryHydrateStateSync<TState>(
115-
StoreBuilder<TState> builder,
116-
LocalStorageProvider provider,
117-
string key)
118-
where TState : notnull
119-
{
120-
try
121-
{
122-
var json = provider.LoadSync(key);
123-
if (string.IsNullOrEmpty(json))
124-
{
125-
return builder;
126-
}
127-
128-
var jsonOptions = new JsonSerializerOptions
129-
{
130-
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
131-
};
132-
133-
// Try to deserialize as new format (with wrapper)
134-
string stateJson;
135-
try
136-
{
137-
var wrapper = JsonSerializer.Deserialize<PersistedStateWrapper>(json, jsonOptions);
138-
if (wrapper != null && !string.IsNullOrEmpty(wrapper.State))
139-
{
140-
stateJson = wrapper.State;
141-
}
142-
else
143-
{
144-
// Legacy format - plain JSON state
145-
stateJson = json;
146-
}
147-
}
148-
catch
149-
{
150-
// Deserialization failed, try legacy format
151-
stateJson = json;
152-
}
153-
154-
var loadedState = JsonSerializer.Deserialize<TState>(stateJson, jsonOptions);
155-
if (loadedState != null)
156-
{
157-
// Create new builder with hydrated state, preserving configuration
158-
return builder.WithInitialState(loadedState);
159-
}
160-
}
161-
catch (Exception ex)
162-
{
163-
// Log but don't fail - fall back to initial state
164-
System.Diagnostics.Debug.WriteLine(
165-
$"[EasyAppDev.Store] Failed to hydrate state for {typeof(TState).Name} from key '{key}': {ex.Message}");
166-
}
167-
168-
return builder;
169-
}
170-
171107
/// <summary>
172108
/// Adds diagnostics middleware if IDiagnosticsService is registered.
173109
/// </summary>

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,13 @@ public StoreBuilder<TState> WithPersistence(
285285
JsonSerializerOptions? jsonOptions = null,
286286
int debounceMs = 0)
287287
{
288-
var hydratedBuilder = TryLoadPersistedState(key, jsonOptions);
289-
290288
var middleware = new PersistenceMiddleware<TState>(
291289
provider,
292290
key,
293291
jsonOptions,
294292
debounceMs);
295293

294+
var hydratedBuilder = TryHydrateFromMiddleware(middleware);
296295
return hydratedBuilder.WithMiddleware(middleware);
297296
}
298297

@@ -322,7 +321,25 @@ public StoreBuilder<TState> WithPersistence(
322321
ArgumentNullException.ThrowIfNull(options);
323322

324323
var middleware = new PersistenceMiddleware<TState>(provider, options);
325-
return WithMiddleware(middleware);
324+
var hydratedBuilder = options.HydrateOnInit
325+
? TryHydrateFromMiddleware(middleware)
326+
: this;
327+
return hydratedBuilder.WithMiddleware(middleware);
328+
}
329+
330+
/// <summary>
331+
/// Attempts sync hydration through the middleware. Returns a builder with the hydrated
332+
/// initial state if the provider supports sync operations (WebAssembly) and a persisted
333+
/// state was successfully loaded. Otherwise returns <c>this</c> unchanged.
334+
/// </summary>
335+
private StoreBuilder<TState> TryHydrateFromMiddleware(PersistenceMiddleware<TState> middleware)
336+
{
337+
var (supported, state) = middleware.TryLoadStateSync();
338+
if (supported && state is not null)
339+
{
340+
return WithInitialState(state);
341+
}
342+
return this;
326343
}
327344

328345
/// <summary>

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,140 @@ private static bool IsStorageQuotaException(Exception ex)
384384
/// Gets whether hydration should occur on initialization.
385385
/// </summary>
386386
public bool HydrateOnInit => _options?.HydrateOnInit ?? true;
387+
388+
/// <summary>
389+
/// Synchronously loads the persisted state if the provider supports sync operations
390+
/// (i.e., WebAssembly mode via Microsoft.JSInterop.IJSInProcessRuntime).
391+
/// </summary>
392+
/// <returns>
393+
/// A tuple of (supported, state). If <c>supported</c> is false, sync loading is not
394+
/// available on this provider and the caller should fall back to async hydration.
395+
/// If <c>supported</c> is true but <c>state</c> is default, no persisted state was found
396+
/// or it failed validation/integrity checks.
397+
/// </returns>
398+
public (bool Supported, TState? State) TryLoadStateSync()
399+
{
400+
if (_provider is not LocalStorageProvider lsp || !lsp.SupportsSyncOperations)
401+
{
402+
return (false, default);
403+
}
404+
405+
try
406+
{
407+
var json = lsp.LoadSync(_key);
408+
if (string.IsNullOrEmpty(json))
409+
{
410+
_options?.OnHydrationSkipped?.Invoke();
411+
return (true, default);
412+
}
413+
414+
var loaded = ParsePersistedJson(json);
415+
return (true, loaded);
416+
}
417+
catch (StateIntegrityException)
418+
{
419+
throw;
420+
}
421+
catch (Exception ex)
422+
{
423+
_logger?.LogError(ex, "Error loading persisted state synchronously from key: {Key}", _key);
424+
// Surface to the browser console as well, since Debug.WriteLine is silent in Release WASM.
425+
Console.Error.WriteLine(
426+
$"[EasyAppDev.Store] Sync hydration failed for {typeof(TState).Name} (key '{_key}'): {ex.GetType().Name}: {ex.Message}");
427+
_options?.OnHydrationFailure?.Invoke(ex);
428+
return (true, default);
429+
}
430+
}
431+
432+
/// <summary>
433+
/// Shared JSON parsing logic for both sync and async hydration paths.
434+
/// Handles wrapper unwrapping, signature verification, validation, and transformation.
435+
/// </summary>
436+
private TState? ParsePersistedJson(string json)
437+
{
438+
string stateJson;
439+
PersistedStateWrapper? wrapper = null;
440+
441+
try
442+
{
443+
wrapper = JsonSerializer.Deserialize<PersistedStateWrapper>(json, _jsonOptions);
444+
}
445+
catch
446+
{
447+
// Ignore - will fall back to legacy plain-JSON format below.
448+
}
449+
450+
if (wrapper != null && !string.IsNullOrEmpty(wrapper.State))
451+
{
452+
stateJson = wrapper.State;
453+
454+
if (_messageSigner != null && !string.IsNullOrEmpty(wrapper.Signature))
455+
{
456+
if (!_messageSigner.Verify(stateJson, wrapper.Signature))
457+
{
458+
var integrityEx = new StateIntegrityException(
459+
$"State integrity verification failed for key '{_key}'. The persisted state may have been tampered with.");
460+
_logger?.LogError(integrityEx, "Integrity check failed for key: {Key}", _key);
461+
_options?.OnHydrationFailure?.Invoke(integrityEx);
462+
return default;
463+
}
464+
}
465+
else if (_messageSigner != null && string.IsNullOrEmpty(wrapper.Signature))
466+
{
467+
_logger?.LogWarning(
468+
"State loaded without signature for key: {Key}. RequireSignature: {Required}",
469+
_key,
470+
_options?.RequireSignature ?? false);
471+
472+
if (_options?.RequireSignature == true)
473+
{
474+
var integrityEx = new StateIntegrityException(
475+
$"Unsigned state rejected for key '{_key}'. Integrity signing is required but no signature found.");
476+
_logger?.LogError(integrityEx, "Unsigned state rejected for key: {Key}", _key);
477+
_options.OnHydrationFailure?.Invoke(integrityEx);
478+
return default;
479+
}
480+
}
481+
}
482+
else
483+
{
484+
// Legacy plain-JSON format (pre-wrapper).
485+
stateJson = json;
486+
}
487+
488+
var loadedState = JsonSerializer.Deserialize<TState>(stateJson, _jsonOptions);
489+
if (loadedState == null)
490+
{
491+
_options?.OnHydrationSkipped?.Invoke();
492+
return default;
493+
}
494+
495+
if (_options?.StateValidator != null)
496+
{
497+
var validationResult = _options.StateValidator.Validate(loadedState);
498+
if (!validationResult.IsValid)
499+
{
500+
_logger?.LogWarning(
501+
"State validation failed for key {Key}: {Errors}",
502+
_key,
503+
string.Join(", ", validationResult.Errors));
504+
_options.OnValidationFailed?.Invoke(validationResult with { Source = "Persistence" });
505+
506+
if (_options.RejectInvalidState)
507+
{
508+
_options.OnHydrationFailure?.Invoke(
509+
new InvalidOperationException(
510+
$"State validation failed: {string.Join(", ", validationResult.Errors)}"));
511+
return default;
512+
}
513+
}
514+
}
515+
516+
var transformedState = _options?.TransformOnLoad != null
517+
? _options.TransformOnLoad(loadedState)
518+
: loadedState;
519+
520+
_options?.OnHydrationSuccess?.Invoke(transformedState);
521+
return transformedState;
522+
}
387523
}

0 commit comments

Comments
 (0)