#13855·MudBlazor

Fix key interceptor failure modes and reduce per-instance cost

Author: danielchalmersCreated Sep 14, 2026Updated Sep 15, 2026
Labelsbugbreaking changeperformance

#13853 shared one key interceptor per MudList and cut a 1000-item list's mount from 733 to 197 ms. It was closed because it moved listener lifetime from the items to the list and missed class overrides, runtime KeyboardEnabled changes and failed hookups. Those failure modes, and a few others, already exist on dev in other forms. This issue scopes a dedicated sweep of every key interceptor usage for v9.x.0 (non-breaking) and v10 (DOM changes).

Where the interceptor is used

12 components subscribe through IKeyInterceptorService. Keys reach .NET in one of two ways:

  • Prevent-only: JS only applies preventDefault, and the component's own @onkeydown calls DispatchAsync. MudCheckBox, MudRadio, MudSwitch, MudChip, MudListItem and MudNumericField work this way. MudMenu and MudTabs register observers that are never called.
  • JS invokes .NET: MudSelect, MudPicker and MudDialogContainer register /./ with subscribeDown and subscribeUp, and MudMask with subscribeDown. Every keydown and keyup inside them is a JS to .NET call, including typing in any text field inside a dialog.
Component Instances per page Opt-out
MudListItem N, 2N with MultiSelection (item plus checkbox) KeyboardEnabled
MudCheckBox N: table and DataGrid row selection, TreeView, multi-select lists KeyboardEnabled only gates the handlers; it still subscribes and still prevents Space
MudChip N clickable or closable chips inert chips skip it (#13695)
MudRadio, MudSwitch N per group, one switch per column in the DataGrid column chooser none
MudNumericField N: ColorPicker has 7, DataGrid filter rows none (#13761)
MudSelect, MudPicker, MudMask, MudDialogContainer, MudTabs, MudMenu 1 each none

Problems on dev

Correctness:

  • A failed connect is permanent. SubscribeAsync ignores the result of connect, and the observer stays registered, so later subscribes take the "already subscribed" path and never retry. MudPicker's _keyInterceptorObserving latch is set before the await and never reset.
  • ObserverManager.NotifyAsync swallows a handler exception and removes the observer, logging only at Trace. JS stays connected, so the component's keys stop working while their browser defaults are still prevented. For example, a user OnKeyDown on MudSelect that throws once leaves the select's arrows, Space and Enter dead until it remounts.
  • TryGetOrAddSubscription replaces an existing observer without reconnecting JS, so new options are ignored. Two components sharing an id (a user id on MudListItem) take over each other's handlers.
  • MudMenu doesn't unsubscribe on dispose while open. MudListItem and MudChip record their subscription after the await, so disposing during it leaks the observer.
  • targetClass matching includes descendants, so inputs inside a clickable chip, a tab header or a list item lose Space, Backspace or the arrows (#10391, #12989).

Cost:

  • One interop call, two listeners and about 30-40 .NET objects per subscribed instance. #13695 measured about 2.3 KB per chip subscription.
  • UnsubscribeAsync always calls JS disconnect, even for ids that never subscribed.
  • On Blazor Server each keystroke in a dialog costs 2 extra JS to .NET messages, and 4 for a select inside a dialog.

Proposed scope

v9.x.0, no DOM change:

  1. Service fixes: retry after a failed connect, skip disconnect for unknown ids, surface or at least log handler exceptions without leaving keys dead, and unsubscribe MudMenu on dispose and fix the subscribe-in-flight leaks.
  2. Less JS to .NET traffic: MudDialogContainer and MudSelect subscribe /./ only for the directions a user handler or built-in behaviour needs, and MudPicker drops keyup.
  3. Prevent-only registration: components that already handle keys in @onkeydown switch on the key directly and connect with static options and no observer. MudTabs and MudMenu drop their dead observers.
  4. A spike on MudChip without an interceptor, since a clickable chip is a <button> whose Space and Enter defaults already click. It needs verifying in Chrome, Firefox and Safari.

v10, DOM change:

  1. One delegated interceptor per container (MudList, MudTable/MudDataGrid row selection, MudTreeView, MudChipSet, MudRadioGroup) matching a data attribute that items render only while they can handle keys. The container reconnects until the connect succeeds. This covers all three #13853 failure modes. Decide at the same time whether editable descendants are excluded.

Rejected: one document-level listener (can't emulate per-element stopPropagation), subscribing on focusin (races the next keystroke on Server), and @onkeydown:preventDefault (static per render, traps Tab).

Measure before and after

Count mudKeyInterceptor.connect calls and JS to .NET messages, and time mount to a quiet DOM in the browser:

Scenario Today
MudList, 1000 items 1000 connects, 733 ms mount (197 ms with one per list)
MudList, 1000 items, MultiSelection 2000 connects
MudDataGrid, 500 rows, MultiSelection 500 connects
MudTreeView, 1000 checkable items 1000 connects
MudChipSet, 200 clickable closable chips 200 connects
100 keystrokes in a MudTextField inside a MudDialog, Server 200 JS to .NET calls

Regression matrix

WASM and Server with latency, for every changed component:

  • Class and id overrides through Class and UserAttributes, on div and anchor items
  • KeyboardEnabled, Disabled and ReadOnly toggled at runtime, including the last enabled item going away
  • Items added, removed and reordered, and virtualized lists
  • Nested lists, a checkbox inside a list item, and a select or picker inside a dialog (Escape closes only the popup)
  • Popover content: menus and submenus, select, autocomplete
  • Tab and Shift+Tab never trapped
  • Space doesn't scroll or double-toggle, Enter doesn't submit from a checkbox, and arrows, Home and End don't scroll
  • Anchors: Enter follows the link
  • Text inputs nested in a chip, tab header and list item
  • A user OnKeyDown handler that throws
  • Keys pressed right after mount and right after opening a popup on Server