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

[Blazor] Use JSON source generator during WebAssembly startup #54956

Merged
merged 20 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Microsoft.AspNetCore.Components;

// For custom converters that don't rely on serializing an object graph,
// we can resolve the incoming type's JsonTypeInfo directly from the converter.
// This skips extra work to collect metadata for the type that won't be used.
internal sealed class JsonConverterFactoryTypeInfoResolver<T> : IJsonTypeInfoResolver
eiriktsarpalis marked this conversation as resolved.
Show resolved Hide resolved
{
public static readonly JsonConverterFactoryTypeInfoResolver<T> Instance = new();

private JsonConverterFactoryTypeInfoResolver()
{
}

public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
{
if (type != typeof(T))
{
return null;
}

foreach (var converter in options.Converters)
{
if (converter is not JsonConverterFactory factory || !factory.CanConvert(type))
{
continue;
}

if (factory.CreateConverter(type, options) is not { } converterToUse)
{
continue;
}

return JsonMetadataServices.CreateValueInfo<T>(options, converterToUse);
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components.Web.Internal;

/// <summary>
/// For internal framework use only.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public interface IInternalWebJSInProcessRuntime
{
/// <summary>
/// For internal framework use only.
/// </summary>
string InvokeJS(string identifier, [StringSyntax(StringSyntaxAttribute.Json)] string? argsJson, JSCallResultType resultType, long targetInstanceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\AttributeUtilities.cs" LinkBase="Forms" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ExpressionFormatting\**\*.cs" LinkBase="Forms\ExpressionFommatting" />
<Compile Include="$(ComponentsSharedSourceRoot)src\DefaultAntiforgeryStateProvider.cs" LinkBase="Forms" />
<Compile Include="$(ComponentsSharedSourceRoot)src\JsonSerialization\JsonConverterFactoryTypeInfoResolver.cs" LinkBase="JsonSerialization" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(string! identifier, string? argsJson, Microsoft.JSInterop.JSCallResultType resultType, long targetInstanceId) -> string!
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.get -> bool
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.set -> void
55 changes: 49 additions & 6 deletions src/Components/Web/src/WebRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Infrastructure;
using Microsoft.AspNetCore.Components.Web.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
Expand Down Expand Up @@ -41,12 +43,7 @@ public WebRenderer(
// Supply a DotNetObjectReference to JS that it can use to call us back for events etc.
jsComponentInterop.AttachToRenderer(this);
var jsRuntime = serviceProvider.GetRequiredService<IJSRuntime>();
jsRuntime.InvokeVoidAsync(
"Blazor._internal.attachWebRendererInterop",
_rendererId,
_interopMethodsReference,
jsComponentInterop.Configuration.JSComponentParametersByIdentifier,
jsComponentInterop.Configuration.JSComponentIdentifiersByInitializer).Preserve();
AttachWebRendererInterop(jsRuntime, jsonOptions, jsComponentInterop);
}

/// <summary>
Expand Down Expand Up @@ -103,6 +100,44 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}

[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
private void AttachWebRendererInterop(IJSRuntime jsRuntime, JsonSerializerOptions jsonOptions, JSComponentInterop jsComponentInterop)
{
const string JSMethodIdentifier = "Blazor._internal.attachWebRendererInterop";

// These arguments should be kept in sync with WebRendererSerializerContext
object[] args = [
_rendererId,
_interopMethodsReference,
jsComponentInterop.Configuration.JSComponentParametersByIdentifier,
jsComponentInterop.Configuration.JSComponentIdentifiersByInitializer,
];

if (jsRuntime is IInternalWebJSInProcessRuntime inProcessRuntime)
{
// Fast path for WebAssembly: Rather than using the JSRuntime to serialize
// parameters, we utilize the source-generated WebRendererSerializerContext
// for a faster JsonTypeInfo resolution.

// We resolve a JsonTypeInfo for DotNetObjectReference<WebRendererInteropMethods> from
// the JS runtime's JsonConverters. This is because adding DotNetObjectReference<T> as
// a supported type in the JsonSerializerContext generates unnecessary code to produce
// JsonTypeInfo for all the types referenced by both DotNetObjectReference<T> and its
// generic type argument.

var newJsonOptions = new JsonSerializerOptions(jsonOptions);
Copy link
Member Author

Choose a reason for hiding this comment

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

I had another commit that eliminated this extra JsonSerializerOptions and serialized each argument individually, passing a JsonTypeInfo for the argument directly and avoiding serializing an object[]. There appeared to be a perf benefit when running in the profiler, but it seemed to actually slightly degrade perf on optimized builds (but it was hard to tell), so I reverted it to just go with the simpler approach.

newJsonOptions.TypeInfoResolverChain.Clear();
newJsonOptions.TypeInfoResolverChain.Add(WebRendererSerializerContext.Default);
newJsonOptions.TypeInfoResolverChain.Add(JsonConverterFactoryTypeInfoResolver<DotNetObjectReference<WebRendererInteropMethods>>.Instance);
var argsJson = JsonSerializer.Serialize(args, newJsonOptions);
inProcessRuntime.InvokeJS(JSMethodIdentifier, argsJson, JSCallResultType.JSVoidResult, 0);
Copy link
Member

Choose a reason for hiding this comment

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

If we were to avoid using the SG for this call, how big is the performance penalty?

This is a single call per app, and we are creating a bunch of interfaces to support it. Might not be worth the extra infrastructure just for this.

Alternatively, the renderer could expose a virtual AttachWebRenderer interop method that WebAssembly could override. If that were to be the case then we only expose a single method, not an extra interface. The differences would be that the method is likely less accessible and doesn't sit on the JSRuntime.

Either way it's not a big deal IMO. If there is not a big penalty for avoiding this call through regular means, I would do that. If we benefit significantly, I will keep it and probably switch to use the virtual method instead of the extra interface, but its not a big deal eitherway.

Copy link
Member

Choose a reason for hiding this comment

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

If we were to avoid using the SG for this call, how big is the performance penalty?

This is a single call per app

Assuming that the goal of this change is to improve startup costs, it shouldn't matter how many times a particular call is made.

Copy link
Member

Choose a reason for hiding this comment

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

It's about the trade-off of having an extra interface and coupling vs saving a couple MS.

Copy link
Member

Choose a reason for hiding this comment

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

Saving a couple of MS sounds like a desirable goal assuming that it's a cost incurred by default for every wasm app.

Copy link
Member

Choose a reason for hiding this comment

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

That couple-of-ms happens client-side as part of a several-hundred-ms startup process. It doesn't impact server CPU usage and hence mostly matters only to the extent that humans can notice it. So realistically it wouldn't be top of the queue of things for us to optimize.

Copy link
Member Author

Choose a reason for hiding this comment

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

If we were to avoid using the SG for this call, how big is the performance penalty?

Locally, I just measured about an 80ms difference on published builds. Not sure exactly how much that difference changes on slower machines, but it seems significant. To put that number into perspective, total startup blocking time with that optimization is (locally) ~330ms.

Alternatively, the renderer could expose a virtual AttachWebRenderer interop method that WebAssembly could override.

We could, it would just mean that if we wanted to do a similar optimization in another area of the framework, we'd have to add additional specialized API (I count 9 other calls to InvokeVoidAsync in Components.Web, but those just happen to not occur during startup).

Is it fine if we proceed with this for now and discuss it further in API review? I think it's an early enough preview where we could change the API later if needed, especially since it's annotated as [EditorBrowsable(Never)].

Copy link
Member

Choose a reason for hiding this comment

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

Fair enough, I leave it up to you.

}
else
{
jsRuntime.InvokeVoidAsync(JSMethodIdentifier, args).Preserve();
}
}

/// <summary>
/// A collection of JS invokable methods that the JS-side code can use when it needs to
/// make calls in the context of a particular renderer. This object is never exposed to
Expand Down Expand Up @@ -145,3 +180,11 @@ public void RemoveRootComponent(int componentId)
=> _jsComponentInterop.RemoveRootComponent(componentId);
}
}

// This should be kept in sync with the argument types in the call to
// 'Blazor._internal.attachWebRendererInterop'
[JsonSerializable(typeof(object[]))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(Dictionary<string, JSComponentConfigurationStore.JSComponentParameter[]>))]
[JsonSerializable(typeof(Dictionary<string, List<string>>))]
internal sealed partial class WebRendererSerializerContext : JsonSerializerContext;
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 curious about how you think we should maintain this in the future. How would a developer know when/what to add to this list of types? Am I right to think that if they start serializing something different and fail to add it here, everything would still work but would just be slower? Or would some error occur so they know to change this code?

Copy link
Member

@SteveSandersonMS SteveSandersonMS Apr 18, 2024

Choose a reason for hiding this comment

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

Along the same lines, how did you even know to include these specific types? I'm hoping it's because if you don't, there's a clear error indicating they need to be included!

Copy link
Member Author

Choose a reason for hiding this comment

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

How would a developer know when/what to add to this list of types?

These types match the types of the arguments passed to the JS interop call. Failing to do this results in a runtime error. However, I've just pushed a change that makes it even clearer where these types come from, so hopefully that eliminates any confusion. If we end up reverting that change, I can add a comment explaining how these types should be specified.

Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication;

public class WebAssemblyAuthenticationServiceCollectionExtensionsTests
{
private static readonly JsonSerializerOptions JsonOptions = new();

[Fact]
public void CanResolve_AccessTokenProvider()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization();
var host = builder.Build();

Expand All @@ -27,7 +25,7 @@ public void CanResolve_AccessTokenProvider()
[Fact]
public void CanResolve_IRemoteAuthenticationService()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization();
var host = builder.Build();

Expand All @@ -37,7 +35,7 @@ public void CanResolve_IRemoteAuthenticationService()
[Fact]
public void ApiAuthorizationOptions_ConfigurationDefaultsGetApplied()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization();
var host = builder.Build();

Expand Down Expand Up @@ -71,7 +69,7 @@ public void ApiAuthorizationOptions_ConfigurationDefaultsGetApplied()
[Fact]
public void ApiAuthorizationOptionsConfigurationCallback_GetsCalledOnce()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;
builder.Services.AddApiAuthorization(options =>
{
Expand All @@ -98,7 +96,7 @@ public void ApiAuthorizationOptionsConfigurationCallback_GetsCalledOnce()
[Fact]
public void ApiAuthorizationTestAuthenticationState_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;
builder.Services.AddApiAuthorization<TestAuthenticationState>(options => calls++);

Expand All @@ -124,7 +122,7 @@ public void ApiAuthorizationTestAuthenticationState_SetsUpConfiguration()
[Fact]
public void ApiAuthorizationTestAuthenticationState_NoCallback_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization<TestAuthenticationState>();

var host = builder.Build();
Expand All @@ -147,7 +145,7 @@ public void ApiAuthorizationTestAuthenticationState_NoCallback_SetsUpConfigurati
[Fact]
public void ApiAuthorizationCustomAuthenticationStateAndAccount_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;
builder.Services.AddApiAuthorization<TestAuthenticationState, TestAccount>(options => calls++);

Expand All @@ -173,7 +171,7 @@ public void ApiAuthorizationCustomAuthenticationStateAndAccount_SetsUpConfigurat
[Fact]
public void ApiAuthorizationTestAuthenticationStateAndAccount_NoCallback_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization<TestAuthenticationState, TestAccount>();

var host = builder.Build();
Expand All @@ -196,7 +194,7 @@ public void ApiAuthorizationTestAuthenticationStateAndAccount_NoCallback_SetsUpC
[Fact]
public void ApiAuthorizationOptions_DefaultsCanBeOverriden()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddApiAuthorization(options =>
{
options.AuthenticationPaths.LogInPath = "a";
Expand Down Expand Up @@ -247,7 +245,7 @@ public void ApiAuthorizationOptions_DefaultsCanBeOverriden()
[Fact]
public void OidcOptions_ConfigurationDefaultsGetApplied()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.Replace(ServiceDescriptor.Singleton<NavigationManager, TestNavigationManager>());
builder.Services.AddOidcAuthentication(options => { });
var host = builder.Build();
Expand Down Expand Up @@ -286,7 +284,7 @@ public void OidcOptions_ConfigurationDefaultsGetApplied()
[Fact]
public void OidcOptions_DefaultsCanBeOverriden()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
builder.Services.AddOidcAuthentication(options =>
{
options.AuthenticationPaths.LogInPath = "a";
Expand Down Expand Up @@ -348,7 +346,7 @@ public void OidcOptions_DefaultsCanBeOverriden()
[Fact]
public void AddOidc_ConfigurationGetsCalledOnce()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;

builder.Services.AddOidcAuthentication(options => calls++);
Expand All @@ -365,7 +363,7 @@ public void AddOidc_ConfigurationGetsCalledOnce()
[Fact]
public void AddOidc_CustomState_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;

builder.Services.AddOidcAuthentication<TestAuthenticationState>(options => options.ProviderOptions.Authority = (++calls).ToString(CultureInfo.InvariantCulture));
Expand All @@ -387,7 +385,7 @@ public void AddOidc_CustomState_SetsUpConfiguration()
[Fact]
public void AddOidc_CustomStateAndAccount_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());
var calls = 0;

builder.Services.AddOidcAuthentication<TestAuthenticationState, TestAccount>(options => options.ProviderOptions.Authority = (++calls).ToString(CultureInfo.InvariantCulture));
Expand All @@ -409,7 +407,7 @@ public void AddOidc_CustomStateAndAccount_SetsUpConfiguration()
[Fact]
public void OidcProviderOptionsAndDependencies_NotResolvedFromRootScope()
{
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods(), JsonOptions);
var builder = new WebAssemblyHostBuilder(new TestInternalJSImportMethods());

var calls = 0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,6 @@ private static void AddWebRootComponents(WebAssemblyRenderer renderer, RootCompo
operation.Descriptor!.Parameters));
}

WebAssemblyRenderer.NotifyEndUpdateRootComponents(operationBatch.BatchId);
renderer.NotifyEndUpdateRootComponents(operationBatch.BatchId);
}
}
Loading
Loading