dotnet/aspnetcore

Support custom event args in Blazor

mkArtakMSFT opened this issue ยท 12 comments

[Design proposal added by @SteveSandersonMS]

Summary

Currently Blazor allows developers to define custom events for their HTML elements. This works by using the [EventHandler] attribute to configure the Razor compiler to recognize a new event name (e.g., @onmyevent=SomeHandler). However, because of both implementation and design limitations, there is currently no way for these events to accept custom argument types, nor is it possible to change/extend the argument types accepted by predefined built-in events. The work here is to:

  • Add the ability to pass custom data with these custom events to become accessible on the .NET side
  • Open question: Possibly, provide a means to override the arguments for existing built-in events.

Motivation and goals

We've had many customer requests for features that would be addressed by custom event arguments. Examples:

  • #13671 - extending built-in events with more information, in this case something to identify which element was clicked
  • #14133 - another request to get additional event data for a standard event, in this case the data from an onpaste event
  • #27651 - defining custom event types with custom data for custom web components

In scope

  1. Define an entirely new custom event along with logic for preparing its event args on the JS side and receiving it on the .NET side
  2. Define customized variants of built-in event types (e.g., onclick) that supply additional custom data from JS to .NET

Out of scope

  1. Anything that affects how events work on component instances (as opposed to plain HTML elements). Component instances already allow arbitrary event names/args, so no further features are needed there. We should not have to change anything about that.

Risks / unknowns

  • Currently, each [EventHandler] declaration's event name must be globally unique, or the Razor compiler throws. Should we loosen that restriction and have some kind of priority/overriding mechanism?

    • If yes, how is that going to compose across 3rd-party libraries? What if LibA wants onclick to supply one set of data, and LibB wants it to supply different incompatible data? What if it's expensive to do LibA's thing - do we still do it all the time, or can we somehow know whether a given event handler is meant to do that? How do we know which 3rd-party library's definition to use in a given case?
    • If no, then how do you override a built-in event? And what if - because we're now encouraging people to declare custom event types - two different 3rd-party libraries happen to define an event with the same "custom" name by coincidence or because it matches a new DOM standard they both want to use? (Perhaps one mitigation to name clashing is that internal types with [EventArgs] should be ignored on referenced assemblies, so each 3rd-party lib can use its own internal event args definitions, but they don't pollute other projects that reference them - this is purely a compile-time thing anyway.)
  • If we tried to auto-serialize all the data from a JS Event object to .NET just in case the custom handler wants to use it, that won't work when the data contains unserializable things or too much data. Seems inevitable the developer will have to supply JS-side logic for determining what data to pass to .NET.

  • If we change aspects of how we know what types to pass to the .NET event handler methods, we might break back-compat in obscure cases. For example, when using .razor syntax the compiler always generates calls to EventCallback.Factory.Create<TEventArgs>, so we know the resulting delegate and the methodgroup are compatible with the [EventHandler] declaration and hence with the hardcoded event args type logic on both JS and .NET sides. However, custom rendertreebuilder code can skip this and use other delegate types, for example wiring up an onclick event to an Action<EventArgs> (I think). If the new logic is stricter about how the types must match, we might break the ability to declare polymorphic event handlers that downcast the incoming eventargs at runtime - a very obscure but possibly valid use case. I suspect we will end up breaking that use case unless we just hard-code rules about built-in event names.

Examples

Basic custom event type

<div @onmycustomevent="HandleMyEvent">Hello</div>

or maybe <some-webcomponent @onmycustomevent="HandleMyEvent"></some-webcomponent>

@code {
    async Task HandleMyEvent(MyEventArgs args) { ... }
}

... with:

// This is the existing mechanism for configuring the Razor compiler to know about @onmycustomevent
[EventHandler("onmycustomevent", typeof(MyEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
static class EventHandlers
{
}

// Inheriting from EventArgs is mandatory if you want us to JSON-deserialize into it
class MyEventArgs : EventArgs
{
    public string SomeCustomProperty { get; set; }
}

... with some JS-side config (these APIs are just sketches - real design to follow):

// This is the only new bit of API in this example
Blazor.eventHandlers.register('onmycustomevent', evt => ({
    someCustomProperty: evt.target.getAttribute('my-attribute')
}));

Of course, there also has to be something that actually triggers a DOM event called onmycustomevent on an element to make this really work end-to-end. Presumably people only do all this if they have some reason to be triggering custom DOM events on elements, for example they are integrating with custom elements, or are adding support for a new built-in browser event.

Supplying extra data with a standard event

Note that onpaste is a standard event, whereas onpastewithdata would be a specialised flavour of it:

<input @onpastewithdata="ProcessPastedData" ... />

@code {
    void ProcessPastedData(PasteWithDataEventArgs args)
    {
        var pastedData = Convert.FromBase64String(args.DataAsBase64);
        // ...
    }
}

... with:

// This is the existing mechanism for configuring the Razor compiler to know about @onmycustomevent
[EventHandler("onpastewithdata", typeof(PasteWithDataEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
static class EventHandlers
{
}

// Inheriting from EventArgs is mandatory if you want us to JSON-deserialize into it
class PasteWithDataEventArgs : EventArgs
{
    public string DataMimeType { get; set; }
    public string DataAsBase64 { get; set; }
}

... with:

// In this sketch, the Blazor event name is the globally unique thing, but the JS-side code is configured to set up listeners
// with a different (non-unique) DOM event name. This allows different "onpaste" listeners to supply different data to .NET.
Blazor.eventHandlers.register(
    'onpastewithdata', // Blazor event name
    'onpaste', // DOM event name
    clipboardEvent => ({
        // See https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/ClipboardEvent
        dataMimeType: clipboardEvent.type,
        dataAsBase64: toBase64Somehow(clipboardEvent.options.clipboardData)
    })
);

Detailed design

There are two goals, in this order of importance:

  1. Ability to define entirely new custom event types
  2. Ability to change what data is reported for built-in event types

This ordering is because [2] can be regarded as a special case of [1]. As long as you can define onmycustompaste and have it occur when a "paste" happens, then you don't need to change the behavior of the default onpaste.

Sure it would be nice to do [2] as well because that's a more obvious match for what people think they need to do, but I'm going to argue we should keep it out of scope for now. Reasons later.

Defining entirely new event types

The Razor compiler already lets you define a custom event type, as follows:

[EventHandler("onmycustomevent", typeof(MyEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
static class EventHandlers
{
}

During compilation it uses the existence of these attributes to know what intellisense to show if you put @onmycustomevent on any HTML element, and what code to generate (e.g., adding an attribute with value EventCallback.Factory.Create<MyEventArgs>(yourCode)).

However, custom arguments don't work end-to-end because currently, there are two places where event args type information is hardcoded:

  1. On the JS side in EventForDotNet.ts, hardcoding the mapping from "DOM event object" (e.g., a JS ClipboardEvent instance) to an "event type" string (e.g., clipboard) and the shape of JSON data we want to send to .NET for this type of event
  2. On the .NET side in WebEventData.cs, hardcoding the mapping that "event type" string (e.g., clipboard) to the .NET args type (e.g., ClipboardEventArgs), into which we deserialize the JSON data

Supporting custom event args means eliminating both sets of hardcoded rules, or at least letting the set be supplemented with custom rules.

Eliminating the hard-coded config on the JS side

I propose defining a new API like this:

Blazor.events.register('onmycustomevent', event => {
    // Converts the JS Event instance to whatever data we want to send to .NET. Example:
    return { elementId: event.srcElement.id, isShiftPressed: event.metaKeys.shift };
});

Of course, by default we'll have registrations for the same set of standard web events already supported.

Why the custom logic is needed

I don't think there's any way to avoid the need for configuring this. We can't just serialize the entire JS Event object by default, as it's not serializable (references DOM elements, etc.). Likewise, developers have to make value judgements (e.g., for a clipboard event, are we going to serialize the pasted data no matter how large, or just give it an ID and send that to .NET?).

Rules

  • Developers can call Blazor.events.register from JS code any time, e.g., when the application is first starting or when certain lazily-loaded component assembly initializes itself.
  • Custom event names must be unique. Trying to register a second custom event with the same name as an earlier one (or the name of a predefined one such as onclick) will throw. Likewise, they can't be unregistered. This is because non-uniqueness just won't compose across independent component libraries.

Custom variants of standard events

We'll also support a further overload of Blazor.events.register that takes a separate DOMEventName parameter:

Blazor.events.register('oncustompaste', 'onpaste' event => {
    ...
});

This treats the event name as oncustompaste as far as all interactions with .NET are concerned, but when registering the actual event listener with browser DOM APIs, uses the event name onpaste.

The DOMEventName parameter values do not have to be unique.

Question: Should we encourage some naming convention when customizing built-in events? For example, you could name your event onpaste.special instead of onspecialpaste - the Razor compiler is already fine with that. I suspect developers will feel better about it, and it composes nicely with directives like @onpaste.special:preventDefault.

Should we even bake that pattern in? That is, instead of having a separate overload of Blazor.events.register, we just impose the rule that if your event name contains a ., then we only take the first segment to be the DOM event name, whereas the whole string is treated as the event name from the .NET perspective? It's pretty nice and simple.

Eliminating the hard-coded config on the .NET side

For the .NET-side logic in WebEventData.cs, I propose more directly eliminating the notion of predefined event types, without needing any extra config. I believe the existing hardcoded set was done only because it was simple, not because it was necessary.

We don't actually need JavaScript to tell .NET the event type, because .NET already has that information. It can look at the attached event handler delegate's type, and simply look at its parameter list via reflection.

Is it bad to use reflection for this? I don't think so:

  • It doesn't harm linkability because if the linker is retaining the delegate's method, it has to retain the type instances on is parameter list anyway.
  • We only do this at the point of invoking an event callback (not when rendering the elements that have event handlers). So it's typically not happening very fast, and in any case, we can cache the delegate type -> event args type mapping.

Back-compat and polymorphic event handlers

Consider the following scenario:

<button @onclick="@HandleEvent" @onfocus="@HandleEvent">Do something</button>

@code {
    void HandleEvent(EventArgs args)
    {
        switch (args)
        {
            case MouseEventArgs mouseEventArgs:
                // Do something
                break;
            case FocusEventArgs focusEventArgs:
                // Do something else
                break;
        }
    }
}

Here, a single method accepts EventArgs and uses run-time type checks to behave differently for different events. You might be concerned that using the declared parameter list to choose how to deserialize the incoming JSON event data would break this, because we'd supply an EventArgs only, and not either of the two subclasses.

However because of how delegates work, this is actually not broken by the change I'm proposing. The @onclick attribute will compile as something that uses EventCallback.Factory.Create<MouseEventArgs>(HandleEvent), and hence produces a delegate instance of type System.Action<MouseEventArgs>. Likewise for @onfocus but with FocusEventArgs. So we actually deserialize the incoming JSON event data as the correct type based on the delegate (we don't actually use the parameter list of the method pointed to by the delegate).

But there is a more obscure case where it would be broken. Consider this:

<button @onclick="@myDelegate" @onfocus="@myDelegate">Do something</button>

@code {
    Action<EventArgs> myDelegate;

    // Constructor
    public MyComponent()
    {
        myDelegate = HandleEvent;
    }

    void HandleEvent(EventArgs args)
    {
        switch (args)
        {
            case MouseEventArgs mouseEventArgs:
                // Do something
                break;
            case FocusEventArgs focusEventArgs:
                // Do something else
                break;
        }
    }
}

There's now only a single delegate instance, and it really does declare EventArgs as the parameter type, so that's all we'll now supply. That's technically a breaking change since before, the hardcoded logic would have supplied MouseEventArgs/FocusEventArgs. The same issue happens if you're writing RenderTreeBuilder logic manually and create event handlers from plain delegates instead of EventCallback<T>.

This is an obscure scenario, but I suspect we still don't want a breaking change here. It's easy to fix though: for the built-in event names, we continue to have built-in hardcoded logic. We only use the delegate's declared parameter type for unrecognized event types.

Supplying custom data for built-in event types

I've already described above how developers could customize built-in event types by using alternate custom names for them (e.g., onspecialpaste or onpaste.special).

Do we want to go further and provide a method for customizing built-in events without having to use custom unique names for this?

If we did this, it would have to compose well across independent 3rd-party libraries. It would have to be valid for two different libraries to customize (say) onpaste without interfering with each other. Somehow, we'd need to know which event handler corresponds to which library's customizations.

My best idea for achieving this would be changing Blazor.events.register to be in terms of the args type name, instead of the event name. For example:

Blazor.events.register('MyPackage.MyPasteEventArgs', event => {
    // Return the JSON blob that will deserialize into MyPasteEventArgs
});

Then, if you have this:

<input @onpaste="SomeHandler" />

@code {
    void SomeHandler(MyPasteEventArgs args)
    {

    }
}

... the fact that you're using MyPasteEventArgs on the handler would trigger the corresponding JS-side code. This would compose beautifully, since each library would add additional ways to handle each event it cares about.

However, one problem with this is getting the JS-side code to know which event args type corresponds to each event handler. The render tree frames don't have space for more per-event reference-type data. We could do it in a complicated way:

  • For WebAssembly, when an event occurs, the JS code could synchronously call the .NET code to ask "Hey, I'm about to raise event 456, so what args type name do you have for that one"?
  • For Server, the existing render batch serialization format happens to have space to include the args type name string in the attribute frame.

It's a bit annoying to have two different strategies to get the info, but we could do it.

It also has the drawback of disclosing a .NET type name to JS, which we try to avoid for Blazor Server. We could work around this by letting people optionally put some unique [EventArgsTypeName("MyUniqueString")] on the corresponding eventargs type. But this is getting hard to understand.

Besides the above, there's one remaining killer problem. None of this solves the compile-time problem of teaching the Razor compiler how to know which TEventArgs to use in the generated code for @onclick=..., if we're saying it's not uniquely determined by the event name. We'd need some entirely new kind of type inference that somehow allows the developer to use any one of the types registered via [EventHandler] for that event name, possibly by codegenning overloads of some method that returns an EventCallback based on the supplied delegate. But this is a whole new level of complexity, and what would it mean for lambdas anyway? They'd immediately become ambiguous.

Overall, while I think this would be a good feature, it could be really expensive to implement, so my proposal is we don't do it initially. We definitely have to support the "entirely custom event names" case, so let's just do that initially and see if people are happy with that. In the future, if people demand the "tweak the behavior of built-in event names" feature we could still add it separately (e.g., via some alternative Blazor.events.registerEventArgsType).


Appendix: why .NET can't determine the event args type by using the TValue that was originally used with EventCallback.Factory.Create<TValue>(delegate)

First, that TValue information isn't stored on the RenderTreeFrame instances in most cases. To reduce allocations we don't store the original EventCallback<T> structs (we'd have to box them). Preserving this data would mean either an extra allocation for every event handler on every render (not acceptable), or expanding the RenderTreeFrame struct (bad as it would increase memory use for every bit of Blazor rendering everywhere, whether or not you use this feature, also diminishing cache locality, and would not be binary-compatible).

Worse still, the TValue doesn't necessarily exist. It's only a detail of the Razor compiler. If you call the AddAttribute method manually, you can supply an arbitrary delegate instance, not just an EventCallback.

Appendix: Alternative considered

The part of the above design I'm most uncomfortable with is relying on uniqueness of the event name, and having the JS-side API in terms of that event name. This may force people to have odd event names, and might lead to unnatural code if, in the future, we create even more extensibility in this area.

If we thought that defining and consuming custom events was a very mainstream part of the programming model, we might consider a more advanced alternative like the following:

  • Retire [EventHandler] attributes as a way of defining custom events, and replace them with extension methods. That is, to define an event, you create an extension method on some standard type like M.A.C.EventHandlers. For example, public static void OnClick(this M.A.C.EventHandlers eventHandlers, MyCustomEventArgs args).
    • Extension methods are nice because it's fine to have any number with the same name (the compiler resolves the overload based on the args), and you can control which ones are in scope on a per-file basis using @using.
  • On the JS side, have the event-args-supplying logic extensible in terms of the arg type. For example, Blazor.registerEventArgsProvider('Fully.Qualified.MyCustomEventArgs', jsEvent => { /* return something JSON-serializable*/ });.
    • At runtime, we'd go through whatever shenanigans we need to determine the args type by asking the .NET code, as discussed above.

Pros of this approach:

  • You can always use any event name you want, including overloading things like @onclick
  • The consumer gets to choose exactly how broadly it's in scope (e.g., globally via _Imports.razor, or on a per-file basis)

Cons of this approach:

  • Possible hard-to-understand new compilation errors due to ambiguity. For example, trying to use a plain lambda with @onclick might be fine by default, but later adding @using SomeThirdPartyNamespace might cause that existing code to break because the compiler now sees it as ambiguous.
  • Either have to disclose the FQN of the args type to JS, or have to define some other globally-unique string within the extensibility mechanism. This would be transparent to consumers, but hard for library authors to make sense of.
  • No runtime polymorphism of event args types. If your delegate claims to receive EventArgs, we can't know which subclass it might cast to internally.
  • A lot more expensive to implement. Not even 100% certain the Razor compiler can trigger availability of tag helpers based on the presence of extension methods in scope.
  • Makes the existing extensibility mechanism redundant, but we can't remove it, so we're left with two independent extensibility mechanisms for custom events

Overall I do think this alternative model is more powerful and ultimately better, but since it's not a mainstream part of the programming model, I don't think the cost to implement and deal with leaving behind a redundant older extensibility model is justified.

Would it be okay for you to add a description to the tickets, so it's users who follow them can understand what is being done?

Is it possible to make some short explanation for this issue:

  • what is the problem it is trying to solve ?
  • how do you plan to solve it ?

Since you are referring this issue as reason to close several other issues, it would help if we can see how this one is supposed to help.

Thanks for contacting us.
We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We will evaluate the request when we are planning the work for the next milestone. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

Thanks for contacting us.
We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We will evaluate the request when we are planning the work for the next milestone. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@dotnet/aspnet-blazor-eng I'm working on the design here, and have added the standard design proposal template above. If you have comments, please let me know!

I think we've already had enough team discussions about this that the high-level goals are already pretty well agreed on, so I'm going to proceed with more implementation design. But if anyone on the team has concerns or differing opinions about the overall scope and direction (compared with the proposal above), please let me know as soon as you can.

Hello Mr. Sanderson,

I originally requested #27651 for custom web components, since we are using them. First of all thank you for this quick response.

Two things I want to put in to consider for custom web components:
We are using LitElement a WebComponent Framework, lit element has already a implementation of inline custom events for javascript: If I put this in my code

<my-link navigationtitle="Events sample" @link-clicked="${(e) => alert(`Item '${e.detail.itemid}' clicked`)}">

the alert will shown when the custom event is fired. LitElement implements this by checking if a attribute is called @ + event/CustomEvent name - here the CustomEvent. Then the value is run. I am not shure how it is run if it checks for a dollar sign or the javascript is interpreted.
See Here: https://lit-element.polymer-project.org/guide/events#add-declarative-event-listeners

Since blazor also uses the @-Syntax this should maybe not result in the handler ending up in the html code, since that could interfere with the web component framework. I am shure LitElement and Blazor are not the only frameworks using (at) for custom elements (Angular? Vue? React?)

Now I also asked in the LitElement Git Before I I found out this solution for LitElement
lit/lit-element#1101

Also I asked in the WICG Standardization if there should be a standard how to define event handlers for CustomEvents in html.
WICG/webcomponents#908

So far the only standard is HtmlElement.addEventListener();

What is also to consider: CustomEvents always pass custom Parameters under .detail so myevent.detail.mytextvar or myevent.detail.myintvar. So maybe there could a CustomEventArgs(Of TDetail) class?
https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail

Regarding the risks with EventHandler having unique names:
two componetns can easily have the same event name but complettly different parameters under event.detail.

Thank you for your time.

Design work is done now. Ready for implementation.

@mkArtakMSFT Moving this to preview 2 for the actual implementation work.

@SteveSandersonMS : This feature included in .NET v6.0.0-preview.1 release ?

@SteveSandersonMS : This feature included in .NET v6.0.0-preview.1 release ?

It'll be in 6.0-preview2, @pandiyarajm93.