Skip to content

[blazor][wasm] JSExport for events#65897

Merged
pavelsavara merged 11 commits intodotnet:mainfrom
pavelsavara:blazor_wasm_events
Mar 23, 2026
Merged

[blazor][wasm] JSExport for events#65897
pavelsavara merged 11 commits intodotnet:mainfrom
pavelsavara:blazor_wasm_events

Conversation

@pavelsavara
Copy link
Copy Markdown
Member

@pavelsavara pavelsavara commented Mar 21, 2026

Blazor WASM: Direct JSExport event dispatch

bypass JSON serialization

Summary

Adds direct [JSExport] fast paths for dispatching DOM events from JavaScript to .NET in Blazor WebAssembly, bypassing the existing JSON serialization + DotNetDispatcher reflection pipeline. Covers all built-in event types. Custom events use a JSON JSExport fallback that still avoids DotNetDispatcher overhead. Also applies the same JSExport-first pattern to updateRootComponents.

Performance impact

The existing event dispatch path (invokeMethodAsync('DispatchEventAsync', eventDescriptor, eventArgs)) incurs:

  1. JS side: Build two JS objects (eventDescriptor, eventArgs), serialize both to JSON strings via JSON.stringify
  2. Interop bridge: Pass two JSON strings through the JS-to-.NET interop layer, which routes through DotNetDispatcher.BeginInvokeDotNet or InvokeDotNet
  3. DotNetDispatcher: Look up the DotNetObjectReference by ID, resolve the method DispatchEventAsync by name via reflection/caching, deserialize the JSON parameters
  4. WebEventData.Parse: Deserialize JsonElement eventDescriptor and JsonElement eventArgs, switch on eventName string, call typed *Reader.Read(JsonElement) methods (e.g. MouseEventArgsReader.Read) to reconstruct EventArgs
  5. EventFieldInfo: Deserialize from JSON
  6. Finally: Call renderer.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs)

The new path:

  1. JS side: Call the typed [JSExport] method directly (e.g. DispatchMouseEvent) passing individual primitive values — no object construction, no JSON.stringify
  2. Interop bridge: Primitives are passed directly through the Mono JSExport binding — no string marshaling, no JSON parsing
  3. C# side: Construct the typed EventArgs directly from parameters, call renderer.DispatchEventAsync — no DotNetDispatcher, no reflection, no JsonElement parsing

This eliminates all JSON serialization/deserialization, DotNetDispatcher method resolution, DotNetObjectReference lookup, and WebEventData.Parse overhead per event. For high-frequency events like mousemove, pointermove, scroll, input, and keydown, this is a significant reduction in per-event cost.

Event ordering and WebAssemblyCallQueue

The JSExport methods dispatch events through WebAssemblyCallQueue.Schedule(), which is the same queue used by all other JS-to-.NET calls in Blazor WebAssembly (render batch acknowledgments, BeginInvokeDotNet, etc.).

This is necessary because:

  • Blazor Server parity: Blazor Server processes all JS-to-.NET calls sequentially through the circuit's synchronization context. Events, render batch completions, and JS interop calls are never interleaved mid-execution. WebAssemblyCallQueue replicates this ordering guarantee on the client.
  • Render batch consistency: When a render batch is being processed (WebAssemblyCallQueue.IsInProgress), incoming events must be deferred until the batch completes. Without queuing, a JSExport event call during UpdateDisplayAsync could trigger re-entrant rendering, corrupting the render tree.
  • Event handler lifetime: Event handler IDs can become stale between the time JS dispatches an event and .NET processes it (e.g., a component disposes during a render batch). The queue ensures events are processed in order relative to disposal, matching the same race-condition handling as Blazor Server.

If WebAssemblyCallQueue is idle (no call in progress), Schedule executes the callback synchronously — so for the common case of a user click with no pending work, there is zero additional overhead from queuing.

Covered event types (15 JSExport methods)

Method Events Parameters
DispatchMouseEvent click, dblclick, mousedown, mouseup, mousemove, mouseover, mouseout, mouseenter, mouseleave, contextmenu 18 fields
DispatchKeyboardEvent keydown, keyup, keypress 10 fields
DispatchChangeEventString input, change (string value) 2 fields + fieldInfo
DispatchChangeEventBool input, change (checkbox) 1 field + fieldInfo
DispatchChangeEventStringArray change (multi-select) string[] via [JSMarshalAs] + fieldInfo
DispatchFocusEvent focus, blur, focusin, focusout 1 field
DispatchClipboardEvent copy, cut, paste 1 field
DispatchPointerEvent pointerdown, pointerup, pointermove, pointerover, pointerout, pointerenter, pointerleave, pointercancel, gotpointercapture, lostpointercapture 26 fields
DispatchWheelEvent wheel, mousewheel 22 fields
DispatchTouchEvent touchstart, touchend, touchmove, touchcancel, touchenter, touchleave flat double[] with stride 7 per TouchPoint × 3 lists
DispatchDragEvent drag, dragstart, dragend, dragenter, dragleave, dragover, drop 18 mouse fields + dropEffect, effectAllowed, files[], itemKinds[], itemTypes[], types[]
DispatchProgressEvent load, loadstart, loadend, progress, abort, timeout 4 fields
DispatchErrorEvent error 5 fields
DispatchEmptyEvent cancel, close, toggle, submit 0 fields
DispatchEventJson custom events, any unrecognized event type JSON string + event type name, deserialized via Renderer.GetEventArgsType()

updateRootComponents JSExport path

WebRendererInteropMethods.updateRootComponents now checks for dotNetExports first and calls UpdateRootComponentsCore directly when available, bypassing invokeMethodAsync. Falls back to the existing invokeMethodAsync('UpdateRootComponents', ...) path for non-WASM renderers. All callers use fire-and-forget (none await the result), so the synchronous return type is safe.

Fallback path

DispatchEventJson serves as the JSExport-based fallback for events that don't have a dedicated typed method (custom events, unrecognized event types). It receives the event data as a JSON string and deserializes it using Renderer.GetEventArgsType() + JsonSerializer.Deserialize. This still avoids the DotNetDispatcher overhead — only the JSON deserialization step remains.

For non-WASM renderers (Server, WebView), dotNetExports is unavailable and the entire dispatchEventDirect() path is skipped — events fall back to the original invokeMethodAsync('DispatchEventAsync', ...) path.

Test coverage

Unit tests (11 tests in EventDispatchTest.cs)

Test What it verifies
CreateFieldInfo_ReturnsNull_WhenComponentIdIsZero No EventFieldInfo created for componentId=0
CreateFieldInfo_ReturnsFieldInfo_WithStringValue String field value correctly assigned
CreateFieldInfo_ReturnsFieldInfo_WithBoolTrue Boolean true field value, uses cached BoxedTrue
CreateFieldInfo_ReturnsFieldInfo_WithBoolFalse Boolean false field value, uses cached BoxedFalse
CreateFieldInfo_BoolValues_AreCached Assert.Same on repeated bool boxing (perf optimization)
UnflattenTouchPoints_ReturnsNull_ForNullArray Null input returns null
UnflattenTouchPoints_ReturnsNull_ForEmptyArray Empty input returns null
UnflattenTouchPoints_ReturnsSinglePoint Single TouchPoint (7 doubles) correctly unflattened
UnflattenTouchPoints_ReturnsMultiplePoints Multiple TouchPoints correctly unflattened
UnflattenTouchPoints_Fields_AreCorrectlyMapped All 7 fields (Identifier, ScreenX/Y, ClientX/Y, PageX/Y) map to correct properties
UnflattenTouchPoints_Stride_IgnoresExtraElements Non-multiple-of-7 length doesn't crash (extra elements ignored)

E2E test coverage (src/Components/test/E2ETest)

The following E2E tests exercise the JSExport dispatch paths when running in WebAssembly mode:

Test file Changed paths covered
EventTest.cs Mouse, pointer, focus, drag, touch, keyboard, input, clipboard, progress, error events — most comprehensive
EventBubblingTest.cs Event propagation through the dispatch path
EventCallbackTest.cs EventCallback<T> invocation via event dispatch
EventCustomArgsTest.cs Custom event args — exercises DispatchEventJson fallback
EventFlagsTest.cs preventDefault/stopPropagation on click, wheel events
FormsTest.cs Change/input events with form binding
BindTest.cs Two-way binding — textbox, checkbox, select (change events)
InputFileTest.cs File input change events
VirtualizationTest.cs Scroll events
JSRootComponentsTest.cs updateRootComponents — add/dispose/update root components

New E2E tests added in this PR (in EventTest.cs using ClipboardProgressErrorEventComponent):

Test JSExport method exercised
ClipboardEvents_CanTrigger DispatchClipboardEvent — dispatches copy, cut, paste via ClipboardEvent
ProgressEvents_CanTrigger DispatchProgressEvent — dispatches progress, loadstart, loadend via ProgressEvent
ErrorEvent_CanTrigger DispatchErrorEvent — dispatches error via ErrorEvent

These tests use ToggleExecutionModeServerFixture (defaults to Client/WASM mode) or BlazorWasmTestAppFixture, so they exercise the JSExport code paths.

@pavelsavara pavelsavara added this to the .NET 11 Planning milestone Mar 21, 2026
@pavelsavara pavelsavara self-assigned this Mar 21, 2026
@pavelsavara pavelsavara added the area-blazor Includes: Blazor, Razor Components label Mar 21, 2026
@pavelsavara pavelsavara marked this pull request as ready for review March 22, 2026 14:51
@pavelsavara pavelsavara requested a review from a team as a code owner March 22, 2026 14:51
Copilot AI review requested due to automatic review settings March 22, 2026 14:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a Blazor WebAssembly “JSExport-first” interop path for DOM event dispatch and root component updates, aiming to bypass JSON serialization and the DotNetDispatcher reflection pipeline for improved performance on high-frequency events.

Changes:

  • Added [JSExport]-based typed event dispatch entrypoints (plus a JSON fallback for custom/unhandled events) that schedule dispatch via WebAssemblyCallQueue.
  • Switched updateRootComponents to prefer a [JSExport] path when running on WASM, falling back to invokeMethodAsync for non-WASM renderers.
  • Removed the public JSInteropMethods type and updated navigation/root-component boot logic and tests accordingly.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/Components/WebAssembly/WebAssembly/test/EventDispatchTest.cs Adds unit tests for field-info creation and touch-point unflattening helpers.
src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.Interop.cs Moves/organizes JSExport interop entrypoints (Invoke/BeginInvoke/EndInvoke/byte arrays) into a partial.
src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.EventDispatch.cs Adds JSExport event dispatch fast paths, JSON fallback parsing, and helper methods.
src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs Updates trimming annotations (DynamicDependency) for the new JSExport entrypoints.
src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs Wires the renderer instance into DefaultWebAssemblyJSRuntime for direct dispatch; cleans up on dispose.
src/Components/WebAssembly/WebAssembly/src/PublicAPI.Unshipped.txt Records removal of JSInteropMethods APIs.
src/Components/WebAssembly/WebAssembly/src/Infrastructure/JSInteropMethods.cs Removes the JSInteropMethods public API surface (file left as header-only).
src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs Removes trimming dependencies tied to JSInteropMethods; minor formatting change.
src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs Removes GC.KeepAlive(typeof(JSInteropMethods)) used to preserve JS-invoked methods.
src/Components/Web.JS/src/Rendering/WebRendererInteropMethods.ts Adds WASM direct event dispatch via dotNetExports with typed JSExport calls + JSON fallback; updates updateRootComponents.
src/Components/Web.JS/src/GlobalExports.ts Expands dotNetExports typings with typed dispatch methods and updated root-components method.
src/Components/Web.JS/src/Boot.WebAssembly.Common.ts Updates navigation/root-components paths to use the new JSExport entrypoints.
src/Components/test/testassets/BasicTestApp/Index.razor Adds the new test component to the selector list.
src/Components/test/testassets/BasicTestApp/ClipboardProgressErrorEventComponent.razor Adds a test component for clipboard/progress/error events.
src/Components/test/E2ETest/Tests/EventTest.cs Adds E2E coverage for clipboard/progress/error event dispatch via the new path.

Comment thread src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs Outdated
Comment thread src/Components/WebAssembly/WebAssembly/test/EventDispatchTest.cs
Comment thread src/Components/WebAssembly/WebAssembly/test/EventDispatchTest.cs
Comment thread src/Components/WebAssembly/WebAssembly/test/EventDispatchTest.cs
pavelsavara and others added 2 commits March 22, 2026 18:11
…AssemblyJSRuntime.EventDispatch.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…lyRenderer.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Member

@maraf maraf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants