Skip to content

Commit d1e7e87

Browse files
committed
feat: add synchronous localStorage operations and state hydration support in WebAssembly
1 parent c7628a6 commit d1e7e87

3 files changed

Lines changed: 145 additions & 0 deletions

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.Json;
12
using EasyAppDev.Blazor.Store.Core;
23
#if DEBUG
34
using EasyAppDev.Blazor.Store.Diagnostics;
@@ -60,6 +61,7 @@ public static StoreBuilder<TState> WithDefaults<TState>(
6061

6162
/// <summary>
6263
/// Adds persistence middleware with automatic LocalStorageProvider creation.
64+
/// In WebAssembly mode, state is automatically hydrated from localStorage on store creation.
6365
/// Requires the store to be registered as scoped (via AddScopedStore or AddScopedStoreWithUtilities)
6466
/// so that IJSRuntime is available during store creation.
6567
/// </summary>
@@ -99,9 +101,77 @@ public static StoreBuilder<TState> WithPersistence<TState>(
99101
}
100102

101103
var provider = new LocalStorageProvider(jsRuntime);
104+
105+
// In WebAssembly mode, synchronous JS interop is available
106+
// Load persisted state before building the store
107+
if (provider.SupportsSyncOperations)
108+
{
109+
builder = TryHydrateStateSync(builder, provider, key);
110+
}
111+
102112
return builder.WithPersistence(provider, key);
103113
}
104114

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

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ public static StoreBuilder<TState> Create(TState initialState)
3838
return new StoreBuilder<TState>(initialState);
3939
}
4040

41+
/// <summary>
42+
/// Creates a new builder with the specified initial state while preserving all other configuration.
43+
/// Used internally for state hydration from persistence.
44+
/// </summary>
45+
/// <param name="newInitialState">The new initial state.</param>
46+
/// <returns>A new builder with the updated initial state and all existing configuration.</returns>
47+
public StoreBuilder<TState> WithInitialState(TState newInitialState)
48+
{
49+
ArgumentNullException.ThrowIfNull(newInitialState);
50+
51+
var newBuilder = new StoreBuilder<TState>(newInitialState)
52+
{
53+
_comparer = this._comparer,
54+
_middlewarePipelineLogger = this._middlewarePipelineLogger,
55+
_storeLogger = this._storeLogger,
56+
_subscriptionManagerLogger = this._subscriptionManagerLogger,
57+
_middlewareOptions = this._middlewareOptions,
58+
_errorHandler = this._errorHandler,
59+
_stateValidator = this._stateValidator,
60+
_requireValidation = this._requireValidation
61+
};
62+
newBuilder._middlewares.AddRange(this._middlewares);
63+
return newBuilder;
64+
}
65+
4166
/// <summary>
4267
/// Gets the configured state validator, if any.
4368
/// </summary>

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ namespace EasyAppDev.Blazor.Store.Persistence;
1212
/// Operations are async due to Blazor's JS interop requirements.
1313
/// Errors are logged at Warning level and don't throw exceptions to ensure
1414
/// application stability when storage is unavailable or quota exceeded.
15+
/// In WebAssembly mode, synchronous operations are available via <see cref="IJSInProcessRuntime"/>.
1516
/// </remarks>
1617
public class LocalStorageProvider : IPersistenceProvider
1718
{
1819
private readonly IJSRuntime _jsRuntime;
20+
private readonly IJSInProcessRuntime? _jsInProcessRuntime;
1921
private readonly ILogger<LocalStorageProvider>? _logger;
2022

2123
/// <summary>
@@ -29,9 +31,57 @@ public class LocalStorageProvider : IPersistenceProvider
2931
public LocalStorageProvider(IJSRuntime jsRuntime, ILogger<LocalStorageProvider>? logger = null)
3032
{
3133
_jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
34+
_jsInProcessRuntime = jsRuntime as IJSInProcessRuntime;
3235
_logger = logger;
3336
}
3437

38+
/// <summary>
39+
/// Gets whether synchronous operations are available (WebAssembly mode).
40+
/// </summary>
41+
public bool SupportsSyncOperations => _jsInProcessRuntime != null;
42+
43+
/// <summary>
44+
/// Synchronously loads a value from localStorage.
45+
/// Only available in WebAssembly mode where <see cref="IJSInProcessRuntime"/> is available.
46+
/// </summary>
47+
/// <param name="key">The storage key.</param>
48+
/// <returns>The stored value, or null if not found.</returns>
49+
/// <exception cref="InvalidOperationException">
50+
/// Thrown when called in Blazor Server mode where synchronous JS interop is not available.
51+
/// </exception>
52+
public string? LoadSync(string key)
53+
{
54+
ArgumentException.ThrowIfNullOrWhiteSpace(key);
55+
56+
if (_jsInProcessRuntime == null)
57+
{
58+
throw new InvalidOperationException(
59+
"Synchronous JS interop is not available. " +
60+
"Use LoadAsync instead, or ensure you're running in WebAssembly mode.");
61+
}
62+
63+
try
64+
{
65+
var value = _jsInProcessRuntime.Invoke<string?>("localStorage.getItem", key);
66+
67+
if (value != null)
68+
{
69+
_logger?.LogDebug("Loaded from localStorage key: {Key}, Size: {Size:N0} bytes (sync)", key, value.Length);
70+
}
71+
else
72+
{
73+
_logger?.LogDebug("No value found in localStorage for key: {Key} (sync)", key);
74+
}
75+
76+
return value;
77+
}
78+
catch (Exception ex)
79+
{
80+
_logger?.LogWarning(ex, "Failed to load from localStorage key: {Key} (sync)", key);
81+
return null;
82+
}
83+
}
84+
3585
/// <inheritdoc />
3686
public async Task<string?> LoadAsync(string key)
3787
{

0 commit comments

Comments
 (0)