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

Custom event args #29993

Merged
merged 25 commits into from
Feb 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8e5e7d4
Mark redundant extension methods as obsolete.
SteveSandersonMS Feb 8, 2021
ea7c7c2
Add E2E test showing polymorphic event handlers work today
SteveSandersonMS Feb 8, 2021
7b58208
Clean up JS-side code to only send raw event args (which does include…
SteveSandersonMS Feb 8, 2021
b37c11a
When necessary, parse the event args JSON using the parameter type fr…
SteveSandersonMS Feb 8, 2021
eb391df
Fix event mapping
SteveSandersonMS Feb 8, 2021
60d67eb
Simplify event dispatching on TypeScript side more. Eliminate unneces…
SteveSandersonMS Feb 9, 2021
f0a78bb
Rename file for clarity
SteveSandersonMS Feb 9, 2021
6d2694d
Migrate "preventDefault for submit" behavior into EventDelegator beca…
SteveSandersonMS Feb 9, 2021
1792351
Put event-centric files into an Events directory for more clarity
SteveSandersonMS Feb 9, 2021
badc38f
Disentangle event dispatch from BrowserRenderer
SteveSandersonMS Feb 9, 2021
c832baa
Update comment
SteveSandersonMS Feb 9, 2021
08967e8
Add a cache for the handler->argstype lookup
SteveSandersonMS Feb 9, 2021
66f7dba
Create a registry of event types so we'll be able to add custom ones …
SteveSandersonMS Feb 9, 2021
c9ca4b8
Public API for registering custom event types
SteveSandersonMS Feb 9, 2021
11cbd20
Dispatch events to any registered event type aliases too
SteveSandersonMS Feb 10, 2021
6ca5e3a
Back-compat for unregistered event types
SteveSandersonMS Feb 10, 2021
c1523ff
Update some older E2E tests
SteveSandersonMS Feb 10, 2021
3a9c621
Begin on E2E scenarios
SteveSandersonMS Feb 10, 2021
34c4338
More E2E scenarios
SteveSandersonMS Feb 10, 2021
c09a2f1
Change E2E scenario to use keydown, not paste, to avoid isolation com…
SteveSandersonMS Feb 10, 2021
1ebdf84
Prepare E2E case for when an aliased event has no native global listener
SteveSandersonMS Feb 10, 2021
907999d
Support custom events that have no corresponding native global listener
SteveSandersonMS Feb 10, 2021
9ad4039
E2E test cases
SteveSandersonMS Feb 10, 2021
8c41111
Another test case showing multiple aliases work
SteveSandersonMS Feb 10, 2021
5c49dac
Update blazor.server.js
SteveSandersonMS Feb 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Microsoft.AspNetCore.Components.DynamicComponent.Type.set -> void
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.CascadingTypeParameterAttribute(string! name) -> void
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.Name.get -> string!
Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong eventHandlerId) -> System.Type!
static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object?>! parameters) -> Microsoft.AspNetCore.Components.ParameterView
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task!
*REMOVED*readonly Microsoft.AspNetCore.Components.RenderTree.RenderTreeEdit.RemovedAttributeName -> string
42 changes: 42 additions & 0 deletions src/Components/Components/src/RenderTree/EventArgsTypeCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Concurrent;
using System.Reflection;

namespace Microsoft.AspNetCore.Components.RenderTree
{
internal static class EventArgsTypeCache
{
private static ConcurrentDictionary<MethodInfo, Type> Cache = new ConcurrentDictionary<MethodInfo, Type>();
Copy link
Member

Choose a reason for hiding this comment

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

Do we need a concurrent dictionary here? Or are all uses within the sync context? The reason that I'm asking is because this hurts "linkability" in the wasm cases, and I believe there are a bunch of cases where we are using it and we don't need it.

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 shared across all circuits


public static Type GetEventArgsType(MethodInfo methodInfo)
{
return Cache.GetOrAdd(methodInfo, methodInfo =>
{
var parameterInfos = methodInfo.GetParameters();
if (parameterInfos.Length == 0)
{
return typeof(EventArgs);
}
else if (parameterInfos.Length > 1)
{
throw new InvalidOperationException($"The method {methodInfo} cannot be used as an event handler because it declares more than one parameter.");
}
else
{
var declaredType = parameterInfos[0].ParameterType;
if (typeof(EventArgs).IsAssignableFrom(declaredType))
{
return declaredType;
}
else
{
throw new InvalidOperationException($"The event handler parameter type {declaredType.FullName} for event must inherit from {typeof(EventArgs).FullName}.");
Copy link
Member Author

Choose a reason for hiding this comment

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

We don't really have to have this rule, but I'd err on the side of strictness. We can always loosen such rules later, but can't tighten them.

Inheriting from EventArgs is something we've made assumptions about in other places, plus it's good to make the developer really indicate for sure that they want to deserialize arbitrary untrusted input into this type.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's legit to force inheriting from EventArgs. As you mention, we need a gesture to mark these types as suitable for custom events. That said, I'm not 100% sure extending EventArgs is enough for this. Mainly because many types outside Blazor extend from it and we would want to avoid those "spurious" usages.

Maybe we can mark it with an attribute instead to be more explicit. Specially since this has security implications in SSB

Copy link
Member Author

Choose a reason for hiding this comment

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

The other gesture a developer does to opt into this is actually using the type as the parameter to an event handler definition (with [EventHandler(...)]). Having two indicators (subclassing EventArgs and explicitly putting it on one of these definitions) is a clear enough statement of intent IMO.

The risk is similar to a [FromBody] SomeType myParam on an MVC action method. In that case we consider it a sufficiently clear statement of intent with fewer steps and restrictions.

}
}
});
}
}
}
34 changes: 29 additions & 5 deletions src/Components/Components/src/RenderTree/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
{
Dispatcher.AssertAccess();

if (!_eventBindings.TryGetValue(eventHandlerId, out var callback))
{
throw new ArgumentException($"There is no event handler associated with this event. EventId: '{eventHandlerId}'.", nameof(eventHandlerId));
}

var callback = GetRequiredEventCallback(eventHandlerId);
Log.HandlingEvent(_logger, eventHandlerId, eventArgs);

if (fieldInfo != null)
Expand Down Expand Up @@ -291,6 +287,24 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
return result;
}

/// <summary>
/// Gets the event arguments type for the specified event handler.
/// </summary>
/// <param name="eventHandlerId">The <see cref="RenderTreeFrame.AttributeEventHandlerId"/> value from the original event attribute.</param>
/// <returns>The parameter type expected by the event handler. Normally this is a subclass of <see cref="EventArgs"/>.</returns>
public Type GetEventArgsType(ulong eventHandlerId)
Copy link
Member

Choose a reason for hiding this comment

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

Is the reason for this being public that we need to call it from other assemblies?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, and because it's really becoming a part of the contract a renderer makes with its host. The web host has reasons for wanting to know the .NET type to supply for an event argument, so other hosts may want to do that too.

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 unclear why we need to do this based on the event handler id vs based on the event name since there can't be two registrations for the same name.

What is preventing us from building a map at host initialization time of (eventName -> type) and using that for the deserialization?

Copy link
Member Author

Choose a reason for hiding this comment

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

What is preventing us from building a map at host initialization time of (eventName -> type) and using that for the deserialization?

That information doesn't exist in any formal way. The [EventHandler] registrations are a convention for the Razor compiler, but developers aren't compelled to use it.

The information that does exist for sure is the mapping from eventHandlerId->Delegate, which the renderer already stores.

Copy link
Member

Choose a reason for hiding this comment

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

That information doesn't exist in any formal way. The [EventHandler] registrations are a convention for the Razor compiler, but developers aren't compelled to use it.

Then the comment here is not completely correct, nothing prevents me from using a random type as an event handler provided that it inherits from EventArgs.

{
var methodInfo = GetRequiredEventCallback(eventHandlerId).Delegate?.Method;

// The DispatchEventAsync code paths allow for the case where Delegate or its method
// is null, and in this case the event receiver just receives null. This won't happen
// under normal circumstances, but to avoid creating a new failure scenario, allow for
// that edge case here too.
return methodInfo == null
? typeof(EventArgs)
: EventArgsTypeCache.GetEventArgsType(methodInfo);
}

internal void InstantiateChildComponentOnFrame(ref RenderTreeFrame frame, int parentComponentId)
{
if (frame.FrameTypeField != RenderTreeFrameType.Component)
Expand Down Expand Up @@ -404,6 +418,16 @@ internal void TrackReplacedEventHandlerId(ulong oldEventHandlerId, ulong newEven
_eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId);
}

private EventCallback GetRequiredEventCallback(ulong eventHandlerId)
{
if (!_eventBindings.TryGetValue(eventHandlerId, out var callback))
{
throw new ArgumentException($"There is no event handler associated with this event. EventId: '{eventHandlerId}'.", nameof(eventHandlerId));
}

return callback;
}

private ulong FindLatestEventHandlerIdInChain(ulong eventHandlerId)
{
while (_eventHandlerIdReplacements.TryGetValue(eventHandlerId, out var replacementEventHandlerId))
Expand Down
101 changes: 101 additions & 0 deletions src/Components/Components/test/RendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,98 @@ public void CanDispatchEventsToTopLevelComponents()
Assert.Same(eventArgs, receivedArgs);
}

[Fact]
public void CanGetEventArgsTypeForHandler()
{
// Arrange: Render a component with an event handler
var renderer = new TestRenderer();

var component = new EventComponent
{
OnArbitraryDelegateEvent = (Func<DerivedEventArgs, Task>)(args => Task.CompletedTask),
};
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();

var eventHandlerId = renderer.Batches.Single()
.ReferenceFrames
.First(frame => frame.AttributeValue != null)
.AttributeEventHandlerId;

// Assert: Can determine event args type
var eventArgsType = renderer.GetEventArgsType(eventHandlerId);
Assert.Same(typeof(DerivedEventArgs), eventArgsType);
}

[Fact]
public void CanGetEventArgsTypeForParameterlessHandler()
{
// Arrange: Render a component with an event handler
var renderer = new TestRenderer();

var component = new EventComponent
{
OnArbitraryDelegateEvent = (Func<Task>)(() => Task.CompletedTask),
};
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();

var eventHandlerId = renderer.Batches.Single()
.ReferenceFrames
.First(frame => frame.AttributeValue != null)
.AttributeEventHandlerId;

// Assert: Can determine event args type
var eventArgsType = renderer.GetEventArgsType(eventHandlerId);
Assert.Same(typeof(EventArgs), eventArgsType);
}

[Fact]
public void CannotGetEventArgsTypeForMultiParameterHandler()
{
// Arrange: Render a component with an event handler
var renderer = new TestRenderer();

var component = new EventComponent
{
OnArbitraryDelegateEvent = (Action<EventArgs, string>)((x, y) => { }),
};
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();

var eventHandlerId = renderer.Batches.Single()
.ReferenceFrames
.First(frame => frame.AttributeValue != null)
.AttributeEventHandlerId;

// Assert: Cannot determine event args type
var ex = Assert.Throws<InvalidOperationException>(() => renderer.GetEventArgsType(eventHandlerId));
Assert.Contains("declares more than one parameter", ex.Message);
}

[Fact]
public void CannotGetEventArgsTypeForHandlerWithNonEventArgsParameter()
{
// Arrange: Render a component with an event handler
var renderer = new TestRenderer();

var component = new EventComponent
{
OnArbitraryDelegateEvent = (Action<DateTime>)(arg => { }),
};
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();

var eventHandlerId = renderer.Batches.Single()
.ReferenceFrames
.First(frame => frame.AttributeValue != null)
.AttributeEventHandlerId;

// Assert: Cannot determine event args type
var ex = Assert.Throws<InvalidOperationException>(() => renderer.GetEventArgsType(eventHandlerId));
Assert.Contains($"must inherit from {typeof(EventArgs).FullName}", ex.Message);
}

[Fact]
public void DispatchEventHandlesSynchronousExceptionsFromEventHandlers()
{
Expand Down Expand Up @@ -4224,6 +4316,9 @@ private class EventComponent : AutoRenderComponent, IComponent, IHandleEvent
[Parameter]
public EventCallback<DerivedEventArgs> OnClickEventCallbackOfT { get; set; }

[Parameter]
public Delegate OnArbitraryDelegateEvent { get; set; }

public bool SkipElement { get; set; }
private int renderCount = 0;

Expand Down Expand Up @@ -4269,6 +4364,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.AddAttribute(5, "onclickaction", OnClickAsyncAction);
}

if (OnArbitraryDelegateEvent != null)
{
builder.AddAttribute(6, "onarbitrarydelegateevent", OnArbitraryDelegateEvent);
}

builder.CloseElement();
builder.CloseElement();
}
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ public async Task DispatchEvent(string eventDescriptorJson, string eventArgsJson
WebEventData webEventData;
try
{
webEventData = WebEventData.Parse(eventDescriptorJson, eventArgsJson);
webEventData = WebEventData.Parse(Renderer, eventDescriptorJson, eventArgsJson);
}
catch (Exception ex)
{
Expand Down
Loading