diff --git a/docs/in-depth/client/advanced/blazor-wasm.md b/docs/in-depth/client/advanced/blazor-wasm.md
index 77cd335..a9f44af 100644
--- a/docs/in-depth/client/advanced/blazor-wasm.md
+++ b/docs/in-depth/client/advanced/blazor-wasm.md
@@ -9,6 +9,14 @@ You can use Blazor WASM with the Datasync Community Toolkit; however, there are
It is possible to use offline mode by storing the SQLite database in local storage within the browser. When you start up your application, you restore the SQLite in-memory database into memory; on a regular basis, you write the SQLite database to local storage. However, this system severely affects performance within the browser and large synchronizations will cause the local storage to overflow. As a result of these problems, it is not recommended, nor is it supported.
+!!! warning "Known limitations of offline mode on browser-wasm"
+ If you do experiment with offline mode in the browser, be aware of the following single-threaded browser-wasm limitations. These are not currently fixed because offline mode in Blazor WASM is not supported.
+
+ * **Use the async APIs.** The synchronous `SaveChanges()` calls `OperationsQueueManager.UpdateOperationsQueue()`, which blocks on a thread-pool task and cannot work on single-threaded WASM. Use `SaveChangesAsync()` and the `PullAsync()` / `PushAsync()` methods instead.
+ * **Specify a query ID for filtered pulls.** When a pull query has an OData filter and you do not supply a query ID, the toolkit generates one using `MD5`, which is marked as unsupported on `browser`. Pass an explicit query ID to avoid this.
+
+ Blocking waits on `SemaphoreSlim` (which throw on .NET 11 browser-wasm) are no longer used when building the offline options, so the first `PullAsync()` call no longer fails for that reason.
+
## Suppress the `WASM0001` warning
When compiling the WASM client, you will see warning `WASM0001`. This is a harmless warning that indicates SQLite is not available. However, you may be running using "Warnings as Errors". The most appropriate solution is to suppress the `WASM0001` warning in your client `.csproj` file as follows:
diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs
index a217ea0..af1d203 100644
--- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs
+++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs
@@ -60,7 +60,12 @@ public abstract partial class OfflineDbContext : DbContext
/// A checker for the disposed state of this context.
///
internal bool _disposedValue;
- internal DisposableLock _optionsLock = new();
+
+ ///
+ /// Guards the lazy creation of . A monitor-based is used instead of
+ /// a because blocking semaphore waits are not supported on single-threaded browser-wasm.
+ ///
+ private readonly Lock _optionsLock = new();
///
/// The operations queue. This is used to store pending requests to the datasync service.
@@ -144,15 +149,17 @@ protected OfflineDbContext(DbContextOptions options) : base(options)
/// The offline options for the datasync operation.
internal OfflineOptions BuildDatasyncOfflineOptions()
{
- using IDisposable optionsLock = this._optionsLock.AcquireLock();
- if (OfflineOptions is null)
+ lock (this._optionsLock)
{
- DatasyncOfflineOptionsBuilder builder = new(QueueManager.GetSynchronizableEntityTypes());
- OnDatasyncInitialization(builder);
- OfflineOptions = builder.Build();
+ if (OfflineOptions is null)
+ {
+ DatasyncOfflineOptionsBuilder builder = new(QueueManager.GetSynchronizableEntityTypes());
+ OnDatasyncInitialization(builder);
+ OfflineOptions = builder.Build();
+ }
+
+ return OfflineOptions;
}
-
- return OfflineOptions;
}
///
@@ -604,7 +611,6 @@ protected virtual void Dispose(bool disposing)
{
if (disposing)
{
- this._optionsLock.Dispose();
base.Dispose();
}
diff --git a/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs b/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs
index 40be358..7f6f5c4 100644
--- a/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs
+++ b/src/CommunityToolkit.Datasync.Client/Threading/AsyncLockDictionary.cs
@@ -39,28 +39,6 @@ public async Task AcquireLockAsync(string key, CancellationToken ca
});
}
- ///
- /// Acquire a lock (asynchronously) from the dictionary of locks.
- ///
- /// The key for the lock.
- /// An to release the lock when done.
- public IDisposable AcquireLock(string key)
- {
- LockEntry entry = this.locks.GetOrAdd(key, _ => new LockEntry());
- _ = entry.IncrementCount();
-
- IDisposable releaser = entry.Lock.AcquireLock();
- return new DisposeAction(() =>
- {
- releaser.Dispose();
- if (entry.DecrementCount() == 0)
- {
- _ = this.locks.TryRemove(key, out _);
- entry.Dispose();
- }
- });
- }
-
///
/// Determines if the specific lock referenced by key is locked now.
///
diff --git a/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs b/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs
index 5131439..0a830a0 100644
--- a/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs
+++ b/src/CommunityToolkit.Datasync.Client/Threading/DisposableLock.cs
@@ -14,16 +14,6 @@ internal class DisposableLock : IDisposable
///
private readonly SemaphoreSlim semaphore = new(1, 1);
- ///
- /// Acquire the lock.
- ///
- /// An to release the lock when the lock is acquired.
- public IDisposable AcquireLock()
- {
- this.semaphore.Wait();
- return new DisposeAction(() => this.semaphore.Release());
- }
-
///
/// Acquire the lock.
///
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs
index 3a3d5d1..7a8b7cd 100644
--- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs
@@ -5,6 +5,7 @@
#pragma warning disable IDE0305 // Simplify collection initialization
using CommunityToolkit.Datasync.Client.Offline;
+using CommunityToolkit.Datasync.Client.Offline.Models;
using CommunityToolkit.Datasync.Client.Serialization;
using CommunityToolkit.Datasync.Client.Test.Offline.Helpers;
using CommunityToolkit.Datasync.TestCommon;
@@ -41,6 +42,34 @@ public void Base_Ctor_CreatesInternalApi()
}
#endregion
+ #region BuildDatasyncOfflineOptions
+ [Fact]
+ public void BuildDatasyncOfflineOptions_ReturnsSameInstance()
+ {
+ int configured = 0;
+ this.context.Configurator = _ => configured++;
+
+ OfflineOptions first = this.context.BuildDatasyncOfflineOptions();
+ OfflineOptions second = this.context.BuildDatasyncOfflineOptions();
+
+ second.Should().BeSameAs(first);
+ configured.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task BuildDatasyncOfflineOptions_ConcurrentCalls_BuildOnce()
+ {
+ int configured = 0;
+ this.context.Configurator = _ => Interlocked.Increment(ref configured);
+
+ OfflineOptions[] results = await Task.WhenAll(
+ Enumerable.Range(0, 16).Select(_ => Task.Run(() => this.context.BuildDatasyncOfflineOptions())));
+
+ results.Should().OnlyContain(o => ReferenceEquals(o, results[0]));
+ configured.Should().Be(1);
+ }
+ #endregion
+
#region PullAsync
[Fact]
public async Task PullAsync_List_Works_InitialSync()
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs
index 29b99e4..da11fc8 100644
--- a/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/AsyncLockDictionary_Tests.cs
@@ -9,24 +9,6 @@ namespace CommunityToolkit.Datasync.Client.Test.Threading;
[ExcludeFromCodeCoverage]
public class AsyncLockDictionary_Tests
{
- [Fact]
- public void AcquireLock_Works()
- {
- AsyncLockDictionary sut = new();
-
- sut.IsLocked("key1").Should().BeFalse();
- sut.IsLocked("key2").Should().BeFalse();
-
- using (sut.AcquireLock("key1"))
- {
- sut.IsLocked("key1").Should().BeTrue();
- sut.IsLocked("key2").Should().BeFalse();
- }
-
- sut.IsLocked("key1").Should().BeFalse();
- sut.IsLocked("key2").Should().BeFalse();
- }
-
[Fact]
public async Task AcquireLockAsync_Works()
{
diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs
index e0d3483..c73e49d 100644
--- a/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs
+++ b/tests/CommunityToolkit.Datasync.Client.Test/Threading/DisposableLock_Tests.cs
@@ -9,20 +9,6 @@ namespace CommunityToolkit.Datasync.Client.Test.Threading;
[ExcludeFromCodeCoverage]
public class DisposableLock_Tests
{
- [Fact]
- public void AcquireLock_Works()
- {
- DisposableLock sut = new();
-
- sut.IsLocked().Should().BeFalse();
- using (sut.AcquireLock())
- {
- sut.IsLocked().Should().BeTrue();
- }
-
- sut.IsLocked().Should().BeFalse();
- }
-
[Fact]
public async Task AcquireLockAsync_Works()
{