Skip to content

Fix event and JS component args serialization #35038

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
10 changes: 3 additions & 7 deletions src/Components/Components/src/ParameterViewBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ internal readonly struct ParameterViewBuilder
/// <summary>
/// Constructs an instance of <see cref="ParameterViewBuilder" />.
/// </summary>
/// <param name="count">The maximum number of parameters that can be held.</param>
public ParameterViewBuilder(int count)
/// <param name="maxCapacity">The maximum number of parameters that can be held.</param>
public ParameterViewBuilder(int maxCapacity)
{
_frames = new RenderTreeFrame[count + 1];
_frames = new RenderTreeFrame[maxCapacity + 1];
_frames[0] = RenderTreeFrame
.Element(0, GeneratedParameterViewElementName)
.WithElementSubtreeLength(1);
Expand All @@ -44,10 +44,6 @@ public void Add(string name, object? value)
/// <returns>The <see cref="ParameterView" />.</returns>
public ParameterView ToParameterView()
{
// Since this is internal, we should expect the usage to always be correct,
// i.e. the given count should match the number of 'Add' calls.
Debug.Assert(_frames[0].ElementSubtreeLengthField == _frames.Length);
Copy link
Member Author

Choose a reason for hiding this comment

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

This check was unhelpful because the capacity needs to be understood as a max capacity, not a guarantee that it will be filled entirely. I realised that if the JS-side code tries to supply undefined, because of JSON serialization conventions, that parameter would be omitted, so it's possible for the number of supplied parameters to be less than the total count.


return new ParameterView(ParameterViewLifetime.Unbound, _frames, 0);
}
}
Expand Down
29 changes: 25 additions & 4 deletions src/Components/Ignitor/src/BlazorClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR.Protocol;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -160,11 +161,11 @@ public Task ClickAsync(string elementId, bool expectRenderBatch = true)
}
if (expectRenderBatch)
{
return ExpectRenderBatch(() => elementNode.ClickAsync(HubConnection));
return ExpectRenderBatch(() => elementNode.ClickAsync(this));
}
else
{
return elementNode.ClickAsync(HubConnection);
return elementNode.ClickAsync(this);
}
}

Expand All @@ -175,7 +176,27 @@ public Task SelectAsync(string elementId, string value)
throw new InvalidOperationException($"Could not find element with id {elementId}.");
}

return ExpectRenderBatch(() => elementNode.SelectAsync(HubConnection, value));
return ExpectRenderBatch(() => elementNode.SelectAsync(this, value));
}

public Task DispatchEventAsync(object descriptor, EventArgs eventArgs)
{
var attachWebRendererInteropCall = Operations.JSInteropCalls.FirstOrDefault(c => c.Identifier == "Blazor._internal.attachWebRendererInterop");
if (attachWebRendererInteropCall is null)
{
throw new InvalidOperationException("The server has not yet attached interop methods, so events cannot be dispatched.");
}

var args = JsonSerializer.Deserialize<JsonElement>(attachWebRendererInteropCall.ArgsJson);
var dotNetObjectRef = args.EnumerateArray().Skip(1).First();
var dotNetObjectId = dotNetObjectRef.GetProperty("__dotNetObject").GetInt32();

return InvokeDotNetMethod(
null,
null,
"DispatchEventAsync",
dotNetObjectId,
JsonSerializer.Serialize(new object[] { descriptor, eventArgs }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Aug 5, 2021

Choose a reason for hiding this comment

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

I'm doing these Ignitor updates to make the Ignitor tests still roughly make sense and not further away from working than before, but they are all quarantined and completely broken even before this PR, as they all time out waiting for renderbatches. Fixing Ignitor completely (or more likely removing it, as we've discussed) is out of scope for this PR.

The change you see here retains a meaningful way to dispatch events, since the old way is not longer applicable.

}

public async Task<CapturedRenderBatch?> ExpectRenderBatch(Func<Task> action, TimeSpan? timeout = null)
Expand Down Expand Up @@ -483,7 +504,7 @@ private Uri GetHubUrl(Uri uri)
}
}

public async Task InvokeDotNetMethod(object callId, string assemblyName, string methodIdentifier, object dotNetObjectId, string argsJson)
public async Task InvokeDotNetMethod(object? callId, string? assemblyName, string methodIdentifier, object dotNetObjectId, string argsJson)
{
await ExpectDotNetInterop(() => HubConnection.InvokeAsync(
"BeginInvokeDotNetFromJS",
Expand Down
33 changes: 20 additions & 13 deletions src/Components/Ignitor/src/ElementNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.SignalR.Client;

Expand Down Expand Up @@ -62,21 +63,31 @@ public void SetEvent(string eventName, ElementEventDescriptor descriptor)
_events[eventName] = descriptor;
}

internal Task SelectAsync(HubConnection connection, string value)
class TestChangeEventArgs : EventArgs
{
public object? Value { get; set; }
}

class TestMouseEventArgs : EventArgs
{
public string? Type { get; set; }
public int Detail { get; set; }
}

internal Task SelectAsync(BlazorClient client, string value)
{
if (!Events.TryGetValue("change", out var changeEventDescriptor))
{
throw new InvalidOperationException("Element does not have a change event.");
}

var args = new
var args = new TestChangeEventArgs
{
Value = value
};

var webEventDescriptor = new WebEventDescriptor
{
BrowserRendererId = 0,
EventHandlerId = changeEventDescriptor.EventId,
EventName = "change",
EventFieldInfo = new EventFieldInfo
Expand All @@ -86,36 +97,32 @@ internal Task SelectAsync(HubConnection connection, string value)
}
};

return DispatchEventCore(connection, Serialize(webEventDescriptor), Serialize(args));
return DispatchEventCore(client, webEventDescriptor, args);
}

public Task ClickAsync(HubConnection connection)
public Task ClickAsync(BlazorClient client)
{
if (!Events.TryGetValue("click", out var clickEventDescriptor))
{
throw new InvalidOperationException("Element does not have a click event.");
}

var mouseEventArgs = new
var mouseEventArgs = new TestMouseEventArgs
{
Type = clickEventDescriptor.EventName,
Detail = 1
};
var webEventDescriptor = new WebEventDescriptor
{
BrowserRendererId = 0,
EventHandlerId = clickEventDescriptor.EventId,
EventName = "click",
};

return DispatchEventCore(connection, Serialize(webEventDescriptor), Serialize(mouseEventArgs));
return DispatchEventCore(client, webEventDescriptor, mouseEventArgs);
}

private static byte[] Serialize<T>(T payload) =>
JsonSerializer.SerializeToUtf8Bytes(payload, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });

private static Task DispatchEventCore(HubConnection connection, byte[] descriptor, byte[] eventArgs) =>
connection.InvokeAsync("DispatchBrowserEvent", descriptor, eventArgs);
private static Task DispatchEventCore(BlazorClient client, WebEventDescriptor descriptor, EventArgs eventArgs) =>
client.DispatchEventAsync(descriptor, eventArgs);

public class ElementEventDescriptor
{
Expand Down
4 changes: 3 additions & 1 deletion src/Components/Server/src/Circuits/CircuitFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,15 @@ public async ValueTask<CircuitHost> CreateCircuitHostAsync(
var appLifetime = scope.ServiceProvider.GetRequiredService<ComponentApplicationLifetime>();
await appLifetime.RestoreStateAsync(store);

var jsComponentInterop = new CircuitJSComponentInterop(_options);
var renderer = new RemoteRenderer(
scope.ServiceProvider,
_loggerFactory,
_options,
client,
_loggerFactory.CreateLogger<RemoteRenderer>(),
jsRuntime.ElementReferenceContext);
jsRuntime,
jsComponentInterop);

var circuitHandlers = scope.ServiceProvider.GetServices<CircuitHandler>()
.OrderBy(h => h.Order)
Expand Down
48 changes: 0 additions & 48 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,6 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, C
await OnCircuitOpenedAsync(cancellationToken);
await OnConnectionUpAsync(cancellationToken);

// From this point onwards, JavaScript code can add root components if configured
await Renderer.InitializeJSComponentSupportAsync(
_options.RootComponents.JSComponents,
JSRuntime.ReadJsonSerializerOptions());
Copy link
Member Author

Choose a reason for hiding this comment

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

This kind of initialization is now done from the WebRenderer constructor instead, so it's common to all.


// Here, we add each root component but don't await the returned tasks so that the
// components can be processed in parallel.
var count = Descriptors.Count;
Expand Down Expand Up @@ -445,49 +440,6 @@ internal async Task<bool> ReceiveJSDataChunk(long streamId, long chunkId, byte[]
}
}

// DispatchEvent is used in a fire-and-forget context, so it's responsible for its own
// error handling.
public async Task DispatchEvent(JsonElement eventDescriptorJson, JsonElement eventArgsJson)
{
AssertInitialized();
AssertNotDisposed();

WebEventData webEventData;
try
{
// JsonSerializerOptions are tightly bound to the JsonContext. Cache it on first use using a copy
// of the serializer settings.
webEventData = WebEventData.Parse(Renderer, JSRuntime.ReadJsonSerializerOptions(), eventDescriptorJson, eventArgsJson);
}
catch (Exception ex)
{
// Invalid event data is fatal. We expect a well-behaved client to send valid JSON.
Log.DispatchEventFailedToParseEventData(_logger, ex);
await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Bad input data."));
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
return;
}

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
{
return Renderer.DispatchEventAsync(
webEventData.EventHandlerId,
webEventData.EventFieldInfo,
webEventData.EventArgs);
});
}
catch (Exception ex)
{
// A failure in dispatching an event means that it was an attempt to use an invalid event id.
// A well-behaved client won't do this.
Log.DispatchEventFailedToDispatchEvent(_logger, webEventData.EventHandlerId.ToString(CultureInfo.InvariantCulture), ex);
await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Failed to dispatch event."));
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
}
}

// OnLocationChangedAsync is used in a fire-and-forget context, so it's responsible for its own
// error handling.
public async Task OnLocationChangedAsync(string uri, bool intercepted)
Expand Down
29 changes: 5 additions & 24 deletions src/Components/Server/src/Circuits/CircuitJSComponentInterop.cs
Original file line number Diff line number Diff line change
@@ -1,37 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Infrastructure;
using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components.Server.Circuits
{
/// <summary>
/// Intended for framework use only. Not supported for use from application code.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public class CircuitJSComponentInterop : JSComponentInterop
internal class CircuitJSComponentInterop : JSComponentInterop
Copy link
Member Author

Choose a reason for hiding this comment

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

This no longer needs to be public because it's no longer called directly through JS interop. It's now called via the new WebRendererInteropMethods thing.

{
private readonly CircuitOptions _circuitOptions;
private int _jsRootComponentCount;

internal CircuitJSComponentInterop(
JSComponentConfigurationStore configuration,
JsonSerializerOptions jsonOptions,
CircuitOptions circuitOptions)
: base(configuration, jsonOptions)
internal CircuitJSComponentInterop(CircuitOptions circuitOptions)
: base(circuitOptions.RootComponents.JSComponents)
{
_circuitOptions = circuitOptions;
}

/// <summary>
/// For framework use only.
/// </summary>
[JSInvokable]
public override int AddRootComponent(string identifier, string domElementSelector)
protected override int AddRootComponent(string identifier, string domElementSelector)
{
if (_jsRootComponentCount >= _circuitOptions.RootComponents.MaxJSRootComponents)
{
Expand All @@ -43,11 +28,7 @@ public override int AddRootComponent(string identifier, string domElementSelecto
return id;
}

/// <summary>
/// For framework use only.
/// </summary>
[JSInvokable]
public override void RemoveRootComponent(int componentId)
protected override void RemoveRootComponent(int componentId)
{
base.RemoveRootComponent(componentId);

Expand Down
13 changes: 4 additions & 9 deletions src/Components/Server/src/Circuits/RemoteRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ public RemoteRenderer(
CircuitOptions options,
CircuitClientProxy client,
ILogger logger,
ElementReferenceContext? elementReferenceContext)
: base(serviceProvider, loggerFactory)
RemoteJSRuntime jsRuntime,
CircuitJSComponentInterop jsComponentInterop)
: base(serviceProvider, loggerFactory, jsRuntime.ReadJsonSerializerOptions(), jsComponentInterop)
{
_client = client;
_options = options;
_logger = logger;

ElementReferenceContext = elementReferenceContext;
ElementReferenceContext = jsRuntime.ElementReferenceContext;
}

public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
Expand Down Expand Up @@ -311,12 +312,6 @@ private static void CompleteRender(TaskCompletionSource pendingRenderInfo, strin
}
}

public ValueTask InitializeJSComponentSupportAsync(JSComponentConfigurationStore configuration, JsonSerializerOptions jsonOptions)
{
var interop = new CircuitJSComponentInterop(configuration, jsonOptions, _options);
return InitializeJSComponentSupportAsync(interop);
}

internal readonly struct UnacknowledgedRenderBatch
{
public UnacknowledgedRenderBatch(long batchId, ArrayBuilder<byte> data, TaskCompletionSource completionSource, ValueStopwatch valueStopwatch)
Expand Down
15 changes: 0 additions & 15 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,21 +242,6 @@ public async ValueTask<bool> ReceiveJSDataChunk(long streamId, long chunkId, byt
return await circuitHost.ReceiveJSDataChunk(streamId, chunkId, chunk, error);
}

public async ValueTask DispatchBrowserEvent(JsonElement eventInfo)
Copy link
Contributor

Choose a reason for hiding this comment

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

Removing this method gives me a pause - I can't find the history for this change (it ends at this PR - #12250), but I vaguely recall we used to plumb events thru JSInterop but special cased it at as a perf optimization. Are we losing out on those benefits as part of this change?

Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Aug 4, 2021

Choose a reason for hiding this comment

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

The only difference we had before by not using JS interop was more scenario-specific logs, which I don't think are very consequential. As for perf, it's not going to be meaningfully different because even before this change, we were JSON-serializing the data before sending it over BlazorPack, which is still equivalent to what JS interop does.

But the more fundamental issue is that is has to go via JS interop if we want it to also have JS interop features like transporting byte[] or DotNetObjectReference.

Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Aug 5, 2021

Choose a reason for hiding this comment

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

Update: I should be clearer about this because there is some extra cost in going through JS interop, but (1) it's the same as we had in 5.0, so not a regression - just not an improvement either, and (2) this sets us up to improve it further in the future if we want.

Overview for Blazor Server:

Version How event dispatch worked Summary of transformations
5.0 JS sent JSON strings; .NET received .NET strings then called STJ to build .NET objects JS JSON string -> Encode as UTF8 for wire -> Decode as .NET string for hub method -> Encode as UTF8 for JSON parsing -> .NET EventData object
6.0-pre7 JS sent UTF8-encoded bytes; .NET received bytes then called STJ to build .NET objects JS JSON string -> Encode as UTF8 for wire -> Receive as UTF8 on hub method -> JSON parse to .NET EventData object
This PR JS sends JSON strings; .NET receives .NET strings then our JS interop code serializes as UTF8 and our custom deserialization logic processes the JsonElement over it JS JSON string -> Encode as UTF8 for wire -> Decode as .NET string for hub method -> JSInterop does own UTF8 encoding and passes that to STJ -> JsonElement -> .NET EventData object

So in effect, this PR removes an optimization we had previously added in a 6.0 preview and takes us back to the same perf characteristics we had in 5.0 and before, in that it goes back to passing the data via a .NET string representation instead of staying as UTF8 all the way through.

For WebAssembly, this PR doesn't change the perf characteristics either, because it was already doing event dispatch via JS interop.

Of course it would be nice if we didn't have to go through that extra representation, but it's fundamental to how JS interop is defined in terms of exchanging strings (JS strings and .NET strings, not UTF8 bytes). But the good news is that following the layering properly like this PR does means we can improve that layering in the future and would automatically get benefits here. We could eliminate the .NET string representation entirely, as suggested at #35065.

{
Debug.Assert(eventInfo.GetArrayLength() == 2, "Array length should be 2");
var eventDescriptor = eventInfo[0];
var eventArgs = eventInfo[1];

var circuitHost = await GetActiveCircuitAsync();
if (circuitHost == null)
{
return;
}

_ = circuitHost.DispatchEvent(eventDescriptor, eventArgs);
}

public async ValueTask OnRenderCompleted(long renderId, string errorMessageOrNull)
{
var circuitHost = await GetActiveCircuitAsync();
Expand Down
3 changes: 0 additions & 3 deletions src/Components/Server/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,3 @@ Microsoft.AspNetCore.Components.Server.CircuitRootComponentOptions.CircuitRootCo
Microsoft.AspNetCore.Components.Server.CircuitRootComponentOptions.JSComponents.get -> Microsoft.AspNetCore.Components.Web.JSComponentConfigurationStore!
Microsoft.AspNetCore.Components.Server.CircuitRootComponentOptions.MaxJSRootComponents.get -> int
Microsoft.AspNetCore.Components.Server.CircuitRootComponentOptions.MaxJSRootComponents.set -> void
Microsoft.AspNetCore.Components.Server.Circuits.CircuitJSComponentInterop
override Microsoft.AspNetCore.Components.Server.Circuits.CircuitJSComponentInterop.AddRootComponent(string! identifier, string! domElementSelector) -> int
override Microsoft.AspNetCore.Components.Server.Circuits.CircuitJSComponentInterop.RemoveRootComponent(int componentId) -> void
Loading