# Events (ActionEvent / FuncEvent) SpawnDev.BlazorJS provides a clean .NET-style event system for JavaScript events using `ActionEvent` and `FuncEvent`. These types allow you to subscribe to and unsubscribe from JavaScript events using the `+=` and `-=` operators, with automatic callback reference counting. **Namespace:** `SpawnDev.BlazorJS` --- ## ActionEvent `ActionEvent` wraps JavaScript events and exposes them as C# events that accept `Action` delegates. It handles all the reference management automatically - creating Callbacks when you subscribe, and disposing them when you unsubscribe. ### How It Works Internally When you write `window.OnStorage += MyHandler`: 1. A `Callback` is created wrapping `MyHandler` (via `CallbackRef.RefAdd`) 2. The Callback is passed to JavaScript's `addEventListener` 3. The reference count for `MyHandler` is incremented When you write `window.OnStorage -= MyHandler`: 1. The existing `Callback` for `MyHandler` is retrieved 2. It is passed to JavaScript's `removeEventListener` 3. The reference count is decremented 4. If the count reaches 0, the `Callback` is disposed ### Basic Usage ```csharp // Get a Window reference using var window = JS.Get("window"); // Subscribe to an event window.OnStorage += Window_OnStorage; // ... later, ALWAYS unsubscribe before disposing the parent object window.OnStorage -= Window_OnStorage; void Window_OnStorage(StorageEvent e) { Console.WriteLine($"Storage changed: {e.Key}"); } ``` --- ## The ActionEvent Property Pattern When implementing `ActionEvent` properties in your own JSObject wrappers, use this pattern: ```csharp public ActionEvent OnStorage { get => new ActionEvent("storage", AddEventListener, RemoveEventListener); set { } } ``` **Why `set { }` is required:** The `+=` operator in C# is syntactic sugar for `property = property + value`. Without a setter, `+=` cannot compile. The empty `set { }` satisfies the compiler while the actual subscription is handled by the `+` operator overload on `ActionEvent`. **The constructor parameters:** - `"storage"` - the JavaScript event name - `AddEventListener` - method reference for subscribing (inherited from `EventTarget`) - `RemoveEventListener` - method reference for unsubscribing (inherited from `EventTarget`) ### Alternative Constructor Form For objects that do not inherit from `EventTarget`, you can pass lambda attach/detach callbacks: ```csharp public ActionEvent OnMessage { get => new ActionEvent( (callback) => JSRef!.CallVoid("addEventListener", "message", callback), (callback) => JSRef!.CallVoid("removeEventListener", "message", callback) ); set { } } ``` Or for APIs that use `on` and `off` instead of `addEventListener`/`removeEventListener`: ```csharp public ActionEvent OnData { get => new ActionEvent( (callback) => JSRef!.CallVoid("on", "data", callback), (callback) => JSRef!.CallVoid("off", "data", callback) ); set { } } ``` --- ## The += and -= Operators ```csharp using var videoEl = (HTMLVideoElement)videoRef; // Subscribe videoEl.OnLoadedMetadata += VideoEl_OnLoadedMetadata; videoEl.OnError += VideoEl_OnError; videoEl.OnAbort += VideoEl_OnAbort; // ... use the element ... // ALWAYS unsubscribe before disposing videoEl.OnLoadedMetadata -= VideoEl_OnLoadedMetadata; videoEl.OnError -= VideoEl_OnError; videoEl.OnAbort -= VideoEl_OnAbort; videoEl.Dispose(); ``` --- ## Optional Parameters - Zero-Cost Event Notifications Event handler parameters are optional. This is not just a convenience feature - it is a **performance optimization**. When you omit the event arguments, the JS interop layer skips serialization of the event object entirely. No `IJSInProcessObjectReference` is created, no JSON is marshaled, no object needs disposal. You get a pure notification that the event fired with zero interop overhead per invocation. This optimization happens on the **JavaScript side**. Each `Callback` carries a `_paramTypes` array that tells the JS interop bridge exactly how many arguments the .NET handler expects. When the JS event fires, the bridge loops `for (i = 0; i < paramTypes.length; i++)` and only serializes that many arguments to send to .NET. A zero-parameter handler means `paramTypes.length` is 0 - the loop never executes and the JS event object is never touched. This works because `ActionEvent` inherits from `ActionEvent`. The base class has `operator +(ActionEvent, Action)` (parameterless), and the subclass adds `operator +(ActionEvent, Action)` (typed). Both are valid for the same event property. ```csharp // FULL ARGUMENTS - event object is deserialized from JS into .NET // Use when you need the event data void Window_OnStorage(StorageEvent e) { Console.WriteLine($"Key: {e.Key}, NewValue: {e.NewValue}"); } // NO ARGUMENTS - zero interop cost, just a notification // Use when you only need to know the event fired void Window_OnStorage() { Console.WriteLine("Storage changed"); } // Both are valid for ActionEvent: window.OnStorage += Window_OnStorage; ``` **When this matters most:** High-frequency events like `OnMouseMove`, `OnScroll`, `OnTimeUpdate`, or `OnResize` where you might only need the notification (e.g., to trigger a re-render or set a flag) without the event payload. Skipping the `MouseEvent` or `Event` deserialization on every mouse move is a significant win. ```csharp // High-frequency - skip serialization since we don't need the event data void HandleMouseMove() { needsRedraw = true; } // Only use the typed version when you actually need the coordinates void HandleMouseMove(MouseEvent e) { cursorX = e.ClientX; cursorY = e.ClientY; } ``` --- ## On() and Off() Methods `ActionEvent` also provides `On` and `Off` methods as an alternative to `+=` and `-=`. These methods support attaching handlers with different parameter signatures. ```csharp using var window = JS.Get("window"); // Attach with On window.OnStorage.On(Window_OnStorage); // Detach with Off window.OnStorage.Off(Window_OnStorage); void Window_OnStorage() { Console.WriteLine("Storage changed"); } ``` --- ## Reference Counting The Callback reference count is for the **delegate** and has nothing to do with which JSObject or event it is used with. Every `+=` increments the ref count for that delegate; every `-=` decrements it. When it reaches 0, the Callback is disposed. The same delegate used across different events on different objects still shares one Callback: ```csharp // First += creates the Callback with RefCount = 1 window.OnOnline += HandleNetworkChange; // Second += on a DIFFERENT event, same delegate - RefCount = 2 window.OnOffline += HandleNetworkChange; // Third += on a DIFFERENT object, same delegate - RefCount = 3 using var document = JS.Get("document"); document.OnVisibilityChange += HandleNetworkChange; // -= from any object decrements - RefCount = 2 document.OnVisibilityChange -= HandleNetworkChange; // RefCount = 1 window.OnOnline -= HandleNetworkChange; // RefCount = 0 - Callback is disposed window.OnOffline -= HandleNetworkChange; ``` **Every `+=` MUST have a matching `-=`.** The total number of `+=` and `-=` calls must balance for each delegate. It does not matter which JSObject you use for `-=` - any reference to the same underlying JS object will work. Failing to balance leaks the Callback and the .NET object reference, and can cause calls into disposed objects that trigger Blazor error UI. --- ## FuncEvent `FuncEvent` works identically to `ActionEvent` but supports `Func` delegates that return a value to JavaScript. Use this when the JavaScript event handler is expected to return something. ```csharp // FuncEvent property pattern public FuncEvent OnBeforeUnload { get => new FuncEvent("beforeunload", AddEventListener, RemoveEventListener); set { } } // Usage window.OnBeforeUnload += Window_OnBeforeUnload; string? Window_OnBeforeUnload(BeforeUnloadEvent e) { return "Are you sure you want to leave?"; } ``` --- ## Named Methods vs Lambdas Every `+=` creates an underlying `Callback` that is reference-counted by the delegate instance you pass in. To properly dispose that `Callback`, you must `-=` with the **same delegate instance**. This has an important consequence: **Named methods** - can be unsubscribed because the same method reference is used for both `+=` and `-=`: ```csharp ws.OnMessage += HandleMessage; // creates Callback, ref count = 1 ws.OnMessage -= HandleMessage; // same delegate - ref count = 0, Callback disposed void HandleMessage(MessageEvent e) { /* ... */ } ``` **Lambdas** - cannot be unsubscribed because each lambda expression creates a new delegate instance: ```csharp ws.OnMessage += (e) => Console.WriteLine(e.Data); // creates Callback ws.OnMessage -= (e) => Console.WriteLine(e.Data); // DIFFERENT delegate - does nothing! // The original Callback is now leaked - it can never be disposed ``` **When lambdas are OK:** If the event subscription lives for the entire lifetime of the application and never needs to be removed, a lambda is fine - the `Callback` will be cleaned up when the app exits. This is common for global event handlers set up once in `Program.cs` or long-lived singleton services. **When named methods are required:** Any time you need to unsubscribe - components that dispose, short-lived objects, anything with a cleanup lifecycle. This is the more common case. --- ## How It Works Under the Hood Understanding the internals helps you use ActionEvent correctly: 1. **`CallbackRef` is static.** All ActionEvent/FuncEvent instances share a single static `CallbackRef` instance. It maintains a `Dictionary` keyed by the delegate (your method). Each `Callback` in the dictionary contains: a `DotNetObjectReference` (the .NET-to-JS bridge), a unique `_callbackId`, parameter type info (`_paramTypes`), return type info (`_returnVoid`), and a `RefCount`. This is the full interop object that JavaScript calls back into. 2. **`+=` calls `CallbackRef.RefAdd(delegate)`** - looks up your delegate in the dictionary. If found, increments `RefCount`. If not found, creates a new `Callback` (with its `DotNetObjectReference`, unique ID, and parameter types) and stores it. Then calls `addEventListener` on the JS object, passing the `Callback`. 3. **`-=` calls `CallbackRef.RefGet(delegate)` then `RefDel(delegate)`** - looks up your delegate in the same dictionary, calls `removeEventListener` on the JS object (passing the same `Callback`), and decrements `RefCount`. When `RefCount` reaches 0, the `Callback` disposes its `DotNetObjectReference` and notifies the JS side to clean up via `DisposeCallback(_callbackId)`. **Key insight:** The Callback reference count is purely about the **delegate** and has nothing to do with the JSObject it is used with. The JSObject is just a handle used to call `addEventListener`/`removeEventListener` on the underlying JavaScript object. This means: - The `-=` does not need to happen on the same JSObject instance. Any reference to the same underlying JS object will work for calling `removeEventListener`. - What matters for Callback disposal is that the total number of `-=` calls matches the total number of `+=` calls for that delegate. - Disposing the JSObject does NOT automatically unsubscribe events or dispose Callbacks. The Callback lives in the static dictionary independently. ```csharp using var window1 = JS.Get("window"); window1.OnResize += HandleResize; window1.Dispose(); // The Callback is still alive in the static dictionary // Later, get a new reference to the same JS window object using var window2 = JS.Get("window"); window2.OnResize -= HandleResize; // This works! Same delegate = same Callback found and disposed void HandleResize() => Console.WriteLine("Resized"); ``` --- ## Proper Disposal Pattern Unsubscribe from events to dispose their underlying Callbacks. This can be done from any JSObject reference to the same underlying JavaScript object - it does not have to be the same JSObject instance you used for `+=`: ```csharp @implements IDisposable @code { HTMLVideoElement? videoEl; protected override void OnAfterRender(bool firstRender) { if (firstRender) { videoEl = (HTMLVideoElement)videoRef; videoEl.OnLoadedMetadata += VideoEl_OnLoadedMetadata; videoEl.OnAbort += VideoEl_OnAbort; videoEl.OnError += VideoEl_OnError; } } void VideoEl_OnLoadedMetadata() { Console.WriteLine($"Metadata loaded: {videoEl!.VideoWidth}x{videoEl!.VideoHeight}"); StateHasChanged(); } void VideoEl_OnAbort() { /* handle abort */ } void VideoEl_OnError() { /* handle error */ } public void Dispose() { if (videoEl != null) { // ALWAYS unsubscribe first videoEl.OnLoadedMetadata -= VideoEl_OnLoadedMetadata; videoEl.OnAbort -= VideoEl_OnAbort; videoEl.OnError -= VideoEl_OnError; // Then dispose videoEl.Dispose(); videoEl = null; } } } ``` --- ## Implementing Events in Your Own Wrappers Complete example for a WebSocket wrapper: ```csharp public class WebSocket : EventTarget { public WebSocket(IJSInProcessObjectReference _ref) : base(_ref) { } public WebSocket(string url) : base(JS.New("WebSocket", url)) { } // Events using ActionEvent pattern public ActionEvent OnOpen { get => new ActionEvent("open", AddEventListener, RemoveEventListener); set { } } public ActionEvent OnClose { get => new ActionEvent("close", AddEventListener, RemoveEventListener); set { } } public ActionEvent OnMessage { get => new ActionEvent("message", AddEventListener, RemoveEventListener); set { } } public ActionEvent OnError { get => new ActionEvent("error", AddEventListener, RemoveEventListener); set { } } // Methods public void Send(string data) => JSRef!.CallVoid("send", data); public void Send(ArrayBuffer data) => JSRef!.CallVoid("send", data); public void Close(int? code = null, string? reason = null) { if (code == null) JSRef!.CallVoid("close"); else if (reason == null) JSRef!.CallVoid("close", code); else JSRef!.CallVoid("close", code, reason); } // Properties public int ReadyState => JSRef!.Get("readyState"); public string Url => JSRef!.Get("url"); } ``` --- ## See Also - [Callbacks](callbacks.md) - Lower-level callback creation - [JSObject](jsobject.md) - Base wrapper class - [Disposal and Lifetime](disposal.md) - Full disposal guide