CommunityToolkit / dotnet

.NET Community Toolkit is a collection of helpers and APIs that work for all .NET developers and are agnostic of any specific UI platform. The toolkit is maintained and published by Microsoft, and part of the .NET Foundation.
https://docs.microsoft.com/dotnet/communitytoolkit/?WT.mc_id=dotnet-0000-bramin
Other
2.81k stars 279 forks source link

[Feature] APIs to one-time subscribe to/await events #7

Open Sergio0694 opened 4 years ago

Sergio0694 commented 4 years ago

Follow up from a request by @michael-hawker on his latest Twitch stream.

Describe the problem this feature would solve

This issue is about having some APIs to allow users to subscribe to events just once, or to asynchronously await for an event to be raised, without having to manually define the handler, register/unregister the event from within the handler, and setup Task-s to await if needed.

Note: that this issue is mostly just to gather feedbacks and discuss the idea at this point, I'm not actually opening a PR to add this feature. This is just to have a reference after the previous conversation.

Describe the solution

I came up with a simple class that adds 3 methods that can be used to solve this issue. The signature can arguably seem a bit weird, but I don't think there's another way to do this at least at the moment, especially on UWP. Code generators might come in handy in the future, but those are still a ways off:

public static class EventAwaiter
{
    public static async Task Await<THandler>(
        Action<THandler> register,
        Action<THandler> unregister,
        THandler handler)
        where THandler : Delegate;

    public static async Task Await<THandler>(
        Action<THandler> register,
        Action<THandler> unregister)
        where THandler : Delegate;

    public static async Task<TArgs> Await<THandler, TArgs>(
        Action<THandler> register,
        Action<THandler> unregister)
        where THandler : Delegate
        where TArgs : class;
}

The full code can be found in my gist here.

The way these work is by creating a TaskCompletionSource<T> behind the scenes, then using LINQ expression trees to wire up a custom handler that sets that task source, registering the handler, awaiting the task and then unregistering the handler. This is all completely transparent to the user, which just sees a nice API that supports the TPL workflow. Here's a sample usage:

var button = new Button();

// BEFORE
TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

void Handler(object s, EventArgs e)
{
    tcs.TrySetResult(null);
    button.Loaded -= Handler;
}

button.Loaded += Handler;

await tcs.Task;

// AFTER
await EventAwaiter.Await<RoutedEventHandler>(
    h => button.Loaded += h,
    h => button.Loaded -= h);

Those two lambdas to register/unregister events are needed right now as C# doesn't have proper support for registering events externally (this is by design, as far as I know).

Describe alternatives you've considered

One possible alternative would be to leverage the APIs in the System.Reactive package, though that would require a whole new dependency just for this, and would still result in a very similar API surface for users anyway. Also that would probably involve even more overhead. With the proposed solution instead the whole thing is 100% self-contained and without external dependencies.

ghost commented 4 years ago

Hello, 'Sergio0694! Thanks for submitting a new feature request. I've automatically added a vote 👍 reaction to help get things started. Other community members can vote to help us prioritize this feature in the future!

michael-hawker commented 3 years ago

@Arlodotexe you had some similar helpers somewhere for this as well? I can't seem to find the conversation we had about it in Discord.

Would love to know your thoughts on comparison or if you'd use this type of setup instead?

Arlodotexe commented 3 years ago

@michael-hawker Yep, I've got something for this in my OwlCore library, though it's quite different from @Sergio0694's implementation. It's shorter / simpler, doesn't use reflection, and requires the user pass a timeout for cleanup. The timeout is a "better safe than sorry" thing to avoid potential memory leaks, though I haven't spent a lot of time with it, I'm open to feedback there.

Here's the current implementation:

/// <summary>
/// Helpers related to Threading.
/// </summary>
public static partial class Threading
{
    /// <summary>
    /// Waits for an event to fire. If the event doesn't fire within the given timeout, a default value is returned.
    /// </summary>
    /// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
    public static async Task<(object? Sender, TResult Result)?> EventAsTask<TResult>(Action<EventHandler<TResult>> subscribe, Action<EventHandler<TResult>> unsubscribe, TimeSpan timeout)
    {
        var completionSource = new TaskCompletionSource<(object? Sender, TResult Result)>();
        var resultCancellationToken = new CancellationTokenSource();

        resultCancellationToken.CancelAfter(timeout);
        subscribe(EventHandler);

        try
        {
            var result = await Task.Run(() => completionSource.Task, resultCancellationToken.Token);
            unsubscribe(EventHandler);
            return result;
        }
        catch (TaskCanceledException)
        {
            unsubscribe(EventHandler);
            return null;
        }

        void EventHandler(object sender, TResult eventArgs)
        {
            completionSource.SetResult((sender, eventArgs));
        }
    }
}

Example usage (pulled straight from Strix v2):

var updatedState = await Threading.EventAsTask<CoreState>(cb => core.CoreStateChanged += cb, cb => core.CoreStateChanged -= cb, timeAllowedForUISetup);
if (updatedState == null)
    // Timed out and cleaned up
else
    // Do stuff with updatedState.Sender and updatedState.Result.

edit: @Sergio0694 pointed out that this only works with EventHandler<T>, which makes sense since it's the minimum I've needed so far. Source generators might be able to make this more dynamic.

michael-hawker commented 3 years ago

Feel like Source Generators could help with this now, eh?

michael-hawker commented 2 years ago

@Sergio0694 this would be a .NCT thing. Wondering if we want a label for all these types of things so we can move them over to the new repo when we're ready? Think that's something easy for you to identify and label any existing issues on?

Sergio0694 commented 2 years ago

@michael-hawker We already have the .NET Standard label we've been using for general .NET APIs as well, would adding that to this issue work? 🙂

MisinformedDNA commented 1 year ago

A source generator solution now exists: https://github.com/reactivemarbles/ObservableEvents.

michael-hawker commented 1 year ago

@MisinformedDNA thanks for the link. Not sure if I understand the full application based on the small sample they have. I think all the source generator projects I see are still using the v1 non-incremental source generators though too. @Sergio0694 have they not deprecated that interface in the BCL yet? 🙁

Was just thinking something like this could be handy for DependencyProperty changed callback registration, but that's UI specific, so probably needs its own helper in the WCT...

Sergio0694 commented 1 year ago

V1 generators are not deprecated yet, unfortunately. Not to mention a lot of V2 out there are also terribly inefficient as well.

MisinformedDNA commented 1 year ago

@michael-hawker The linked library allows one to add Events() to any class. It then exposes all the events via Observables. There's a PR for the v2 generator, but a couple tests are failing. The maintainer is also a maintainer on reactiveui.