diff --git a/src/Butil/Bit.Butil/Bit.Butil.csproj b/src/Butil/Bit.Butil/Bit.Butil.csproj
index 87c0b5b8ec..47446002ca 100644
--- a/src/Butil/Bit.Butil/Bit.Butil.csproj
+++ b/src/Butil/Bit.Butil/Bit.Butil.csproj
@@ -5,11 +5,6 @@
net10.0;net9.0;net8.0true
-
- BeforeBuildTasks;
- $(ResolveStaticWebAssetsInputsDependsOn)
-
- $(NoWarn);IL2026
@@ -28,28 +23,56 @@
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Butil/Bit.Butil/BitButil.cs b/src/Butil/Bit.Butil/BitButil.cs
index db906418aa..00395422a2 100644
--- a/src/Butil/Bit.Butil/BitButil.cs
+++ b/src/Butil/Bit.Butil/BitButil.cs
@@ -6,43 +6,87 @@ public static class BitButil
{
public static IServiceCollection AddBitButilServices(this IServiceCollection services)
{
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
+ // Scoped matches Blazor's "one circuit / one WASM app instance per user" model.
+ // Transient would create a fresh wrapper on every @inject, fragmenting per-instance
+ // listener bookkeeping and keeping captured component delegates alive longer than
+ // the component itself.
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
return services;
}
- internal static bool FastInvokeEnabled { get; private set; }
+ private static volatile bool _fastInvokeEnabled;
+
+ internal static bool FastInvokeEnabled => _fastInvokeEnabled;
///
- /// Enables the use of the fast APIs globally when available (Invoke methods of IJSInProcessRuntime).
+ /// Enables the synchronous in-process ("fast") invoke path for the APIs that opt into it.
+ ///
+ /// Only APIs backed by synchronous JavaScript functions (for example ,
+ /// , , and )
+ /// use this path; everything that wraps an asynchronous (Promise-returning) browser API always runs
+ /// asynchronously regardless of this setting, so enabling it can't break those calls.
+ /// Only effective on Blazor WebAssembly (where an is available).
+ ///
+ /// NOTE: this is a process-wide static toggle, not per-app/per-circuit. It is intended to be set
+ /// once at startup. On Blazor Server it is effectively a no-op (the fast path always falls back to
+ /// the async path because there is no in-process runtime), so sharing it across circuits is benign.
///
public static void UseFastInvoke()
{
- FastInvokeEnabled = true;
+ _fastInvokeEnabled = true;
}
///
- /// Disables the use of the fast APIs globally when available (Invoke methods of IJSInProcessRuntime).
+ /// Disables the synchronous in-process ("fast") invoke path; all calls run asynchronously.
+ /// Process-wide static toggle - see .
///
public static void UseNormalInvoke()
{
- FastInvokeEnabled = false;
+ _fastInvokeEnabled = false;
}
}
diff --git a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs
index 1d6445af61..41eaf462ad 100644
--- a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs
+++ b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs
@@ -1,8 +1,9 @@
using System;
-using System.Threading;
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using System.Threading;
using System.Threading.Tasks;
-using System.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop;
using static Bit.Butil.LinkerFlags;
@@ -10,23 +11,61 @@ namespace Bit.Butil;
internal static class InternalJSRuntimeExtensions
{
+ ///
+ /// Invokes a void JavaScript function through the safe async path.
+ ///
+ ///
+ /// During static SSR / pre-render (when no real JS runtime is available) this is a no-op:
+ /// it returns a completed without calling into JS, so callers don't
+ /// have to special-case prerender. See .
+ ///
internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
{
return InvokeVoid(jsRuntime, identifier, CancellationToken.None, args);
}
- internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
+ internal static async ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
{
+ // This method must stay async: the CancellationTokenSource's internal timer is what
+ // enforces the timeout, and it must remain alive (undisposed) until the JS call
+ // completes. Returning the ValueTask from a non-async method would dispose the CTS
+ // immediately, cancelling its timer and silently defeating the timeout.
using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout);
var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None;
- return InvokeVoid(jsRuntime, identifier, cancellationToken, args);
+ await InvokeVoid(jsRuntime, identifier, cancellationToken, args);
}
internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args)
{
if (jsRuntime.IsJsRuntimeInvalid()) return default;
+ // Always the safe async path. The synchronous in-process ("fast") path is only valid
+ // for JS functions that are synchronous; using it for a Promise-returning function
+ // either throws on deserialization or silently fires-and-forgets. Callers that know
+ // their JS function is synchronous opt in via InvokeVoidFast.
+ return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args);
+ }
+
+ ///
+ /// Opt-in fast invoke for VOID calls. Honors and,
+ /// when running under an (Blazor WebAssembly), calls the
+ /// JS function synchronously.
+ ///
+ /// IMPORTANT: only use this for JS functions that are genuinely synchronous (no Promise).
+ /// Using it for an async JS function loses awaiting and error propagation.
+ ///
+ internal static ValueTask InvokeVoidFast(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
+ {
+ return InvokeVoidFast(jsRuntime, identifier, CancellationToken.None, args);
+ }
+
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "The fast path forwards to FastInvokeVoidAsync (annotated [RequiresUnreferencedCode]) but only ever passes trim-safe primitives from the opted-in synchronous APIs. The real protection - the attribute - stays on the public FastInvoke* surface so a trimming/AOT consumer still gets the warning at their call site; this suppresses only the redundant internal propagation.")]
+ internal static ValueTask InvokeVoidFast(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args)
+ {
+ if (jsRuntime.IsJsRuntimeInvalid()) return default;
+
return BitButil.FastInvokeEnabled
? jsRuntime.FastInvokeVoidAsync(identifier, cancellationToken, args)
: jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args);
@@ -34,22 +73,96 @@ internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifie
+ ///
+ /// Invokes a value-returning JavaScript function through the safe async path.
+ ///
+ ///
+ /// The deserialized result, or a safe default during static SSR / pre-render when no JS runtime
+ /// is available. The safe default is an empty string for , an empty array for
+ /// array types, and default() for everything else.
+ ///
+ ///
+ /// IMPORTANT: because prerender returns a safe default (e.g. "", [], false,
+ /// 0) instead of throwing, a caller can't distinguish a genuine value from "the runtime
+ /// wasn't available". Code that branches on the result should treat the prerender pass accordingly
+ /// (for example, by deferring the read to OnAfterRender). See .
+ ///
internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
{
return Invoke(jsRuntime, identifier, CancellationToken.None, args);
}
- internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
+ internal static async ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
{
+ // Async on purpose - see the note on the InvokeVoid timeout overload: the CTS timer
+ // must outlive the call, which only happens if we await inside the using scope.
using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout);
var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None;
- return Invoke(jsRuntime, identifier, cancellationToken, args);
+ return await Invoke(jsRuntime, identifier, cancellationToken, args);
}
internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args)
{
- if (jsRuntime.IsJsRuntimeInvalid()) return default;
+ // Prerender/SSR: no runtime, so hand back a safe default rather than throwing. For string
+ // and array return types that means an empty string / empty array (never null) so callers
+ // that read in OnInitializedAsync don't hit a NullReferenceException; everything else gets
+ // default(TValue). Callers still can't tell this apart from a real empty/default value -
+ // documented on the params-based overload.
+ if (jsRuntime.IsJsRuntimeInvalid()) return SafeDefault();
+
+ // Always the safe async path - see the note on InvokeVoid. Callers whose JS function is
+ // synchronous opt in via InvokeFast.
+ return jsRuntime.InvokeAsync(identifier, cancellationToken, args);
+ }
+
+ ///
+ /// The value handed back when the runtime is invalid (prerender/SSR). Reference types that are
+ /// routinely dereferenced - and arrays - become empty instead of
+ /// null to avoid surprise s; all other types fall
+ /// back to default(TValue). The computed value is cached per
+ /// so repeated prerender calls don't re-run the type inspection / array allocation.
+ ///
+ private static ValueTask SafeDefault() => new(SafeDefaultHolder.Value);
+
+ private static class SafeDefaultHolder
+ {
+ internal static readonly TValue Value = Create();
+
+ [UnconditionalSuppressMessage("Trimming", "IL3050", Justification = "Array.CreateInstance with a concrete element type is AOT-safe; no members are reflected over.")]
+ private static TValue Create()
+ {
+ var type = typeof(TValue);
+
+ if (type == typeof(string))
+ return (TValue)(object)string.Empty;
+
+ if (type.IsArray)
+ return (TValue)(object)Array.CreateInstance(type.GetElementType()!, 0);
+
+ return default!;
+ }
+ }
+
+ ///
+ /// Opt-in fast invoke for value-returning calls. Honors
+ /// and, when running under an (Blazor WebAssembly), calls the
+ /// JS function synchronously.
+ ///
+ /// IMPORTANT: only use this for JS functions that are genuinely synchronous (no Promise).
+ /// Invoking a Promise-returning function this way throws when the result can't be deserialized
+ /// to .
+ ///
+ internal static ValueTask InvokeFast<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, params object?[]? args)
+ {
+ return InvokeFast(jsRuntime, identifier, CancellationToken.None, args);
+ }
+
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "The fast path forwards to FastInvokeAsync (annotated [RequiresUnreferencedCode]) but only ever passes trim-safe primitives from the opted-in synchronous APIs. The real protection - the attribute - stays on the public FastInvoke* surface so a trimming/AOT consumer still gets the warning at their call site; this suppresses only the redundant internal propagation.")]
+ internal static ValueTask InvokeFast<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args)
+ {
+ if (jsRuntime.IsJsRuntimeInvalid()) return SafeDefault();
return BitButil.FastInvokeEnabled
? jsRuntime.FastInvokeAsync(identifier, cancellationToken, args)
@@ -57,19 +170,120 @@ internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifie
}
- [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")]
- internal static bool IsJsRuntimeInvalid(this IJSRuntime jsRuntime)
+ ///
+ /// True for exceptions that are safe to swallow while tearing down a wrapper (its
+ /// DisposeAsync / teardown path). During teardown a JS interop call can surface as more
+ /// than just a :
+ ///
+ /// - the circuit/runtime is already gone.
+ /// (including ,
+ /// which derives from it) - the default invoke path uses a timed
+ /// , so a teardown that races the timeout can cancel.
+ /// - the runtime/circuit has already been disposed
+ /// out from under the call.
+ ///
+ /// None of these are actionable while disposing, so callers can ignore them.
+ ///
+ internal static bool IsIgnorableDisposalException(this Exception exception)
+ => exception is JSDisconnectedException
+ or OperationCanceledException
+ or ObjectDisposedException;
+
+ ///
+ /// Returns true when calling into JavaScript right now would either be impossible
+ /// (no runtime / pre-render) or guaranteed to fail (circuit not yet initialized).
+ ///
+ ///
+ /// There are three runtimes that can reject a JS call even though they look like a normal
+ /// , and each needs a different signal:
+ ///
+ /// UnsupportedJavaScriptRuntime - injected during static SSR / pre-render; it
+ /// throws on every call. Detected purely by type name, so the verdict is stable per type.
+ /// RemoteJSRuntime (Blazor Server) - throws an
+ /// ("JavaScript interop calls cannot be issued at this time…") while the component is being
+ /// prerendered, i.e. while IsInitialized is false. This is NOT a
+ /// , so we must guard it here; otherwise a read in
+ /// OnInitializedAsync that used to yield default would now throw.
+ /// WebViewJSRuntime (Blazor Hybrid) - throws during the window between construction
+ /// and the WebView attaching, i.e. while its _ipcSender is null.
+ ///
+ /// Because IsInitialized/_ipcSender flip from "not ready" to "ready" over the
+ /// lifetime of a single instance, the verdict for those two runtimes can't be cached by type -
+ /// only the classification (which runtime kind this is) is cached. The reflected member accessor
+ /// is cached too, so the hot path is one dictionary lookup plus a single property/field read.
+ ///
+ /// We reflect over a public property (IsInitialized) and a private field
+ /// (_ipcSender) whose internals have shifted across .NET releases. If either ever
+ /// disappears we fail open (treat the runtime as ready) and let any genuine error surface at
+ /// the call site, rather than throwing here or silently swallowing every call.
+ ///
+ private enum JsRuntimeKind : byte
+ {
+ Operational, // Blazor WASM, or any runtime we treat as always-ready
+ Unsupported, // static SSR / pre-render sentinel
+ RemoteServer, // Blazor Server: ready only once the circuit IsInitialized
+ WebViewHybrid // Blazor Hybrid: ready only once the IPC channel is attached
+ }
+
+ private static readonly ConcurrentDictionary RuntimeKindCache = new();
+ private static readonly ConcurrentDictionary IsInitializedCache = new();
+ private static readonly ConcurrentDictionary IpcSenderCache = new();
+
+ // Once a Server/Hybrid runtime instance reports "ready", it never reverts (IsInitialized and
+ // _ipcSender only flip not-ready -> ready over an instance's lifetime). Remembering the ready
+ // verdict per instance lets the hot path short-circuit the per-call reflection read - we only
+ // reflect until the first "ready", then never again for that instance. A ConditionalWeakTable
+ // keys on the runtime instance without keeping it alive.
+ private static readonly System.Runtime.CompilerServices.ConditionalWeakTable ReadyRuntimes = new();
+ private static readonly object ReadyMarker = new();
+
+ [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflected members belong to framework JS runtime types that are always present at runtime; we fail open if a member is trimmed/renamed.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflected members belong to framework JS runtime types that are always present at runtime; we fail open if a member is trimmed/renamed.")]
+ internal static bool IsJsRuntimeInvalid(this IJSRuntime? jsRuntime)
{
- if (jsRuntime is null) return false;
+ if (jsRuntime is null) return true;
var type = jsRuntime.GetType();
- return type.Name switch
+ var kind = RuntimeKindCache.GetOrAdd(type, static t => t.Name switch
+ {
+ "UnsupportedJavaScriptRuntime" => JsRuntimeKind.Unsupported, // Prerendering
+ "RemoteJSRuntime" => JsRuntimeKind.RemoteServer, // Blazor Server
+ "WebViewJSRuntime" => JsRuntimeKind.WebViewHybrid, // Blazor Hybrid
+ _ => JsRuntimeKind.Operational // Blazor WASM
+ });
+
+ switch (kind)
{
- "UnsupportedJavaScriptRuntime" => true, // Prerendering
- "RemoteJSRuntime" => (bool)type.GetProperty("IsInitialized")!.GetValue(jsRuntime)! is false, // Blazor server
- "WebViewJSRuntime" => type.GetField("_ipcSender", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(jsRuntime) is null, // Blazor Hybrid
- _ => false // Blazor WASM
- };
+ case JsRuntimeKind.Unsupported:
+ return true;
+
+ case JsRuntimeKind.RemoteServer:
+ case JsRuntimeKind.WebViewHybrid:
+ // If we've already seen this instance become ready, skip the reflection entirely.
+ if (ReadyRuntimes.TryGetValue(jsRuntime, out _)) return false;
+
+ bool ready;
+ if (kind == JsRuntimeKind.RemoteServer)
+ {
+ // Server circuit is unusable until IsInitialized becomes true (after prerender).
+ var isInitialized = IsInitializedCache.GetOrAdd(type,
+ static t => t.GetProperty("IsInitialized", BindingFlags.Public | BindingFlags.Instance));
+ ready = isInitialized?.GetValue(jsRuntime) is not false;
+ }
+ else
+ {
+ // Hybrid runtime is unusable until the WebView attaches its IPC sender.
+ var ipcSender = IpcSenderCache.GetOrAdd(type,
+ static t => t.GetField("_ipcSender", BindingFlags.NonPublic | BindingFlags.Instance));
+ ready = ipcSender is null || ipcSender.GetValue(jsRuntime) is not null;
+ }
+
+ if (ready) ReadyRuntimes.AddOrUpdate(jsRuntime, ReadyMarker);
+ return ready is false;
+
+ default:
+ return false;
+ }
}
}
diff --git a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs
index d5970ea7be..2e41d5c2ab 100644
--- a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs
+++ b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs
@@ -1,6 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.JSInterop;
@@ -35,12 +34,15 @@ public static class JSRuntimeExtensions
/// JSON-serializable arguments.
/// An instance of obtained by JSON-deserializing the return value.
[RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")]
- public static ValueTask FastInvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TResult>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
+ public static async ValueTask FastInvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TResult>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
{
+ // Must be async: the CancellationTokenSource's timer enforces the timeout and has to
+ // remain alive until the call finishes. Disposing it synchronously (as a non-async
+ // method would) cancels the timer and defeats the timeout.
using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout);
var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None;
- return FastInvokeAsync(jsRuntime, identifier, cancellationToken, args);
+ return await FastInvokeAsync(jsRuntime, identifier, cancellationToken, args);
}
///
@@ -60,20 +62,14 @@ public static class JSRuntimeExtensions
{
if (jsRuntime is IJSInProcessRuntime jsInProcessRuntime)
{
- try
- {
- return ValueTask.FromResult(jsInProcessRuntime.Invoke(identifier, args));
- }
- catch (JsonException ex)
- {
- System.Console.Error.WriteLine($"Error invoking '{identifier}' using {nameof(IJSInProcessRuntime)}. A JSON-related issue occurred: {ex.Message}.");
- return ValueTask.FromResult(default(TResult)!);
- }
- }
- else
- {
- return jsRuntime.InvokeAsync(identifier, cancellationToken, args);
+ // We deliberately do not catch JsonException here. Calling the synchronous
+ // Invoke against a JS function that returns a Promise produces a JSON
+ // payload that cannot deserialize to TResult; surfacing the error makes the
+ // mistake visible instead of silently returning default(TResult).
+ return ValueTask.FromResult(jsInProcessRuntime.Invoke(identifier, args));
}
+
+ return jsRuntime.InvokeAsync(identifier, cancellationToken, args);
}
@@ -95,12 +91,13 @@ public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string id
/// The duration after which to cancel the async operation. Overrides default timeouts ().
/// JSON-serializable arguments.
[RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")]
- public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
+ public static async ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args)
{
+ // Async on purpose - the CTS timer must outlive the call for the timeout to fire.
using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout);
var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None;
- return FastInvokeVoidAsync(jsRuntime, identifier, cancellationToken, args);
+ await FastInvokeVoidAsync(jsRuntime, identifier, cancellationToken, args);
}
///
@@ -117,21 +114,12 @@ public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string id
{
if (jsRuntime is IJSInProcessRuntime jsInProcessRuntime)
{
- try
- {
- jsInProcessRuntime.Invoke(identifier, args);
- return ValueTask.CompletedTask;
- }
- catch (JsonException ex)
- {
- System.Console.Error.WriteLine($"Error invoking '{identifier}' using {nameof(IJSInProcessRuntime)}. A JSON-related issue occurred: {ex.Message}.");
- return ValueTask.CompletedTask;
- }
- }
- else
- {
- return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args);
+ // Don't swallow JsonException - see FastInvokeAsync for rationale.
+ jsInProcessRuntime.Invoke(identifier, args);
+ return ValueTask.CompletedTask;
}
+
+ return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args);
}
diff --git a/src/Butil/Bit.Butil/Internals/DotNetObjectReferenceHelper.cs b/src/Butil/Bit.Butil/Internals/DotNetObjectReferenceHelper.cs
new file mode 100644
index 0000000000..32ebff155c
--- /dev/null
+++ b/src/Butil/Bit.Butil/Internals/DotNetObjectReferenceHelper.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using Microsoft.JSInterop;
+
+namespace Bit.Butil;
+
+///
+/// Thread-safe lazy creation of a per-instance .
+///
+///
+/// The previous _dotNetRef ??= DotNetObjectReference.Create(this) idiom is a non-atomic
+/// read-modify-write. Under the classic single-threaded Blazor circuit / sync-context model that is
+/// fine, but multithreaded WebAssembly runtimes can run two callers concurrently, in which case two
+/// references would be created and one would leak (never disposed). This helper publishes exactly one
+/// reference via and disposes the redundant
+/// one created by any racing loser, so the field always holds a single, correctly-tracked reference.
+///
+internal static class DotNetObjectReferenceHelper
+{
+ internal static DotNetObjectReference GetOrCreate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TValue>(ref DotNetObjectReference? field, TValue value)
+ where TValue : class
+ {
+ var existing = Volatile.Read(ref field);
+ if (existing is not null) return existing;
+
+ var created = DotNetObjectReference.Create(value);
+
+ // Publish only if the field is still null; otherwise another thread won the race.
+ var winner = Interlocked.CompareExchange(ref field, created, null);
+ if (winner is null) return created;
+
+ // Lost the race: drop our redundant reference so it doesn't leak, and use the winner's.
+ created.Dispose();
+ return winner;
+ }
+}
diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs
index 8239275e5c..cf9deaac88 100644
--- a/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs
+++ b/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs
@@ -8,10 +8,73 @@ internal static Type TypeOf(string domEvent)
{
return domEvent switch
{
+ // Mouse
ButilEvents.Click => typeof(ButilMouseEventArgs),
+ ButilEvents.DblClick => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseDown => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseUp => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseMove => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseEnter => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseLeave => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseOver => typeof(ButilMouseEventArgs),
+ ButilEvents.MouseOut => typeof(ButilMouseEventArgs),
+ ButilEvents.ContextMenu => typeof(ButilMouseEventArgs),
+
+ // Keyboard
ButilEvents.KeyDown => typeof(ButilKeyboardEventArgs),
ButilEvents.KeyUp => typeof(ButilKeyboardEventArgs),
ButilEvents.KeyPress => typeof(ButilKeyboardEventArgs),
+
+ // Pointer
+ ButilEvents.PointerDown => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerUp => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerMove => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerEnter => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerLeave => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerOver => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerOut => typeof(ButilPointerEventArgs),
+ ButilEvents.PointerCancel => typeof(ButilPointerEventArgs),
+ ButilEvents.GotPointerCapture => typeof(ButilPointerEventArgs),
+ ButilEvents.LostPointerCapture => typeof(ButilPointerEventArgs),
+
+ // Touch
+ ButilEvents.TouchStart => typeof(ButilTouchEventArgs),
+ ButilEvents.TouchEnd => typeof(ButilTouchEventArgs),
+ ButilEvents.TouchMove => typeof(ButilTouchEventArgs),
+ ButilEvents.TouchCancel => typeof(ButilTouchEventArgs),
+
+ // Wheel
+ ButilEvents.Wheel => typeof(ButilWheelEventArgs),
+
+ // Focus
+ ButilEvents.Focus => typeof(ButilFocusEventArgs),
+ ButilEvents.Blur => typeof(ButilFocusEventArgs),
+ ButilEvents.FocusIn => typeof(ButilFocusEventArgs),
+ ButilEvents.FocusOut => typeof(ButilFocusEventArgs),
+
+ // Input
+ ButilEvents.Input => typeof(ButilInputEventArgs),
+ ButilEvents.BeforeInput => typeof(ButilInputEventArgs),
+
+ // Drag
+ ButilEvents.DragStart => typeof(ButilDragEventArgs),
+ ButilEvents.Drag => typeof(ButilDragEventArgs),
+ ButilEvents.DragEnd => typeof(ButilDragEventArgs),
+ ButilEvents.DragEnter => typeof(ButilDragEventArgs),
+ ButilEvents.DragLeave => typeof(ButilDragEventArgs),
+ ButilEvents.DragOver => typeof(ButilDragEventArgs),
+ ButilEvents.Drop => typeof(ButilDragEventArgs),
+
+ // Clipboard
+ ButilEvents.Copy => typeof(ButilClipboardEventArgs),
+ ButilEvents.Cut => typeof(ButilClipboardEventArgs),
+ ButilEvents.Paste => typeof(ButilClipboardEventArgs),
+
+ // Composition
+ ButilEvents.CompositionStart => typeof(ButilCompositionEventArgs),
+ ButilEvents.CompositionUpdate => typeof(ButilCompositionEventArgs),
+ ButilEvents.CompositionEnd => typeof(ButilCompositionEventArgs),
+
_ => typeof(object),
};
}
diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs
deleted file mode 100644
index 27c62b2e69..0000000000
--- a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using System.Diagnostics.CodeAnalysis;
-using Microsoft.JSInterop;
-
-namespace Bit.Butil;
-
-internal static class DomEventDispatcher
-{
- private static readonly object FalseUseCapture = false;
- private static readonly object TrueUseCapture = true;
-
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilMouseEventArgs))]
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilKeyboardEventArgs))]
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomEventListenersManager))]
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomMouseEventListenersManager))]
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomKeyboardEventListenersManager))]
- internal static async Task AddEventListener(IJSRuntime js,
- string elementName,
- string domEvent,
- Action listener,
- bool useCapture = false,
- bool preventDefault = false,
- bool stopPropagation = false)
- {
- var argType = typeof(T);
- var eventType = DomEventArgs.TypeOf(domEvent);
-
- if (argType != eventType)
- throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})");
-
- string[] args = [];
- var methodName = "";
- var id = Guid.NewGuid();
- var options = useCapture ? TrueUseCapture : FalseUseCapture;
-
- if (argType == typeof(ButilKeyboardEventArgs))
- {
- args = ButilKeyboardEventArgs.EventArgsMembers;
- methodName = DomKeyboardEventListenersManager.InvokeMethodName;
- var action = listener as Action;
- id = DomKeyboardEventListenersManager.SetListener(action!, elementName, options);
- }
- else if (argType == typeof(ButilMouseEventArgs))
- {
- args = ButilMouseEventArgs.EventArgsMembers;
- methodName = DomMouseEventListenersManager.InvokeMethodName;
- var action = listener as Action;
- id = DomMouseEventListenersManager.SetListener(action!, elementName, options);
- }
- else
- {
- methodName = DomEventListenersManager.InvokeMethodName;
- var action = listener as Action
public async Task Assert(bool? condition, params object?[]? args)
- => await js.InvokeVoid("BitButil.console.assert", [condition, .. args]);
+ => await js.InvokeVoidFast("BitButil.console.assert", [condition, .. args ?? []]);
///
/// Clear the console.
@@ -19,7 +19,7 @@ public async Task Assert(bool? condition, params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/clear_static
///
public async Task Clear()
- => await js.InvokeVoid("BitButil.console.clear");
+ => await js.InvokeVoidFast("BitButil.console.clear");
///
/// Log the number of times this line has been called with the given label.
@@ -27,8 +27,8 @@ public async Task Clear()
/// https://developer.mozilla.org/en-US/docs/Web/API/console/count_static
///
public async Task Count(string? label = null)
- => await (label is null ? js.InvokeVoid("BitButil.console.count")
- : js.InvokeVoid("BitButil.console.count", label));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.count")
+ : js.InvokeVoidFast("BitButil.console.count", label));
///
/// Resets the value of the counter with the given label.
@@ -36,8 +36,8 @@ public async Task Count(string? label = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/countreset_static
///
public async Task CountReset(string? label = null)
- => await (label is null ? js.InvokeVoid("BitButil.console.countReset")
- : js.InvokeVoid("BitButil.console.countReset", label));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.countReset")
+ : js.InvokeVoidFast("BitButil.console.countReset", label));
///
/// Outputs a message to the console with the log level debug.
@@ -45,7 +45,7 @@ public async Task CountReset(string? label = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/debug_static
///
public async Task Debug(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.debug", args);
+ => await js.InvokeVoidFast("BitButil.console.debug", args);
///
/// Displays an interactive listing of the properties of a specified JavaScript object.
@@ -54,7 +54,7 @@ public async Task Debug(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/dir_static
///
public async Task Dir(object? item, object? options = null)
- => await js.InvokeVoid("BitButil.console.dir", item, options);
+ => await js.InvokeVoidFast("BitButil.console.dir", item, options);
///
/// Displays an XML/HTML Element representation of the specified object if possible
@@ -63,7 +63,7 @@ public async Task Dir(object? item, object? options = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/dirxml_static
///
public async Task Dirxml(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.dirxml", args);
+ => await js.InvokeVoidFast("BitButil.console.dirxml", args);
///
/// Outputs an error message. You may use string substitution and additional arguments with this method.
@@ -71,7 +71,7 @@ public async Task Dirxml(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/error_static
///
public async Task Error(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.error", args);
+ => await js.InvokeVoidFast("BitButil.console.error", args);
///
/// Creates a new inline group, indenting all following output by another level.
@@ -80,7 +80,7 @@ public async Task Error(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/group_static
///
public async Task Group(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.group", args);
+ => await js.InvokeVoidFast("BitButil.console.group", args);
///
/// Creates a new inline group, indenting all following output by another level. However, unlike console.group()
@@ -90,7 +90,7 @@ public async Task Group(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/groupcollapsed_static
///
public async Task GroupCollapsed(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.groupCollapsed", args);
+ => await js.InvokeVoidFast("BitButil.console.groupCollapsed", args);
///
/// Exits the current inline group.
@@ -98,7 +98,7 @@ public async Task GroupCollapsed(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/groupend_static
///
public async Task GroupEnd()
- => await js.InvokeVoid("BitButil.console.groupEnd");
+ => await js.InvokeVoidFast("BitButil.console.groupEnd");
///
/// Informative logging of information. You may use string substitution and additional arguments with this method.
@@ -106,7 +106,7 @@ public async Task GroupEnd()
/// https://developer.mozilla.org/en-US/docs/Web/API/console/info_static
///
public async Task Info(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.info", args);
+ => await js.InvokeVoidFast("BitButil.console.info", args);
///
/// For general output of logging information. You may use string substitution and additional arguments with this method.
@@ -114,7 +114,7 @@ public async Task Info(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/log_static
///
public async Task Log(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.log", args);
+ => await js.InvokeVoidFast("BitButil.console.log", args);
///
/// Starts the browser's built-in profiler (for example, the Firefox performance tool).
@@ -123,8 +123,8 @@ public async Task Log(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/profile_static
///
public async Task Profile(string? name = null)
- => await (name is null ? js.InvokeVoid("BitButil.console.profile")
- : js.InvokeVoid("BitButil.console.profile", name));
+ => await (name is null ? js.InvokeVoidFast("BitButil.console.profile")
+ : js.InvokeVoidFast("BitButil.console.profile", name));
///
/// Stops the profiler. You can see the resulting profile in the browser's performance tool
@@ -133,8 +133,8 @@ public async Task Profile(string? name = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/profileend_static
///
public async Task ProfileEnd(string? name = null)
- => await (name is null ? js.InvokeVoid("BitButil.console.profileEnd")
- : js.InvokeVoid("BitButil.console.profileEnd", name));
+ => await (name is null ? js.InvokeVoidFast("BitButil.console.profileEnd")
+ : js.InvokeVoidFast("BitButil.console.profileEnd", name));
///
/// Displays tabular data as a table.
@@ -142,8 +142,8 @@ public async Task ProfileEnd(string? name = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/table_static
///
public async Task Table(object? data, object? properties = null)
- => await (properties is null ? js.InvokeVoid("BitButil.console.table", data)
- : js.InvokeVoid("BitButil.console.table", data, properties));
+ => await (properties is null ? js.InvokeVoidFast("BitButil.console.table", data)
+ : js.InvokeVoidFast("BitButil.console.table", data, properties));
///
/// Starts a timer with a name specified as an input parameter. Up to 10,000 simultaneous timers can run on a given page.
@@ -151,8 +151,8 @@ public async Task Table(object? data, object? properties = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/time_static
///
public async Task Time(string? label = null)
- => await (label is null ? js.InvokeVoid("BitButil.console.time")
- : js.InvokeVoid("BitButil.console.time", label));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.time")
+ : js.InvokeVoidFast("BitButil.console.time", label));
///
/// Stops the specified timer and logs the elapsed time in milliseconds since it started.
@@ -160,8 +160,8 @@ public async Task Time(string? label = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/timeend_static
///
public async Task TimeEnd(string? label = null)
- => await (label is null ? js.InvokeVoid("BitButil.console.timeEnd")
- : js.InvokeVoid("BitButil.console.timeEnd", label));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.timeEnd")
+ : js.InvokeVoidFast("BitButil.console.timeEnd", label));
///
/// Logs the value of the specified timer to the console.
@@ -169,8 +169,8 @@ public async Task TimeEnd(string? label = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/timelog_static
///
public async Task TimeLog(string? label = null, params object?[]? args)
- => await (label is null ? js.InvokeVoid("BitButil.console.timeLog")
- : js.InvokeVoid("BitButil.console.timeLog", [label, .. args]));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.timeLog", args ?? [])
+ : js.InvokeVoidFast("BitButil.console.timeLog", [label, .. args ?? []]));
///
/// Adds a marker to the browser performance tool's timeline.
@@ -178,8 +178,8 @@ public async Task TimeLog(string? label = null, params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/timestamp_static
///
public async Task TimeStamp(string? label = null)
- => await (label is null ? js.InvokeVoid("BitButil.console.timeStamp")
- : js.InvokeVoid("BitButil.console.timeStamp", label));
+ => await (label is null ? js.InvokeVoidFast("BitButil.console.timeStamp")
+ : js.InvokeVoidFast("BitButil.console.timeStamp", label));
///
/// Outputs a stack trace.
@@ -187,7 +187,7 @@ public async Task TimeStamp(string? label = null)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/trace_static
///
public async Task Trace(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.trace", args);
+ => await js.InvokeVoidFast("BitButil.console.trace", args);
///
/// Outputs a warning message.
@@ -195,5 +195,5 @@ public async Task Trace(params object?[]? args)
/// https://developer.mozilla.org/en-US/docs/Web/API/console/warn_static
///
public async Task Warn(params object?[]? args)
- => await js.InvokeVoid("BitButil.console.warn", args);
+ => await js.InvokeVoidFast("BitButil.console.warn", args);
}
diff --git a/src/Butil/Bit.Butil/Publics/ContactPicker.cs b/src/Butil/Bit.Butil/Publics/ContactPicker.cs
new file mode 100644
index 0000000000..f42ea62700
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/ContactPicker.cs
@@ -0,0 +1,45 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Microsoft.JSInterop;
+
+namespace Bit.Butil;
+
+///
+/// Wraps the Contact Picker API
+/// (navigator.contacts).
+///
+///
+/// Available on Chromium-based mobile browsers only. Users always see a native picker
+/// - there's no programmatic access to a user's contacts.
+///
+public class ContactPicker(IJSRuntime js)
+{
+ /// True when the runtime exposes navigator.contacts.
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask IsSupported() => js.Invoke("BitButil.contactPicker.isSupported");
+
+ ///
+ /// Returns the list of properties the platform can expose. Common values: "name",
+ /// "email", "tel", "address", "icon".
+ ///
+ public ValueTask GetProperties() => js.Invoke("BitButil.contactPicker.getProperties");
+
+ ///
+ /// Opens the contact picker and returns the user's selection. Must be invoked from a
+ /// user-gesture handler.
+ ///
+ /// Subset of . Defaults to name/email/tel.
+ /// When true, the user can pick more than one contact.
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ContactInfo))]
+ public ValueTask Select(string[]? properties = null, bool multiple = false)
+ {
+ // Treat null *and* empty as "use the default set". An empty array is truthy in JS, so the
+ // TS side can't fall back on its own - normalize it here so the two defaults stay in sync.
+ var props = properties is { Length: > 0 } ? properties : new[] { "name", "email", "tel" };
+ return js.Invoke("BitButil.contactPicker.select", props, multiple);
+ }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs b/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs
new file mode 100644
index 0000000000..646a0d8817
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs
@@ -0,0 +1,22 @@
+namespace Bit.Butil;
+
+///
+/// One result from . All collections are arrays so the
+/// shape is friendly to common UI consumption.
+///
+public class ContactInfo
+{
+ public string[] Name { get; set; } = [];
+ public string[] Email { get; set; } = [];
+ public string[] Tel { get; set; } = [];
+
+ /// Postal addresses serialized as plain strings.
+ public string[] Address { get; set; } = [];
+
+ ///
+ /// Avatar images as self-contained data: URLs (base64), if exposed by the platform.
+ /// These are inline payloads with no lifetime to manage - unlike object URLs they don't need
+ /// to be revoked.
+ ///
+ public string[] Icon { get; set; } = [];
+}
diff --git a/src/Butil/Bit.Butil/Publics/Cookie.cs b/src/Butil/Bit.Butil/Publics/Cookie.cs
index 09551d7948..5683bfb4b4 100644
--- a/src/Butil/Bit.Butil/Publics/Cookie.cs
+++ b/src/Butil/Bit.Butil/Publics/Cookie.cs
@@ -1,4 +1,5 @@
-using System.Linq;
+using System;
+using System.Linq;
using System.Threading.Tasks;
using Microsoft.JSInterop;
@@ -15,15 +16,36 @@ public class Cookie(IJSRuntime js)
///
/// Gets all cookies registered on the current document.
///
+ ///
+ /// The browser's document.cookie API exposes only name=value pairs, so each
+ /// returned has only its and
+ /// populated. Attributes such as ,
+ /// , , ,
+ /// , and
+ /// are never returned by the browser and will be at their
+ /// default values regardless of how the cookie was originally set. HttpOnly cookies are not
+ /// visible at all.
+ ///
public async Task GetAll()
{
- var cookie = await js.Invoke("BitButil.cookie.get");
- return cookie.Split(';').Select(ButilCookie.Parse).ToArray();
+ var raw = await js.InvokeFast("BitButil.cookie.get");
+
+ if (string.IsNullOrWhiteSpace(raw)) return [];
+
+ return raw.Split(';', StringSplitOptions.RemoveEmptyEntries)
+ .Select(ButilCookie.Parse)
+ .Where(c => c is not null)
+ .Select(c => c!)
+ .ToArray();
}
///
/// Returns a cookie by providing the cookie name.
///
+ ///
+ /// Only and are populated; see
+ /// for why the other attributes can't be read back from the browser.
+ ///
public async Task Get(string name)
{
var allCookies = await GetAll();
@@ -62,5 +84,5 @@ public async Task Remove(ButilCookie cookie)
/// Sets a cookie.
///
public async Task Set(ButilCookie cookie)
- => await js.InvokeVoid("BitButil.cookie.set", cookie.ToString());
+ => await js.InvokeVoidFast("BitButil.cookie.set", cookie.ToString());
}
diff --git a/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs b/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs
index e7ae67a656..fcb84c4995 100644
--- a/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs
+++ b/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Text;
namespace Bit.Butil;
@@ -17,25 +18,36 @@ public class ButilCookie
public override string ToString()
{
- if (Name is null) return string.Empty;
+ if (string.IsNullOrEmpty(Name)) return string.Empty;
var sb = new StringBuilder();
- sb.Append($"{Name}={Value}");
+ // Per RFC 6265, name and value must be encoded so that reserved characters
+ // (=, ;, ,, whitespace, non-ASCII) don't break the cookie. Uri.EscapeDataString matches
+ // the browser's encodeURIComponent semantics (e.g. space -> %20, not '+'), so cookies
+ // round-trip correctly with values written/read by JS or the server.
+ sb.Append(Uri.EscapeDataString(Name));
+ sb.Append('=');
+ if (Value is not null)
+ {
+ sb.Append(Uri.EscapeDataString(Value));
+ }
if (Domain is not null)
{
- sb.Append($";domain={Domain}");
+ sb.Append(";domain=").Append(ValidateAttribute(Domain, nameof(Domain)));
}
if (Expires is not null)
{
- sb.Append($";expires={Expires.Value.UtcDateTime.ToString("ddd, MMM dd yyyy HH:mm:ss \"GMT\"")}");
+ // RFC 1123 / RFC 7231 IMF-fixdate: e.g. "Wed, 21 Oct 2015 07:28:00 GMT".
+ sb.Append(";expires=")
+ .Append(Expires.Value.UtcDateTime.ToString("R", CultureInfo.InvariantCulture));
}
if (MaxAge is not null)
{
- sb.Append($";max-age={MaxAge}");
+ sb.Append(";max-age=").Append(MaxAge.Value.ToString(CultureInfo.InvariantCulture));
}
if (Partitioned)
@@ -45,12 +57,12 @@ public override string ToString()
if (Path is not null)
{
- sb.Append($";path={Path}");
+ sb.Append(";path=").Append(ValidateAttribute(Path, nameof(Path)));
}
if (SameSite is not null)
{
- sb.Append($";samesite={SameSite.ToString()!.ToLowerInvariant()}");
+ sb.Append(";samesite=").Append(SameSite.ToString()!.ToLowerInvariant());
}
if (Secure)
@@ -61,15 +73,35 @@ public override string ToString()
return sb.ToString();
}
- public static ButilCookie Parse(string rawCookie)
+ private static string ValidateAttribute(string value, string attributeName)
+ {
+ // Name and value are percent-encoded, but attributes like domain/path are appended
+ // verbatim. Reject the separators (';' splits attributes, CR/LF could inject headers)
+ // so a caller-supplied value can't smuggle extra cookie attributes.
+ if (value.IndexOfAny([';', '\r', '\n']) >= 0)
+ throw new FormatException(
+ $"Cookie '{attributeName}' contains an invalid character (';', CR or LF): '{value}'.");
+
+ return value;
+ }
+
+ public static ButilCookie? Parse(string rawCookie)
{
- var cookie = new ButilCookie();
- if (rawCookie.Contains('='))
+ if (string.IsNullOrWhiteSpace(rawCookie)) return null;
+
+ var trimmed = rawCookie.Trim();
+ var eqIndex = trimmed.IndexOf('=');
+
+ // A cookie with no '=' or with an empty name is not valid; skip it.
+ if (eqIndex <= 0) return null;
+
+ var name = trimmed.Substring(0, eqIndex).Trim();
+ var value = trimmed.Substring(eqIndex + 1).Trim();
+
+ return new ButilCookie
{
- var split = rawCookie.Split('=');
- cookie.Name = split[0].Trim();
- cookie.Value = split[1].Trim();
- }
- return cookie;
+ Name = Uri.UnescapeDataString(name),
+ Value = Uri.UnescapeDataString(value),
+ };
}
}
diff --git a/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs b/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs
new file mode 100644
index 0000000000..44999c472b
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Cookie returned by CookieStore.
+/// Unlike , this carries all attributes the browser knows.
+///
+public class CookieStoreItem
+{
+ public string Name { get; set; } = string.Empty;
+ public string Value { get; set; } = string.Empty;
+ public string? Domain { get; set; }
+ public string? Path { get; set; }
+
+ /// Expiration time. Null for session cookies.
+ public DateTimeOffset? Expires { get; set; }
+
+ public bool Secure { get; set; }
+
+ /// One of "strict", "lax", "none", or null.
+ public string? SameSite { get; set; }
+
+ public bool? Partitioned { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/CookieStore.cs b/src/Butil/Bit.Butil/Publics/CookieStore.cs
new file mode 100644
index 0000000000..95f7d6f33c
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/CookieStore.cs
@@ -0,0 +1,39 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Microsoft.JSInterop;
+
+namespace Bit.Butil;
+
+///
+/// Wraps the modern async CookieStore API.
+///
+///
+/// The legacy service still works on every browser, but it can only see Name/Value
+/// because document.cookie doesn't expose other attributes. Use this service when you need the full
+/// metadata (Domain/Path/Expires/SameSite). Browser support is Chromium-only at the time of writing.
+///
+public class CookieStore(IJSRuntime js)
+{
+ /// True when the runtime exposes cookieStore.
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask IsSupported() => js.Invoke("BitButil.cookieStore.isSupported");
+
+ /// Returns every cookie visible to the current document.
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))]
+ public ValueTask GetAll() => js.Invoke("BitButil.cookieStore.getAll");
+
+ /// Returns the cookie with the given name, or null when absent.
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))]
+ public ValueTask Get(string name) => js.Invoke("BitButil.cookieStore.get", name);
+
+ /// Sets a cookie. Use to remove one (don't pass MaxAge=0 - that's the legacy trick).
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))]
+ public ValueTask Set(CookieStoreItem cookie) => js.InvokeVoid("BitButil.cookieStore.set", cookie);
+
+ /// Deletes the named cookie.
+ public ValueTask Delete(string name) => js.InvokeVoid("BitButil.cookieStore.delete", name);
+}
diff --git a/src/Butil/Bit.Butil/Publics/Crypto.cs b/src/Butil/Bit.Butil/Publics/Crypto.cs
index e28b53fe50..46dac299af 100644
--- a/src/Butil/Bit.Butil/Publics/Crypto.cs
+++ b/src/Butil/Bit.Butil/Publics/Crypto.cs
@@ -1,4 +1,5 @@
-using System.Diagnostics.CodeAnalysis;
+using System;
+using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.JSInterop;
@@ -10,8 +11,186 @@ namespace Bit.Butil;
///
/// More info: https://developer.mozilla.org/en-US/docs/Web/API/Crypto
///
+///
+/// Security note: the key-handling methods on this type marshal raw key material across the
+/// JavaScript↔.NET interop boundary. Generated keys are created as extractable and their
+/// bytes (symmetric raw keys, private pkcs8 keys, and PBKDF2-derived bits) are exported
+/// back to .NET, where they are transferred as base64 over the interop channel and may therefore
+/// appear in interop logs, traces, or memory dumps. They are not retained inside the browser's
+/// non-extractable key store. Treat returned key bytes as sensitive: avoid logging them, zero/clear
+/// buffers when done where practical, and prefer server-side key custody when the threat model
+/// requires keys never to leave a hardware/secure boundary.
+///
public class Crypto(IJSRuntime js)
{
+ ///
+ /// Returns a cryptographically strong random Guid (v4 UUID).
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public async ValueTask RandomUuid()
+ {
+ var raw = await js.Invoke("BitButil.crypto.randomUUID");
+ // During prerender/SSR the invoke returns a safe default (empty string), and a genuine
+ // call always yields a parseable UUID. Guid.Parse(null/"") would throw, contradicting the
+ // documented "returns default rather than throwing" prerender contract - so guard it.
+ return Guid.TryParse(raw, out var uuid) ? uuid : default;
+ }
+
+ ///
+ /// Fills bytes with cryptographically strong random values.
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues
+ ///
+ /// When is negative or above the
+ /// browser's per-call limit (65 536).
+ public ValueTask GetRandomValues(int length)
+ {
+ if (length < 0)
+ throw new ArgumentOutOfRangeException(nameof(length), "length must be non-negative.");
+ if (length > 65536)
+ throw new ArgumentOutOfRangeException(nameof(length), "Web Crypto rejects requests larger than 65 536 bytes.");
+
+ return js.Invoke("BitButil.crypto.getRandomValues", length);
+ }
+
+ ///
+ /// Computes a digest of using the requested algorithm.
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
+ ///
+ public ValueTask Digest(CryptoKeyHash algorithm, byte[] data)
+ {
+ return js.Invoke("BitButil.crypto.digest", HashAlgorithmName(algorithm), data);
+ }
+
+ ///
+ /// Produces an HMAC tag for using the given symmetric key.
+ ///
+ /// SubtleCrypto.sign()
+ ///
+ public ValueTask SignHmac(CryptoKeyHash algorithm, byte[] key, byte[] data)
+ {
+ var algo = HashAlgorithmName(algorithm);
+ return js.Invoke("BitButil.crypto.signHmac", algo, key, data);
+ }
+
+ ///
+ /// Verifies an HMAC tag previously produced by (or any compatible producer).
+ ///
+ /// SubtleCrypto.verify()
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask VerifyHmac(CryptoKeyHash algorithm, byte[] key, byte[] signature, byte[] data)
+ {
+ var algo = HashAlgorithmName(algorithm);
+ return js.Invoke("BitButil.crypto.verifyHmac", algo, key, signature, data);
+ }
+
+ private static string HashAlgorithmName(CryptoKeyHash algorithm) => algorithm switch
+ {
+ CryptoKeyHash.Sha256 => "SHA-256",
+ CryptoKeyHash.Sha384 => "SHA-384",
+ CryptoKeyHash.Sha512 => "SHA-512",
+ // An out-of-range value (only reachable by casting an invalid int to the enum) is a caller
+ // bug. For crypto, fail loudly rather than silently substituting SHA-256.
+ _ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unsupported hash algorithm."),
+ };
+
+ // ─── Key generation / import / export ──────────────────────────────────────
+
+ ///
+ /// Generates a fresh AES key as raw bytes.
+ ///
+ /// Key length in bits - 128, 192, or 256.
+ /// The key is returned as extractable raw bytes - see the security note on .
+ public ValueTask GenerateAesKey(int bits = 256)
+ => js.Invoke("BitButil.crypto.generateAesKey", bits);
+
+ ///
+ /// Generates an HMAC key of the requested length and hash.
+ ///
+ /// The key is returned as extractable raw bytes - see the security note on .
+ public ValueTask GenerateHmacKey(CryptoKeyHash algorithm = CryptoKeyHash.Sha256, int? lengthBits = null)
+ => js.Invoke("BitButil.crypto.generateHmacKey", HashAlgorithmName(algorithm), lengthBits);
+
+ ///
+ /// Generates an RSA key pair (RSA-OAEP). Returns spki/pkcs8 DER bytes for public/private.
+ ///
+ /// The private key is returned as extractable pkcs8 bytes - see the security note on .
+ public ValueTask GenerateRsaKeyPair(int modulusLengthBits = 2048,
+ CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.generateRsaKeyPair", modulusLengthBits, HashAlgorithmName(algorithm));
+
+ ///
+ /// Generates an ECDSA key pair on the named curve.
+ ///
+ /// One of "P-256", "P-384", "P-521".
+ /// The private key is returned as extractable pkcs8 bytes - see the security note on .
+ public ValueTask GenerateEcdsaKeyPair(string curve = "P-256")
+ => js.Invoke("BitButil.crypto.generateEcdsaKeyPair", curve);
+
+ // ─── Derivation ────────────────────────────────────────────────────────────
+
+ ///
+ /// Derives raw bytes from a password using PBKDF2.
+ ///
+ /// The derived bits are returned as raw bytes - see the security note on .
+ public ValueTask DerivePbkdf2(byte[] password, byte[] salt, int iterations,
+ int outputLengthBits, CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.derivePbkdf2", password, salt, iterations, outputLengthBits, HashAlgorithmName(algorithm));
+
+ // ─── RSA-PSS sign / verify ─────────────────────────────────────────────────
+
+ ///
+ /// Produces an RSA-PSS signature using a PKCS8 private key.
+ ///
+ public ValueTask SignRsaPss(byte[] privateKey, byte[] data, int saltLength = 32,
+ CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.signRsaPss", privateKey, data, saltLength, HashAlgorithmName(algorithm));
+
+ ///
+ /// Verifies an RSA-PSS signature using an SPKI public key.
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask VerifyRsaPss(byte[] publicKey, byte[] signature, byte[] data, int saltLength = 32,
+ CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.verifyRsaPss", publicKey, signature, data, saltLength, HashAlgorithmName(algorithm));
+
+ // ─── ECDSA sign / verify ───────────────────────────────────────────────────
+
+ ///
+ /// Produces an ECDSA signature using a PKCS8 private key.
+ ///
+ public ValueTask SignEcdsa(byte[] privateKey, byte[] data, string curve = "P-256",
+ CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.signEcdsa", privateKey, data, curve, HashAlgorithmName(algorithm));
+
+ ///
+ /// Verifies an ECDSA signature using an SPKI public key.
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask VerifyEcdsa(byte[] publicKey, byte[] signature, byte[] data, string curve = "P-256",
+ CryptoKeyHash algorithm = CryptoKeyHash.Sha256)
+ => js.Invoke("BitButil.crypto.verifyEcdsa", publicKey, signature, data, curve, HashAlgorithmName(algorithm));
+
///
/// The Encrypt method of the Crypto interface that encrypts data.
///
@@ -68,6 +247,10 @@ public ValueTask Encrypt(CryptoAlgorithm algorithm, byte[] key, byte[] d
///
/// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/decrypt
///
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AesCtrCryptoAlgorithmParams))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AesCbcCryptoAlgorithmParams))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AesGcmCryptoAlgorithmParams))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(RsaOaepCryptoAlgorithmParams))]
public ValueTask Decrypt(T algorithm, byte[] key, byte[] data, CryptoKeyHash? keyHash = null) where T : ICryptoAlgorithmParams
{
if (algorithm.GetType() == typeof(RsaOaepCryptoAlgorithmParams))
diff --git a/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs b/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs
new file mode 100644
index 0000000000..2c5bcc8e00
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs
@@ -0,0 +1,14 @@
+namespace Bit.Butil;
+
+/// Elliptic curve key pair returned by .
+public class EcKeyPair
+{
+ /// Public key in SubjectPublicKeyInfo (SPKI) DER format.
+ public byte[] PublicKey { get; set; } = [];
+
+ /// Private key in PKCS8 DER format.
+ public byte[] PrivateKey { get; set; } = [];
+
+ /// The named curve (P-256, P-384, P-521).
+ public string Curve { get; set; } = "P-256";
+}
diff --git a/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs b/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs
new file mode 100644
index 0000000000..4dab0973e7
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs
@@ -0,0 +1,11 @@
+namespace Bit.Butil;
+
+/// RSA key pair returned by .
+public class RsaKeyPair
+{
+ /// Public key in SubjectPublicKeyInfo (SPKI) DER format.
+ public byte[] PublicKey { get; set; } = [];
+
+ /// Private key in PKCS8 DER format.
+ public byte[] PrivateKey { get; set; } = [];
+}
diff --git a/src/Butil/Bit.Butil/Publics/Document.cs b/src/Butil/Bit.Butil/Publics/Document.cs
index 17a4a52321..6ac6879608 100644
--- a/src/Butil/Bit.Butil/Publics/Document.cs
+++ b/src/Butil/Bit.Butil/Publics/Document.cs
@@ -1,23 +1,93 @@
using System;
+using System.Collections.Concurrent;
+using System.Linq;
using System.Threading.Tasks;
using Microsoft.JSInterop;
namespace Bit.Butil;
-public class Document(IJSRuntime js)
+public class Document(IJSRuntime js) : IAsyncDisposable
{
private const string ElementName = "document";
+ // Track listener ids registered through this *instance* so dispose actually drains them.
+ private readonly ConcurrentDictionary<(Guid Id, string Event, bool UseCapture), byte> _listenerIds = new();
+
+ // Per-instance DOM event dispatcher: listeners are isolated per circuit / WASM app and released
+ // on disposal - no static state, no cross-circuit leak.
+ private readonly DomEventsInterop _events = new();
+
public async Task AddEventListener(
string domEvent,
Action listener,
bool useCapture = false,
bool preventDefault = false,
bool stopPropagation = false)
- => await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation);
+ {
+ var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation);
+ _listenerIds.TryAdd((id, domEvent, useCapture), 0);
+ }
+ ///
+ /// Removes a listener previously added with .
+ ///
+ ///
+ /// Listeners are matched by delegate identity, so you must pass the very same
+ /// instance that was registered. A newly-created lambda will not
+ /// match and nothing will be removed. For lambdas, prefer ,
+ /// which returns a disposable you can dispose to detach.
+ ///
public async Task RemoveEventListener(string domEvent, Action listener, bool useCapture = false)
- => await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture);
+ {
+ var ids = await _events.RemoveEventListener(js, ElementName, domEvent, listener, useCapture);
+ foreach (var id in ids) _listenerIds.TryRemove((id, domEvent, useCapture), out _);
+ }
+
+ ///
+ /// Subscribe variant of returning an handle.
+ /// Pair with await using to guarantee detachment.
+ ///
+ public async Task SubscribeEvent(
+ string domEvent,
+ Action listener,
+ bool useCapture = false,
+ bool preventDefault = false,
+ bool stopPropagation = false)
+ {
+ var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation);
+ var key = (id, domEvent, useCapture);
+ _listenerIds.TryAdd(key, 0);
+
+ return new ButilSubscription(id, async () =>
+ {
+ _listenerIds.TryRemove(key, out _);
+ await _events.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture);
+ });
+ }
+
+ ///
+ /// variant of ,
+ /// adding passive and once control on top of capture.
+ ///
+ public async Task SubscribeEvent(
+ string domEvent,
+ Action listener,
+ ButilEventListenerOptions options,
+ bool preventDefault = false,
+ bool stopPropagation = false)
+ {
+ var useCapture = options.Capture;
+ var id = await _events.AddEventListener(js, ElementName, domEvent, listener,
+ useCapture, preventDefault, stopPropagation, options.Passive, options.Once);
+ var key = (id, domEvent, useCapture);
+ _listenerIds.TryAdd(key, 0);
+
+ return new ButilSubscription(id, async () =>
+ {
+ _listenerIds.TryRemove(key, out _);
+ await _events.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture);
+ });
+ }
///
/// Returns the character set being used by the document.
@@ -32,6 +102,11 @@ public async Task GetCharacterSet()
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public async Task GetCompatMode()
{
var mode = await js.Invoke("BitButil.document.compatMode");
@@ -63,6 +138,11 @@ public async Task GetDocumentURI()
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Document/designMode
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public async Task GetDesignMode()
{
var mode = await js.Invoke("BitButil.document.getDesignMode");
@@ -85,6 +165,11 @@ public async Task SetDesignMode(DesignMode mode)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Document/dir
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public async Task GetDir()
{
var mode = await js.Invoke("BitButil.document.getDir");
@@ -148,4 +233,197 @@ public async Task ExitFullscreen()
///
public async Task ExitPointerLock()
=> await js.InvokeVoid("BitButil.document.exitPointerLock");
+
+ ///
+ /// Indicates whether the document is currently visible.
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public async Task GetVisibilityState()
+ {
+ var raw = await js.Invoke("BitButil.document.visibilityState");
+ return raw == "hidden" ? VisibilityState.Hidden : VisibilityState.Visible;
+ }
+
+ ///
+ /// True when the document is currently hidden.
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/hidden
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public async Task IsHidden()
+ => await js.Invoke("BitButil.document.hidden");
+
+ ///
+ /// True when the document or any element inside it has focus.
+ ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public async Task HasFocus()
+ => await js.Invoke("BitButil.document.hasFocus");
+
+ ///
+ /// True when the page is restored from a discarded state by the browser
+ /// (e.g. tab was reclaimed under memory pressure and is now being reactivated).
+ ///
+ /// Document.wasDiscarded
+ ///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
+ public ValueTask WasDiscarded() => js.Invoke("BitButil.document.wasDiscarded");
+
+ // ─── Convenience subscription helpers built on SubscribeEvent ───────────────
+
+ ///
+ /// Fires when flips. The handler receives the new state.
+ ///
+ /// visibilitychange
+ ///
+ ///
+ /// The new state isn't carried on the event, so it's read back via a separate (cheap, synchronous)
+ /// interop call after the event fires. Under very rapid toggles the reported state can lag or
+ /// arrive slightly out of order relative to the raw events; treat the value as "latest known"
+ /// rather than a strictly-ordered log.
+ ///
+ public async Task SubscribeVisibilityChange(Action handler)
+ {
+ Action bridge = _ =>
+ {
+ // We don't get the state on the event itself - fetch it on the fly.
+ // It's cheap (sync property) so the extra interop is acceptable.
+ _ = ReportVisibilityAsync(handler);
+ };
+ return await SubscribeEvent(ButilEvents.VisibilityChange, bridge);
+ }
+
+ private async Task ReportVisibilityAsync(Action handler)
+ {
+ try { handler(await GetVisibilityState()); }
+ catch (Exception ex) when (ex.IsIgnorableDisposalException()) { } // teardown: circuit gone, cancelled, or already disposed
+ }
+
+ ///
+ /// Fires when an element enters or leaves fullscreen. The handler receives true when
+ /// the document currently has a fullscreen element.
+ ///
+ /// fullscreenchange
+ ///
+ ///
+ /// The fullscreen state is read back via a separate interop call after the event fires (the event
+ /// itself carries no payload), so under rapid toggles the reported value can lag or arrive
+ /// slightly out of order. Treat it as "latest known" rather than a strictly-ordered log.
+ ///
+ public async Task SubscribeFullscreenChange(Action handler)
+ {
+ Action bridge = _ => _ = ReportFullscreenAsync(handler);
+ return await SubscribeEvent(ButilEvents.FullscreenChange, bridge);
+ }
+
+ private async Task ReportFullscreenAsync(Action handler)
+ {
+ try
+ {
+ var hasFs = await js.Invoke("BitButil.document.hasFullscreenElement");
+ handler(hasFs);
+ }
+ catch (Exception ex) when (ex.IsIgnorableDisposalException()) { } // teardown: circuit gone, cancelled, or already disposed
+ }
+
+ ///
+ /// Fires when entering fullscreen fails. The handler receives no payload - the spec
+ /// doesn't expose a structured reason.
+ ///
+ public Task SubscribeFullscreenError(Action handler)
+ {
+ Action bridge = _ => handler();
+ return SubscribeEvent(ButilEvents.FullscreenError, bridge);
+ }
+
+ ///
+ /// Fires when pointer lock is entered or exited. The handler receives true when an
+ /// element currently has pointer lock.
+ ///
+ ///
+ /// The lock state is read back via a separate interop call after the event fires, so under rapid
+ /// toggles the reported value can lag or arrive slightly out of order. Treat it as "latest known"
+ /// rather than a strictly-ordered log.
+ ///
+ public async Task SubscribePointerLockChange(Action handler)
+ {
+ Action bridge = _ => _ = ReportPointerLockAsync(handler);
+ return await SubscribeEvent(ButilEvents.PointerLockChange, bridge);
+ }
+
+ private async Task ReportPointerLockAsync(Action handler)
+ {
+ try
+ {
+ var hasLock = await js.Invoke("BitButil.document.hasPointerLockElement");
+ handler(hasLock);
+ }
+ catch (Exception ex) when (ex.IsIgnorableDisposalException()) { } // teardown: circuit gone, cancelled, or already disposed
+ }
+
+ /// Fires when entering pointer lock fails.
+ public Task SubscribePointerLockError(Action handler)
+ {
+ Action bridge = _ => handler();
+ return SubscribeEvent(ButilEvents.PointerLockError, bridge);
+ }
+
+ ///
+ /// Fires when the DOMContentLoaded event has just been raised. Useful when bootstrapping
+ /// post-render work after circuit reconnect.
+ ///
+ public Task SubscribeDomContentLoaded(Action handler)
+ {
+ Action bridge = _ => handler();
+ return SubscribeEvent(ButilEvents.DomContentLoaded, bridge);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeAsync(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual async ValueTask DisposeAsync(bool disposing)
+ {
+ if (disposing is false) return;
+
+ try
+ {
+ if (_listenerIds.IsEmpty is false)
+ {
+ var snapshot = _listenerIds.Keys.ToArray();
+ _listenerIds.Clear();
+ foreach (var (id, evt, useCapture) in snapshot)
+ {
+ await _events.RemoveEventListenerById(js, ElementName, evt, id, useCapture);
+ }
+ }
+ }
+ catch (Exception ex) when (ex.IsIgnorableDisposalException()) { } // teardown: circuit gone, cancelled, or already disposed
+ finally
+ {
+ _events.Dispose();
+ }
+ }
}
diff --git a/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs b/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs
new file mode 100644
index 0000000000..b9c3094104
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs
@@ -0,0 +1,19 @@
+namespace Bit.Butil;
+
+///
+/// Mirrors Document.visibilityState.
+///
+public enum VisibilityState
+{
+ ///
+ /// The page content may be at least partially visible. In practice this means
+ /// the tab is the foreground tab of a non-minimized window.
+ ///
+ Visible,
+
+ ///
+ /// The page content is not visible to the user - the tab is in the background or
+ /// the window is minimized, or the OS screen lock is active.
+ ///
+ Hidden
+}
diff --git a/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs
new file mode 100644
index 0000000000..6e7047fdb5
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+
+namespace Bit.Butil;
+
+///
+/// Element-scoped DOM event subscriptions. Returns an handle
+/// () so callers can await using for the lifetime of a
+/// component without hand-rolling Add/Remove pairs.
+///
+///
+/// Each subscription owns a per-subscription (there is no
+/// long-lived service instance to host it, since these are extension methods). The reference - and
+/// therefore all captured component state - is released when the returned subscription is disposed,
+/// so there is no static state and no cross-circuit bleed.
+///
+public static class ElementReferenceEventExtensions
+{
+ ///
+ /// Subscribes to a DOM event on the given element. The returned handle detaches the listener on dispose.
+ ///
+ ///
+ /// You must dispose the returned . Unlike the
+ /// / services, this extension has no owning scoped
+ /// instance to drain on circuit teardown, so each call allocates its own
+ /// and a JS-side handler entry that live until the handle
+ /// is disposed. Failing to dispose leaks both (plus any state your
+ /// captures) for the lifetime of the circuit. Prefer await using, or store the handle and
+ /// dispose it in the component's DisposeAsync.
+ ///
+ public static Task SubscribeEvent(
+ this ElementReference element,
+ IJSRuntime js,
+ string domEvent,
+ Action listener,
+ bool useCapture = false,
+ bool preventDefault = false,
+ bool stopPropagation = false)
+ => SubscribeEventCore(element, js, domEvent, listener, useCapture, false, false, preventDefault, stopPropagation);
+
+ ///
+ /// variant of
+ /// ,
+ /// adding passive and once control on top of capture. The same disposal
+ /// requirement applies.
+ ///
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(JsAddEventListenerOptions))]
+ public static Task SubscribeEvent(
+ this ElementReference element,
+ IJSRuntime js,
+ string domEvent,
+ Action listener,
+ ButilEventListenerOptions options,
+ bool preventDefault = false,
+ bool stopPropagation = false)
+ => SubscribeEventCore(element, js, domEvent, listener, options.Capture, options.Passive, options.Once, preventDefault, stopPropagation);
+
+ private static async Task SubscribeEventCore(
+ ElementReference element,
+ IJSRuntime js,
+ string domEvent,
+ Action listener,
+ bool useCapture,
+ bool passive,
+ bool once,
+ bool preventDefault,
+ bool stopPropagation)
+ {
+ var argType = typeof(T);
+ var eventType = DomEventArgs.TypeOf(domEvent);
+ if (argType != eventType)
+ throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})");
+
+ // Each element gets a generated id so the JS side can target it directly.
+ var elementId = Guid.NewGuid().ToString("N");
+ var host = new DomEventsInterop();
+ var (listenerId, methodName, members, dotNetRef) = host.Register(listener, elementId, domEvent, useCapture);
+
+ // Bare boolean for the capture-only case; full options object when passive/once are set.
+ object options = (passive || once)
+ ? new JsAddEventListenerOptions { Capture = useCapture, Passive = passive, Once = once }
+ : useCapture;
+
+ await js.InvokeVoid("BitButil.element.subscribeEvent",
+ element,
+ elementId,
+ domEvent,
+ methodName,
+ dotNetRef,
+ listenerId,
+ members,
+ options,
+ preventDefault,
+ stopPropagation);
+
+ return new ButilSubscription(listenerId, async () =>
+ {
+ host.Unregister(listenerId);
+ try
+ {
+ await js.InvokeVoid("BitButil.element.unsubscribeEvent", elementId, domEvent, listenerId, options);
+ }
+ finally
+ {
+ host.Dispose();
+ }
+ });
+ }
+}
diff --git a/src/Butil/Bit.Butil/Publics/ElementReferenceExtensions.cs b/src/Butil/Bit.Butil/Publics/ElementReferenceExtensions.cs
index 93d5f300cd..b1d5ce42f8 100644
--- a/src/Butil/Bit.Butil/Publics/ElementReferenceExtensions.cs
+++ b/src/Butil/Bit.Butil/Publics/ElementReferenceExtensions.cs
@@ -18,11 +18,12 @@ namespace Bit.Butil;
///
public static class ElementReferenceExtensions
{
- //[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_JSRuntime")]
- //extern static IJSRuntime JSRuntimeGetter(WebElementReferenceContext context);
-
- [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "k__BackingField")]
- extern static ref IJSRuntime JSRuntimeGetter(WebElementReferenceContext context);
+ // Bind to the public-surface property getter rather than the compiler-generated
+ // "k__BackingField" field. The getter method name (get_JSRuntime) is part of the
+ // type's stable shape and far less likely to change across framework releases than the
+ // synthesized backing-field name, which is an implementation detail.
+ [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_JSRuntime")]
+ extern static IJSRuntime JSRuntimeGetter(WebElementReferenceContext context);
private static IJSRuntime GetJSRuntime(ElementReference elementReference)
{
@@ -68,6 +69,11 @@ public static async ValueTask GetBoundingClientRect(this ElementReference
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/hasAttribute
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask HasAttribute(this ElementReference element, string name)
=> await GetJSRuntime(element).Invoke("BitButil.element.hasAttribute", element, name);
@@ -76,6 +82,11 @@ public static async ValueTask HasAttribute(this ElementReference element,
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/hasAttributes
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask HasAttributes(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.hasAttributes", element);
@@ -84,6 +95,11 @@ public static async ValueTask HasAttributes(this ElementReference element)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/hasPointerCapture
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask HasPointerCapture(this ElementReference element, int pointerId)
=> await GetJSRuntime(element).Invoke("BitButil.element.hasPointerCapture", element, pointerId);
@@ -92,6 +108,11 @@ public static async ValueTask HasPointerCapture(this ElementReference elem
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/matches
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask Matches(this ElementReference element, string selectors)
=> await GetJSRuntime(element).Invoke("BitButil.element.matches", element, selectors);
@@ -195,6 +216,13 @@ public static async ValueTask ScrollIntoView(this ElementReference element, Scro
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute
///
+ ///
+ /// Security note: attribute values are written verbatim and bypass Blazor's encoding.
+ /// Setting event-handler attributes (onclick, ...), navigational attributes with a
+ /// javascript: URL (href, src, formaction), or srcdoc from
+ /// untrusted input is an XSS vector. Validate /
+ /// before passing user-supplied data.
+ ///
public static async ValueTask SetAttribute(this ElementReference element, string name, string value)
=> await GetJSRuntime(element).InvokeVoid("BitButil.element.setAttribute", element, name, value);
@@ -211,6 +239,11 @@ public static async ValueTask SetPointerCapture(this ElementReference element, i
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/toggleAttribute
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask ToggleAttribute(this ElementReference element, string name, bool? force)
=> await GetJSRuntime(element).Invoke("BitButil.element.toggleAttribute", element, name, force);
@@ -249,6 +282,11 @@ public static async ValueTask SetClassName(this ElementReference element, string
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetClientHeight(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.clientHeight", element);
@@ -257,6 +295,11 @@ public static async ValueTask GetClientHeight(this ElementReference eleme
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/clientLeft
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetClientLeft(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.clientLeft", element);
@@ -265,6 +308,11 @@ public static async ValueTask GetClientLeft(this ElementReference element
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/clientTop
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetClientTop(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.clientTop", element);
@@ -273,6 +321,11 @@ public static async ValueTask GetClientTop(this ElementReference element)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetClientWidth(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.clientWidth", element);
@@ -303,6 +356,11 @@ public static async ValueTask GetInnerHtml(this ElementReference element
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
///
+ ///
+ /// Security note: this assigns directly to element.innerHTML and therefore bypasses
+ /// Blazor's automatic HTML encoding. Never pass untrusted or user-supplied input - doing so is an
+ /// XSS vector. Sanitize first, or render the content through normal Razor markup instead.
+ ///
public static async ValueTask SetInnerHtml(this ElementReference element, string innerHtml)
=> await GetJSRuntime(element).InvokeVoid("BitButil.element.setInnerHTML", element, innerHtml);
@@ -319,6 +377,11 @@ public static async ValueTask GetOuterHtml(this ElementReference element
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/outerHTML
///
+ ///
+ /// Security note: this assigns directly to element.outerHTML and therefore bypasses
+ /// Blazor's automatic HTML encoding. Never pass untrusted or user-supplied input - doing so is an
+ /// XSS vector. Sanitize first, or render the content through normal Razor markup instead.
+ ///
public static async ValueTask SetOuterHtml(this ElementReference element, string outerHtml)
=> await GetJSRuntime(element).InvokeVoid("BitButil.element.setOuterHTML", element, outerHtml);
@@ -327,6 +390,11 @@ public static async ValueTask SetOuterHtml(this ElementReference element, string
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetScrollHeight(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.scrollHeight", element);
@@ -335,6 +403,11 @@ public static async ValueTask GetScrollHeight(this ElementReference eleme
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetScrollLeft(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.scrollLeft", element);
@@ -343,6 +416,11 @@ public static async ValueTask GetScrollLeft(this ElementReference element
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetScrollTop(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.scrollTop", element);
@@ -351,6 +429,11 @@ public static async ValueTask GetScrollTop(this ElementReference element)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollWidth
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetScrollWidth(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.scrollWidth", element);
@@ -367,6 +450,11 @@ public static async ValueTask GetTagName(this ElementReference element)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/contentEditable
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetContentEditable(this ElementReference element)
{
var value = await GetJSRuntime(element).Invoke("BitButil.element.getContentEditable", element);
@@ -400,6 +488,11 @@ public static async ValueTask SetContentEditable(this ElementReference element,
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/isContentEditable
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask IsContentEditable(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.isContentEditable", element);
@@ -408,6 +501,11 @@ public static async ValueTask IsContentEditable(this ElementReference elem
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dir
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetDir(this ElementReference element)
{
var value = await GetJSRuntime(element).Invoke("BitButil.element.getDir", element);
@@ -442,6 +540,11 @@ public static async ValueTask SetDir(this ElementReference element, ElementDir v
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/enterKeyHint
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetEnterKeyHint(this ElementReference element)
{
var value = await GetJSRuntime(element).Invoke("BitButil.element.getEnterKeyHint", element);
@@ -484,6 +587,11 @@ public static async ValueTask SetEnterKeyHint(this ElementReference element, Ent
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/hidden
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetHidden(this ElementReference element)
{
var value = await GetJSRuntime(element).Invoke("BitButil.element.getHidden", element);
@@ -518,6 +626,11 @@ public static async ValueTask SetHidden(this ElementReference element, Hidden va
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetInert(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.getInert", element);
///
@@ -550,6 +663,11 @@ public static async ValueTask SetInnerText(this ElementReference element, string
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inputMode
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetInputMode(this ElementReference element)
{
var value = await GetJSRuntime(element).Invoke("BitButil.element.getInputMode", element);
@@ -594,6 +712,11 @@ public static async ValueTask SetInputMode(this ElementReference element, InputM
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetHeight
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetOffsetHeight(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.offsetHeight", element);
@@ -603,6 +726,11 @@ public static async ValueTask GetOffsetHeight(this ElementReference eleme
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetLeft
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetOffsetLeft(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.offsetLeft", element);
@@ -612,6 +740,11 @@ public static async ValueTask GetOffsetLeft(this ElementReference element
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetTop
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetOffsetTop(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.offsetTop", element);
@@ -620,6 +753,11 @@ public static async ValueTask GetOffsetTop(this ElementReference element)
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetOffsetWidth(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.offsetWidth", element);
@@ -628,6 +766,11 @@ public static async ValueTask GetOffsetWidth(this ElementReference elemen
///
/// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/tabIndex
///
+ ///
+ /// During prerender/SSR (no JS runtime) this returns default (e.g. false/0)
+ /// rather than throwing, so the result can't be distinguished from a genuine value. If you
+ /// branch on it, defer the read to OnAfterRenderAsync.
+ ///
public static async ValueTask GetTabIndex(this ElementReference element)
=> await GetJSRuntime(element).Invoke("BitButil.element.getTabIndex", element);
///
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs
new file mode 100644
index 0000000000..98ff004228
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Clipboard event payload - see ClipboardEvent.
+///
+public class ButilClipboardEventArgs : EventArgs
+{
+ // The DataTransfer object isn't directly serializable; events.ts flattens the most
+ // common shape for us - the plain-text payload - and leaves richer types to the
+ // explicit Clipboard service.
+ internal static readonly string[] EventArgsMembers = ["type", "clipboardText"];
+
+ /// "copy", "cut" or "paste".
+ public string Type { get; set; } = string.Empty;
+
+ /// Plain-text contents of the clipboard event, or null when absent.
+ public string? ClipboardText { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs
new file mode 100644
index 0000000000..afebd4d592
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// IME composition event - see CompositionEvent.
+///
+public class ButilCompositionEventArgs : EventArgs
+{
+ internal static readonly string[] EventArgsMembers = ["type", "data", "locale"];
+
+ /// "compositionstart", "compositionupdate", or "compositionend".
+ public string Type { get; set; } = string.Empty;
+
+ /// The current composition string.
+ public string? Data { get; set; }
+
+ /// BCP-47 language tag for the input method, when supplied.
+ public string? Locale { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs
new file mode 100644
index 0000000000..a07e06a89e
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Drag event payload - see DragEvent.
+///
+///
+/// The DataTransfer object can't be passed straight across JS interop because it holds
+/// browser-side resources; we surface its inert metadata only. To actually read the
+/// dropped files use the standard InputFile component or call back into JS for
+/// each file.
+///
+public class ButilDragEventArgs : EventArgs
+{
+ internal static readonly string[] EventArgsMembers = [
+ "altKey", "button", "buttons", "clientX", "clientY", "ctrlKey", "metaKey",
+ "offsetX", "offsetY", "pageX", "pageY", "screenX", "screenY", "shiftKey",
+ "x", "y"];
+
+ public bool AltKey { get; set; }
+ public int Button { get; set; }
+ public int Buttons { get; set; }
+ public double ClientX { get; set; }
+ public double ClientY { get; set; }
+ public bool CtrlKey { get; set; }
+ public bool MetaKey { get; set; }
+ public double OffsetX { get; set; }
+ public double OffsetY { get; set; }
+ public double PageX { get; set; }
+ public double PageY { get; set; }
+ public double ScreenX { get; set; }
+ public double ScreenY { get; set; }
+ public bool ShiftKey { get; set; }
+ public double X { get; set; }
+ public double Y { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilEventListenerOptions.cs b/src/Butil/Bit.Butil/Publics/Events/ButilEventListenerOptions.cs
new file mode 100644
index 0000000000..d1a2b8c8b4
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilEventListenerOptions.cs
@@ -0,0 +1,29 @@
+namespace Bit.Butil;
+
+///
+/// Options for registering a DOM event listener, mirroring the browser's
+/// addEventListener options.
+///
+public sealed class ButilEventListenerOptions
+{
+ ///
+ /// When true, the listener is invoked during the capture phase (before it reaches the
+ /// target) instead of the bubbling phase. This value must match between add and remove.
+ ///
+ public bool Capture { get; set; }
+
+ ///
+ /// When true, signals the browser that the listener will never call
+ /// preventDefault(), letting it optimize scrolling/touch performance. Setting this
+ /// alongside PreventDefault is contradictory - the preventDefault() call is
+ /// ignored (and the browser logs a console error) for passive listeners.
+ ///
+ public bool Passive { get; set; }
+
+ ///
+ /// When true, the listener is automatically removed after it fires once. The Butil
+ /// bookkeeping is reconciled on the JS side after the single invocation, so disposing the
+ /// returned subscription afterwards is a harmless no-op.
+ ///
+ public bool Once { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs b/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs
index 00934f58dc..3057fd58ff 100644
--- a/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs
@@ -2,8 +2,91 @@
public class ButilEvents
{
+ // ─── Mouse ────────────────────────────────────────────────────────────
public const string Click = "click";
+ public const string DblClick = "dblclick";
+ public const string MouseDown = "mousedown";
+ public const string MouseUp = "mouseup";
+ public const string MouseMove = "mousemove";
+ public const string MouseEnter = "mouseenter";
+ public const string MouseLeave = "mouseleave";
+ public const string MouseOver = "mouseover";
+ public const string MouseOut = "mouseout";
+ public const string ContextMenu = "contextmenu";
+
+ // ─── Keyboard ─────────────────────────────────────────────────────────
public const string KeyDown = "keydown";
public const string KeyUp = "keyup";
public const string KeyPress = "keypress";
+
+ // ─── Pointer ──────────────────────────────────────────────────────────
+ public const string PointerDown = "pointerdown";
+ public const string PointerUp = "pointerup";
+ public const string PointerMove = "pointermove";
+ public const string PointerEnter = "pointerenter";
+ public const string PointerLeave = "pointerleave";
+ public const string PointerOver = "pointerover";
+ public const string PointerOut = "pointerout";
+ public const string PointerCancel = "pointercancel";
+ public const string GotPointerCapture = "gotpointercapture";
+ public const string LostPointerCapture = "lostpointercapture";
+
+ // ─── Touch ────────────────────────────────────────────────────────────
+ public const string TouchStart = "touchstart";
+ public const string TouchEnd = "touchend";
+ public const string TouchMove = "touchmove";
+ public const string TouchCancel = "touchcancel";
+
+ // ─── Wheel / scroll ───────────────────────────────────────────────────
+ public const string Wheel = "wheel";
+ public const string Scroll = "scroll";
+
+ // ─── Focus ────────────────────────────────────────────────────────────
+ public const string Focus = "focus";
+ public const string FocusIn = "focusin";
+ public const string Blur = "blur";
+ public const string FocusOut = "focusout";
+
+ // ─── Input ────────────────────────────────────────────────────────────
+ public const string Input = "input";
+ public const string Change = "change";
+ public const string Submit = "submit";
+ public const string Reset = "reset";
+ public const string BeforeInput = "beforeinput";
+
+ // ─── Drag & drop ──────────────────────────────────────────────────────
+ public const string DragStart = "dragstart";
+ public const string Drag = "drag";
+ public const string DragEnd = "dragend";
+ public const string DragEnter = "dragenter";
+ public const string DragLeave = "dragleave";
+ public const string DragOver = "dragover";
+ public const string Drop = "drop";
+
+ // ─── Clipboard ────────────────────────────────────────────────────────
+ public const string Copy = "copy";
+ public const string Cut = "cut";
+ public const string Paste = "paste";
+
+ // ─── Composition ──────────────────────────────────────────────────────
+ public const string CompositionStart = "compositionstart";
+ public const string CompositionUpdate = "compositionupdate";
+ public const string CompositionEnd = "compositionend";
+
+ // ─── Window-only ──────────────────────────────────────────────────────
+ public const string Resize = "resize";
+ public const string Online = "online";
+ public const string Offline = "offline";
+ public const string HashChange = "hashchange";
+ public const string LanguageChange = "languagechange";
+ public const string Load = "load";
+ public const string Unload = "unload";
+
+ // ─── Document-level visibility / fullscreen ───────────────────────────
+ public const string VisibilityChange = "visibilitychange";
+ public const string FullscreenChange = "fullscreenchange";
+ public const string FullscreenError = "fullscreenerror";
+ public const string PointerLockChange = "pointerlockchange";
+ public const string PointerLockError = "pointerlockerror";
+ public const string DomContentLoaded = "DOMContentLoaded";
}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs
new file mode 100644
index 0000000000..b3cf86b9f9
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Focus event payload - see FocusEvent.
+///
+public class ButilFocusEventArgs : EventArgs
+{
+ internal static readonly string[] EventArgsMembers = ["type"];
+
+ /// "focus", "focusin", "blur" or "focusout".
+ public string Type { get; set; } = string.Empty;
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs
new file mode 100644
index 0000000000..8d5da5f308
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Input/beforeinput event payload - see InputEvent.
+///
+public class ButilInputEventArgs : EventArgs
+{
+ internal static readonly string[] EventArgsMembers = [
+ "data", "inputType", "isComposing"];
+
+ /// The string representing the inserted text. Null for deletions.
+ public string? Data { get; set; }
+
+ /// e.g. "insertText", "deleteContentBackward", "insertFromPaste".
+ public string InputType { get; set; } = string.Empty;
+
+ /// True if the event was fired during an IME composition session.
+ public bool IsComposing { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs
index f5a0b6557d..51574593af 100644
--- a/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs
@@ -89,7 +89,10 @@ public class ButilMouseEventArgs : EventArgs
public double PageY { get; set; }
///
- /// The secondary target for the event, if there is one.
+ /// The id of the secondary target for the event (for example the element the pointer
+ /// is entering/leaving), or an empty string when there is no related target or the
+ /// related element has no id. The underlying DOM node can't be marshaled across JS
+ /// interop, so only its id is surfaced here.
///
public string RelatedTarget { get; set; } = string.Empty;
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs
new file mode 100644
index 0000000000..46d2d38a66
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs
@@ -0,0 +1,66 @@
+using System;
+
+namespace Bit.Butil;
+
+///
+/// Pointer event payload - see PointerEvent.
+/// Pointer events unify mouse, pen and touch interaction.
+///
+public class ButilPointerEventArgs : EventArgs
+{
+ internal static readonly string[] EventArgsMembers = [
+ "altKey", "button", "buttons", "clientX", "clientY", "ctrlKey", "metaKey",
+ "movementX", "movementY", "offsetX", "offsetY", "pageX", "pageY",
+ "screenX", "screenY", "shiftKey", "x", "y",
+ "pointerId", "width", "height", "pressure", "tangentialPressure",
+ "tiltX", "tiltY", "twist", "pointerType", "isPrimary"];
+
+ public bool AltKey { get; set; }
+ public int Button { get; set; }
+ public int Buttons { get; set; }
+ public double ClientX { get; set; }
+ public double ClientY { get; set; }
+ public bool CtrlKey { get; set; }
+ public bool MetaKey { get; set; }
+ public double MovementX { get; set; }
+ public double MovementY { get; set; }
+ public double OffsetX { get; set; }
+ public double OffsetY { get; set; }
+ public double PageX { get; set; }
+ public double PageY { get; set; }
+ public double ScreenX { get; set; }
+ public double ScreenY { get; set; }
+ public bool ShiftKey { get; set; }
+ public double X { get; set; }
+ public double Y { get; set; }
+
+ /// Identifier for the pointer that produced the event (see PointerEvent.pointerId).
+ public int PointerId { get; set; }
+
+ /// Width (magnitude on the X axis), in CSS pixels, of the contact geometry.
+ public double Width { get; set; }
+
+ /// Height (magnitude on the Y axis), in CSS pixels, of the contact geometry.
+ public double Height { get; set; }
+
+ /// Normalized pressure of the pointer input in the range 0 to 1.
+ public double Pressure { get; set; }
+
+ /// Normalized tangential pressure (also called barrel pressure) for stylus inputs.
+ public double TangentialPressure { get; set; }
+
+ /// Plane angle (degrees) between the Y-Z plane and the plane containing the pointer axis and Y axis.
+ public double TiltX { get; set; }
+
+ /// Plane angle (degrees) between the X-Z plane and the plane containing the pointer axis and X axis.
+ public double TiltY { get; set; }
+
+ /// Clockwise rotation of the pointer (e.g. pen barrel) in degrees, 0–359.
+ public double Twist { get; set; }
+
+ /// "mouse", "pen", "touch", or empty for unknown.
+ public string PointerType { get; set; } = string.Empty;
+
+ /// True if this pointer is the primary pointer of its type.
+ public bool IsPrimary { get; set; }
+}
diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs b/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs
new file mode 100644
index 0000000000..32e671292b
--- /dev/null
+++ b/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Bit.Butil;
+
+///
+/// Lightweight token returned by Butil event/observer subscriptions. Disposing the token
+/// detaches the underlying JS listener so consumers can await using a subscription
+/// for the lifetime of a component without juggling Guids.
+///
+///
+/// Disposal is idempotent and safe to call during teardown - the underlying remover is wrapped to
+/// swallow a , a cancelled interop call, or
+/// an from an already-disposed runtime.
+/// The exposed is still useful when callers want to compose multiple
+/// subscriptions and remove them in bulk.
+///
+public sealed class ButilSubscription : IAsyncDisposable
+{
+ private Func? _remover;
+
+ internal ButilSubscription(Guid id, Func remover)
+ {
+ Id = id;
+ _remover = remover;
+ }
+
+ ///