Skip to content
This repository has been archived by the owner on Sep 26, 2024. It is now read-only.

Support @bind:get, @bind:set, @bind:after #70

Merged
merged 33 commits into from
Jun 21, 2022

Conversation

javiercn
Copy link
Member

@javiercn javiercn commented Feb 11, 2022

Adds support for bind:get, bind:set, bind:after

This PR introduces a new syntax for bind that allows specifying an action to run after the binding or specifying the setter for the binding directly.

There are two companion PRs to this one:

There are in general three scenarios to cover:

  • Binding to elements like div, button, etc.
  • Binding to input elements like <input type=text />.
  • Binding to components.

When binding to components the receiving property can either be an Action<T>, Func<T,Task>, Func<T,ValueTask> or an EventCallback<T> and we need to generate different code for each one of them.

When binding to elements we rely on new overloads of CreateBinder that take in an EventCallback<T> with the appropriate T for the elements that we know how to bind to in addition to the existing overloads that receive an Action<T>. For example, for T = bool?:

public static EventCallback<ChangeEventArgs> CreateBinder(
        this EventCallbackFactory factory,
        object receiver,
        EventCallback<bool?> setter,
        bool? existingValue,
        CultureInfo? culture = null)
    {
        return CreateBinderCore<bool?>(factory, receiver, setter, culture, ConvertToNullableBool);
    }

Within the compiler we take care of creating the EventCallback using a CreateInferredEventCallback helper when the developer uses bind:set or bind:after. For example:

            __builder.AddAttribute(2, "myevent", global::Microsoft.AspNetCore.Components.EventCallback.Factory.CreateBinder(this, global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, callback: ValueChanged, value: ParentValue), ParentValue));

and

            __builder.AddAttribute(2, "myevent", global::Microsoft.AspNetCore.Components.EventCallback.Factory.CreateBinder(this, global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, callback: __value => { ParentValue = __value; return global::Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, callback: DoSomething).InvokeAsync(); }, value: ParentValue), ParentValue));

For components we also have to deal with the cases where the component property is an Action<T> or a Func<T,<<awaitable>>> where <<awaitable>> follows the same rules as the C# compiler for determining if using await is supported and covers Task, ValueTask, and any future addition. In these cases we can't leverage CreateInferredCallback and instead we rely on two helper methods InvokeSynchronousDelegate and InvokeAsynchronousDelegate that are provided by the runtime. These two methods help us make possible to invoke the provided expression by the developer no matter whether it is a method group or a lambda and take care of producing an error when the developer tries to bind to an Action with a function returning an awaitable result.

For example, for:

namespace Test
{
    public class MyComponent : ComponentBase
    {
        [Parameter]
        public int Value { get; set; }
        [Parameter]
        public Func<int, Task> ValueChanged { get; set; }
    }
}
<MyComponent @bind-Value:get="ParentValue" @bind-Value:after="Update" />
@code {
    public int ParentValue { get; set; } = 42;
    public Task Update() => Task.CompletedTask;
}

we generate code like the one below:

            async __value => { ParentValue = __value; await global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate(Update); });

@javiercn javiercn requested a review from a team February 11, 2022 13:51
@@ -261,6 +263,57 @@ bool HasTypeParameter(ITypeSymbol type)
}
}

private static string IsAwaitable(IPropertySymbol prop)
Copy link
Member

Choose a reason for hiding this comment

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

I have to admit I don't really have any sense of what this is for. Could you briefly summarise what the IPropertySymbol values here represent (e.g., where they come from) and what difference it makes whether we want to regard them as awaitable or not?

Copy link
Member Author

@javiercn javiercn Feb 17, 2022

Choose a reason for hiding this comment

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

We need for component bindings in the form of Value and ValueChanged where ValueChanged can be Action<T> or Func<T, Task>.

Unlike for other bindings when we can always create an EventCallback, in this case we can't (in the synchronous case at least). So what this method does is, when we are inspecting the ValueChanged property, (which we already know is a delegate) we check to see if it has a return type, and if that return type is awaitable, which covers Task, ValueTask, and any other potential awaitable in the future.

The logic in this method is the same that the roslyn compiler uses to make that determination.

We use this information during the bindloweringphase to generate either an Task returning callback or a void returning callback

@@ -109,6 +109,8 @@ public static class RuntimeHelpers
{
public const string TypeCheck = "global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck";
public const string CreateInferredEventCallback = "global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback";
public const string InvokeSynchronousDelegate = "global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeSynchronousDelegate";
public const string InvokeAsynchronousDelegate = "global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.InvokeAsynchronousDelegate";
Copy link
Member

Choose a reason for hiding this comment

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

I'd be interested to see how these helpers will look (e.g., their range of overloads). Does that info exist anywhere yet?

Copy link
Member Author

Choose a reason for hiding this comment

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

IntermediateToken - - CSharp - global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => ParentValue = __value, ParentValue)
IntermediateToken - - CSharp - global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this,
IntermediateToken - - CSharp - __value => ParentValue = __value
IntermediateToken - - CSharp - , ParentValue)
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure if breaking this over multiple lines is for an important reason, but it does add a lot of noise to the baseline diffs.

Is there any chance you could put this part of the code formatting back to how it was before, to minimize baseline churn, and then maybe do a separate PR (or at least an independent commit for it at the end of this PR after the code review is done)?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's quite painful to do this, instead I would suggest the following:

  • Clone the PR:
    • run git diff origin/main --diff-filter=M -- 'src/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/**' to isolate these changes
    • run git diff origin/main --diff-filter=A -- 'src/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/**' to only look at new baselines

@SteveSandersonMS
Copy link
Member

the way the PR/Commits are structured will make our reviewing life's miserable, so we need to discuss how best break down this PR for easier review

Yep, it is a bit of a challenge, but I think we can handle it. The main issue is just trying to keep the baseline diffs to a human-readable level. I suggest avoiding the existing test case name changes until right at the end when everything is done and signed-off, and similarly revert the change that splits up the generated CreateInferredEventCallback call over multiple lines until everything else is signed off.

Likewise, if you could avoid doing any squash-rebasing from here on, it will make it possible just to review the subsequent commits and not have to go back over the existing changes.

@SteveSandersonMS
Copy link
Member

So far, this looks like a great enhancement!

@javiercn javiercn force-pushed the javiercn/improved-bind-primitives branch from 99bef6d to 1b7302a Compare March 15, 2022 15:52
@javiercn
Copy link
Member Author

🆙 📅

@javiercn javiercn marked this pull request as ready for review March 23, 2022 15:41
@javiercn javiercn force-pushed the javiercn/improved-bind-primitives branch from 115b37a to 3c4b8e7 Compare March 24, 2022 14:58
@javiercn
Copy link
Member Author

@SteveSandersonMS is there any concrete feedback on the PR you want us to discuss or are we good to move forward?

@javiercn javiercn force-pushed the javiercn/improved-bind-primitives branch from 96e14b5 to eaab3d6 Compare June 21, 2022 17:43
@mckaragoz
Copy link

Is there any article that explains a bit more? Don't we need to call ValueChanged anymore, or we will call in after method?

@boukenka
Copy link

@mckaragoz Please have a look at ASP.NET Core Blazor data binding

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants