Skip to content
Open
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
118 changes: 118 additions & 0 deletions src/Components/Components/perf/HtmlRendererBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using BenchmarkDotNet.Attributes;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Logging.Abstractions;

namespace Microsoft.AspNetCore.Components.Performance;

public class HtmlRendererBenchmark
{
private HtmlRenderer _renderer;
private Web.HtmlRendering.HtmlRootComponent _rendered;
private StringWriter _writer;

[GlobalSetup]
public void Setup()
{
_renderer = new HtmlRenderer(new EmptyServiceProvider(), NullLoggerFactory.Instance);
_writer = new StringWriter(new System.Text.StringBuilder(256 * 1024));
_rendered = _renderer.Dispatcher.InvokeAsync(() => _renderer.RenderComponentAsync<TablePage>()).GetAwaiter().GetResult();
}

[GlobalCleanup]
public void Cleanup() => _renderer.Dispose();

[Benchmark]
public int RenderAndWriteHtml()
{
// A new renderer per operation, like EndpointHtmlRenderer per request
using var renderer = new HtmlRenderer(new EmptyServiceProvider(), NullLoggerFactory.Instance);
return renderer.Dispatcher.InvokeAsync(async () =>
{
var output = await renderer.RenderComponentAsync<TablePage>();
_writer.GetStringBuilder().Clear();
output.WriteHtmlTo(_writer);
return _writer.GetStringBuilder().Length;
}).GetAwaiter().GetResult();
}

[Benchmark]
public int WriteHtmlOnly()
{
return _renderer.Dispatcher.InvokeAsync(() =>
{
_writer.GetStringBuilder().Clear();
_rendered.WriteHtmlTo(_writer);
return _writer.GetStringBuilder().Length;
}).GetAwaiter().GetResult();
}

private sealed class EmptyServiceProvider : IServiceProvider
{
public object GetService(Type serviceType) => null;
}

private sealed class TablePage : IComponent
{
private RenderHandle _renderHandle;

public void Attach(RenderHandle renderHandle) => _renderHandle = renderHandle;

public Task SetParametersAsync(ParameterView parameters)
{
_renderHandle.Render(BuildRenderTree);
return Task.CompletedTask;
}

private static void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "table");
builder.AddAttribute(1, "class", "table table-striped");
builder.OpenElement(2, "tbody");
for (var i = 0; i < 100; i++)
{
builder.OpenComponent<Row>(3);
builder.AddComponentParameter(4, nameof(Row.Index), i);
builder.AddComponentParameter(5, nameof(Row.Name), "Customer's name #" + i);
builder.AddComponentParameter(6, nameof(Row.Selected), i % 7 == 0);
builder.CloseComponent();
}
builder.CloseElement();
builder.CloseElement();
}
}

private sealed class Row : ComponentBase
{
[Parameter] public int Index { get; set; }
[Parameter] public string Name { get; set; }
[Parameter] public bool Selected { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "tr");
builder.AddAttribute(1, "class", Selected ? "row selected" : "row");
builder.AddAttribute(2, "data-index", Index);
builder.OpenElement(3, "td");
builder.AddContent(4, Index);
builder.CloseElement();
builder.OpenElement(5, "td");
builder.AddContent(6, Name);
builder.CloseElement();
builder.OpenElement(7, "td");
builder.OpenElement(8, "input");
builder.AddAttribute(9, "type", "checkbox");
builder.AddAttribute(10, "checked", Selected);
builder.CloseElement();
builder.OpenElement(11, "a");
builder.AddAttribute(12, "href", "/customers/" + Index);
builder.AddContent(13, "Details");
builder.CloseElement();
builder.CloseElement();
builder.CloseElement();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" />

<ProjectReference Include="$(RepoRoot)src\Components\Components\src\Microsoft.AspNetCore.Components.csproj" />
<ProjectReference Include="$(RepoRoot)src\Components\Web\src\Microsoft.AspNetCore.Components.Web.csproj" />

<Compile Include="$(SharedSourceRoot)BenchmarkRunner\*.cs" />
</ItemGroup>
Expand Down
48 changes: 39 additions & 9 deletions src/Components/Components/src/Rendering/ComponentState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ public class ComponentState : IAsyncDisposable
private bool _hasCascadingParameters;
private bool _hasCompletedInitialParameterSupply;
private bool _hasSingleDeliveryCascadingParameters;
private RenderTreeBuilder _nextRenderTree;
// Created on demand when the component renders for a second time. Until then, the first render
// is written directly into CurrentRenderTree and diffed against an empty tree. Many components
// (e.g., all components during static SSR) only ever render once, so this avoids allocating a
// second RenderTreeBuilder for them.
private RenderTreeBuilder? _nextRenderTree;
private bool _hasRendered;
private ArrayBuilder<RenderTreeFrame>? _latestDirectParametersSnapshot; // Lazily instantiated
private bool _componentWasDisposed;
private readonly string? _componentTypeName;
Expand All @@ -44,7 +49,6 @@ public ComponentState(Renderer renderer, int componentId, IComponent component,
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_cascadingParameters = CascadingParameterState.FindCascadingParameters(this, out _hasSingleDeliveryCascadingParameters);
CurrentRenderTree = new RenderTreeBuilder();
_nextRenderTree = new RenderTreeBuilder();

_renderer.RegisterComponentState(component, ComponentId, this);

Expand Down Expand Up @@ -112,32 +116,58 @@ internal void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment re
return;
}

_nextRenderTree.Clear();
var isFirstRender = !_hasRendered;
var renderTree = isFirstRender ? CurrentRenderTree : (_nextRenderTree ??= new RenderTreeBuilder());
renderTree.Clear();

try
{
renderFragment(_nextRenderTree);
renderFragment(renderTree);
}
catch (Exception ex)
{
// If an exception occurs in the render fragment delegate, we won't process the diff in any way, so child components,
// event handlers, etc., will all be left untouched as if this component didn't re-render at all. The Renderer will
// then forcibly clear the descendant subtree by rendering an empty fragment for this component.
if (isFirstRender)
{
// CurrentRenderTree must keep representing the (empty) output from before this render
CurrentRenderTree.Clear();
}

renderFragmentException = ex;
return;
}

// We don't want to make errors from this be recoverable, because there's no legitimate reason for them to happen
_nextRenderTree.AssertTreeIsValid(Component);
try
{
renderTree.AssertTreeIsValid(Component);
}
catch when (isFirstRender)
{
CurrentRenderTree.Clear();
throw;
}

// Swap the old and new tree builders
(CurrentRenderTree, _nextRenderTree) = (_nextRenderTree, CurrentRenderTree);
ArrayRange<RenderTreeFrame> oldTree;
if (isFirstRender)
{
_hasRendered = true;
oldTree = new ArrayRange<RenderTreeFrame>(Array.Empty<RenderTreeFrame>(), 0);
}
else
{
// Swap the old and new tree builders
(CurrentRenderTree, _nextRenderTree) = (renderTree, CurrentRenderTree);
oldTree = _nextRenderTree.GetFrames();
}

var diff = RenderTreeDiffBuilder.ComputeDiff(
_renderer,
batchBuilder,
ComponentId,
_nextRenderTree.GetFrames(),
oldTree,
CurrentRenderTree.GetFrames());
batchBuilder.UpdatedComponentDiffs.Append(diff);
batchBuilder.InvalidateParameterViews();
Expand Down Expand Up @@ -354,7 +384,7 @@ public virtual ValueTask DisposeAsync()

private void DisposeBuffers()
{
((IDisposable)_nextRenderTree).Dispose();
((IDisposable?)_nextRenderTree)?.Dispose();
((IDisposable)CurrentRenderTree).Dispose();
_latestDirectParametersSnapshot?.Dispose();
}
Expand Down
71 changes: 71 additions & 0 deletions src/Components/Components/test/RendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4540,6 +4540,77 @@ public async Task ThrowsIfComponentProducesInvalidRenderTree()
Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Element' was left unclosed.", ex.Message);
}

[Fact]
public void ExceptionDuringFirstRender_LeavesRenderTreeEmpty_AndLaterRenderSucceeds()
{
// Arrange
var renderer = new TestRenderer { ShouldHandleExceptions = true };
var exception = new InvalidTimeZoneException("Error during render");
var shouldThrow = true;
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "partial");
builder.CloseElement();
if (shouldThrow)
{
throw exception;
}

builder.AddContent(1, "Hello");
});
var componentId = renderer.AssignRootComponentId(component);

// Act 1: The first render throws after producing partial output
renderer.RenderRootComponent(componentId);

// Assert 1: The partial output was discarded
Assert.Same(exception, Assert.Single(renderer.HandledExceptions));
Assert.Equal(0, renderer.GetCurrentRenderTreeFrames(componentId).Count);

// Act 2: A later render succeeds, and is diffed against the empty tree
shouldThrow = false;
component.TriggerRender();

// Assert 2
AssertFrame.Element(renderer.GetCurrentRenderTreeFrames(componentId).Array[0], "partial", 1, 0);
AssertFrame.Text(renderer.GetCurrentRenderTreeFrames(componentId).Array[1], "Hello", 1);
var diff = renderer.Batches.Last().DiffsByComponentId[componentId].Single();
Assert.Collection(diff.Edits,
edit =>
{
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
Assert.Equal(0, edit.SiblingIndex);
},
edit =>
{
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
Assert.Equal(1, edit.SiblingIndex);
});

// Act 3: A subsequent render is diffed against the previous output
component.TriggerRender();

// Assert 3
Assert.Empty(renderer.Batches.Last().DiffsByComponentId[componentId].Single().Edits);
Assert.Equal(2, renderer.GetCurrentRenderTreeFrames(componentId).Count);
}

[Fact]
public async Task InvalidRenderTreeDuringFirstRender_LeavesRenderTreeEmpty()
{
// Arrange
var renderer = new TestRenderer();
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "myElem");
});
var componentId = renderer.AssignRootComponentId(component);

// Act/Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => renderer.RenderRootComponentAsync(componentId));
Assert.Equal(0, renderer.GetCurrentRenderTreeFrames(componentId).Count);
}

[Fact]
public void RenderingExceptionsCanBeHandledByClosestErrorBoundary()
{
Expand Down
Loading