Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EventCallback: Updates to Event Handling and Binding #6351

Closed
rynowak opened this issue Jan 3, 2019 · 2 comments
Closed

EventCallback: Updates to Event Handling and Binding #6351

rynowak opened this issue Jan 3, 2019 · 2 comments
Assignees
Labels
area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one

Comments

@rynowak
Copy link
Member

rynowak commented Jan 3, 2019

Summary

In ASP.NET Core 3.0.0-preview3/Blazor-0.9 we're making a big set of changes to how event routing works in Components. Powering this is a new feature called EventCallback that every Components user will want to learn about. If you've ever had to add a manual StatehasChanged to get rendering to occur properly then these are changes you will probably be interested in.

What's changing

Event Handlers and bind-... are having their event routing improved to be more intuitive. Consider the following components:

@* MyButton.razor *@

<button onclick="@OnClick">Click here and see what happens!</button>

@functions {
  [Parameter] Action<UIMouseEventArgs> OnClick { get; set; }
}


@* UsesMyButton.razor *@

<div>@text</div>
<MyButton OnClick="@ShowSecretMessage" />

@function {
    string text;
    void ShowSecretMessage(UIMouseEventArgs e)
    {
        text = "Hello, world!";
    }
}

If you've been using Components so far, you would expect that when you click the button, the MyButton component will re-render after the UsesMyButton.ShowSecretMessage method is called. We call this behaviour event routing. When the onclick event fires in the browser, the rendering process will notify the MyButton component that an event occured, and (by default) MyButton will re-render. UsesMyButton does not re-render because it was never notified about the event. The correct way to describe event routing in previous releases is to say that events are routed to the component that registered them.

If you wanted UsesMyButton to also re-render then you would have to insert a manual call to StateHasChanged(). Here's the corrected method:

    string text;
    void ShowSecretMessage(UIMouseEventArgs e)
    {
        text = "Hello, world!";
        StateHasChanged();
    }

There are a few more common cases where this could occur, but they can all be generalized under the statement:

You need to call StateHasChanged if a child component is going to call your method.


Well not anymore.

We're making some changes that are going to make this just work for all of the common cases. Instead of routing events to the component that registered the event, we are going to route them based on the delegate target.

This seems kind of esoteric at first, but we can simplify it a bit. Delegates (lambdas, converted from methods) all have a .Target property.

The official docs describe it as:

The object on which the current delegate invokes the instance method, if the delegate represents an instance method; null if the delegate represents a static method.

So if you pass an instance method (like ShowSecretMessage) it means that the event will be now routed to UsesButtonComponent. This is inuitive because the whole purpose of an event handler is change the state of something. Now the event will be routed to the component whose state changed. With the updated event routing behavior the previous example will just work.

What about a lambda? Well lambdas have a .Target as well. Usually what happens in a lamdba that changes a component's state is that you will close over the current component. The compiler adds a hidden instance method to the component class, and when the delegate object is created, its .Target will refer to the component. There are some cases where this doesn't happen, and that will be explained further later.

So we could also write UsesMyButton like the following, and it will just work with the new event routing.

@* UsesMyButton.razor *@

<div>@text</div>
<MyButton OnClick="@(() => { text = "Hello, world!"; }" />

@function {
    string text;
}

Another problem

However if you wrote UsesMyButton like the following, you'd have a problem.

@* UsesMyButton.razor *@

<div>@text</div>

@{ var message = "Hello, world!"; }
<MyButton OnClick="@((e) => { text = message; }" />

@function {
    string text;
}

Now the lambda is a full closure because it closes over the message variable. The .Target property won't be the MyButton but will instead by an instance of a generated class. So when the button is clicked, there's nowhere to route the event.

We have a solution for this too.

Introducing EventCallback... We're adding a new basic building block to the Components programming model. EventCallback is a new value type that wraps a delegate and attempts to smooth over several of the problems that are common using delegates between components.

Here's the same sample with an EventCallback<UIMouseEventArgs>.

@* MyButton.razor *@

<button onclick="@OnClick">Click here and see what happens!</button>

@functions {
  [Parameter] EventCallback<UIMouseEventArgs> OnClick { get; set; }
}


@* UsesMyButton.razor *@

<div>@text</div>
@{ var message = "Hello, world!"; }
<MyButton OnClick="@((e) => { text = "Hello, world!"; }" />

@function {
    string text;
}

Notice the only code change here is that we changed Action<UIMouseEventArgs> to EventCallback<UIMouseEventArgs>. However, we've now solved the problem with event routing. The compiler has built-in support for converting a delegate to an EventCallback, and will do some other things to make sure that the rendering process has enough information to dispatch the event properly.

The generated code for this looks like:

builder.AddAttribute(2, "OnClick", EventCallback.Factory.Create<UIMouseEventArgs>(this, <your code here>);

So the EventCallback is always created with a reference to the component that created it. We also do some optimizations to try and limit the amount of allocations this creates (EventCallback is a struct, but will be boxed in some cases).

From the point of view of MyButton - it gets a value of EventCallback<> and passes that off to the onclick event handler. Event handlers in Components now understand EventCallback<> as well as delegates. The experience for using EventCallback should be smooth at this point. You can use it in place of an Action for event handlers and bind-....

The event handling infrastructue still understands delegates. So if you wrote C# code that uses the RenderTreeBuilder directly, it will still work. Using C# to program against new component code that exposes EventCallback as a parameter will need to use EventCallback.Factory to create values.

Adapting delegate types

Another feature of EventCallback is that it provides much better support for flexibility with delegates than we had before. Remember MyButton?

@* MyButton.razor *@

<button onclick="@OnClick">Click here and see what happens!</button>

@functions {
  [Parameter] Action<UIMouseEventArgs> OnClick { get; set; }
}

The author of MyButton made a choice that OnClick has to be a synchronous method that accepts a UIMouseEventArgs as an argument. Using basic delegates it's not possible to assign a Func<UIMouseEventArgs, Task> or an Action to this because the types are incompatible. If you need to do async work in this situation, your only option is async void.

We don't like async void as much as an option because it requires manual error handling - and we've tried to make error handling more predictable as well during this release.

Fortunately, using EventCallback<T> allows us to solve this problem as well. The set of overloads on EventCallback.Factory enables to convert a bunch of different delegate types to work transparently, and the compiler is smart enough to call EventCallback.Factory for you in a .razor file.

Doing the following will now just work - with proper error handling.

@* MyButton.razor *@

<button onclick="@OnClick">Click here and see what happens!</button>

@functions {
  [Parameter] EventCallback<UIMouseEventArgs> OnClick { get; set; }
}


@* UsesMyButton.razor *@

<div>@text</div>
@{ var message = "Hello, world!"; }
<MyButton OnClick="@(async () => { await Task.Yield(); text = "Hello, world!"; }" />

@function {
    string text;
}

Invoking EventCallback manually

You can invoke an EventCallback like: await callback.InvokeAsync(arg).

Yes, EventCallback and EventCallback<> are async. We think that this will work well, because it's now much easier to use async thanks to the other improvements we made in this area. EventCallback<> is strongly typed and requires a specific argument type to invoke. EventCallback is weakly-typed and will allow any argument type.

TLDR Migration Guidance

We recommend using EventCallback and EventCallback<T> when you define component parameters for event handling and binding. The only place you can't use these new types is for child content - those should still be RenderFragment or RenderFragment<>.

Prefer EventCallback<> where possible because it's strongly typed and will provide better feedback to users of your component. Use EventCallback when there's no value you will pass to the callback.

Remember, consumers don't have to write different code when using a component because of EventCallback. It should be very un-impactful to adopt this, and hopefully you find that it resolves a few thorny problems and makes the experience better for you. As with all things we welcome your feedback, especially code samples of things that are hard or don't work well.

Known issues

One known issue is that we had to weaken the compiler's enforcement of strong-typing in this release. In particular, it should be possible to assign a delegate value in an attribute to just about any parameter. These cases are mostly non-sensical, and they will compile but fail at runtime.

We unfortunately had to do this because 3.0-preview3 does not line up with a Visual Studio release - Visual Studio 2019 is not public yet but is in a stabilization period and we will not be able to add new features until the next release. The problem with this exactly is that the editor sees all of the new types, but has the old code generation with no knowledge of EventCallback. There were a few cases where a common usage of this new feature would cause spurious errors in the editor. We had to scramble to workaround this problem by making a change to the runtime, resulting in weaker type-checking.

This is a fairly major compiler feature for us to without a corresponding tools update, so we are very excited to bring it to you!

@rynowak rynowak added Needs: Design This issue requires design work before implementating. area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates labels Jan 3, 2019
@rynowak rynowak added this to To do in Blazor via automation Jan 3, 2019
@rynowak rynowak moved this from To do to Design in Blazor Jan 3, 2019
@SteveSandersonMS SteveSandersonMS added the area-blazor Includes: Blazor, Razor Components label Feb 6, 2019
@mkArtakMSFT mkArtakMSFT added this to the 3.0.0-preview3 milestone Feb 6, 2019
@danroth27
Copy link
Member

danroth27 commented Feb 7, 2019

@rynowak Do you expect to handle #5504 as part of this design review? Or should we treat chaining binds as a separate issue?

@rynowak
Copy link
Member Author

rynowak commented Feb 7, 2019

Yes.

@rynowak rynowak moved this from Design to In progress in Blazor Feb 15, 2019
@rynowak rynowak added 2 - Working and removed Needs: Design This issue requires design work before implementating. labels Feb 15, 2019
@rynowak rynowak changed the title Design Review: Binding EventCallback: Updates to Event Handling and Binding Feb 23, 2019
@rynowak rynowak added Done This issue has been fixed cost: XL and removed 2 - Working labels Feb 23, 2019
@rynowak rynowak closed this as completed Feb 23, 2019
Blazor automation moved this from In progress to Done Feb 23, 2019
@danroth27 danroth27 added the enhancement This issue represents an ask for new feature or an enhancement to an existing one label Feb 25, 2019
@rynowak rynowak mentioned this issue Mar 4, 2019
56 tasks
@mkArtakMSFT mkArtakMSFT removed area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates labels May 9, 2019
@ghost ghost locked as resolved and limited conversation to collaborators Dec 3, 2019
@jaredpar jaredpar removed this from Done in Blazor Jan 7, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one
Projects
None yet
Development

No branches or pull requests

4 participants