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

Smarter rendering in v2 #1466

Draft
wants to merge 1 commit into
base: v2
Choose a base branch
from
Draft
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
69 changes: 27 additions & 42 deletions src/bunit/Rendering/BunitRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class BunitRenderer : Renderer
{
private readonly BunitServiceProvider services;
private readonly List<Task> disposalTasks = [];
private static readonly ConcurrentDictionary<Type, ConstructorInfo> componentActivatorCache = new();
private static readonly ConcurrentDictionary<Type, ConstructorInfo> ComponentActivatorCache = new();

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_isBatchInProgress")]
private static extern ref bool GetIsBatchInProgressField(Renderer renderer);
Expand Down Expand Up @@ -224,7 +224,7 @@ protected override ComponentState CreateComponentState(int componentId, ICompone

object CreateComponentInstance()
{
var constructorInfo = componentActivatorCache.GetOrAdd(renderedComponentType, type
var constructorInfo = ComponentActivatorCache.GetOrAdd(renderedComponentType, type
=> type.GetConstructor(
[
typeof(BunitRenderer),
Expand Down Expand Up @@ -349,65 +349,50 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
for (var i = 0; i < renderBatch.UpdatedComponents.Count; i++)
{
var diff = renderBatch.UpdatedComponents.Array[i];
var componentState = GetComponentState(diff.ComponentId);
var renderedComponent = (IRenderedComponent)componentState;
var componentState = GetRenderedComponent(diff.ComponentId);
componentState.RenderCount++;

if (returnedRenderedComponentIds.Contains(diff.ComponentId))
{
renderedComponent.UpdateState(hasRendered: true, isMarkupGenerationRequired: diff.Edits.Count > 0);
}
else
componentState.IsDirty = true;

if (componentState.Root is not null)
{
renderedComponent.UpdateState(hasRendered: true, false);
componentState.Root.IsDirty = true;
}

UpdateParents(diff.Edits.Count > 0, componentState, in renderBatch);
}

return Task.CompletedTask;

void UpdateParents(bool hasChanges, ComponentState componentState, in RenderBatch renderBatch)
foreach (var item in rootComponents)
{
var parent = componentState.ParentComponentState;
if (parent is null)
var root = GetRenderedComponent(item);
if (root.IsDirty)
{
return;
}

if (!IsParentComponentAlreadyUpdated(parent.ComponentId, in renderBatch))
{
if (returnedRenderedComponentIds.Contains(parent.ComponentId))
{
((IRenderedComponent)parent).UpdateState(hasRendered: true, isMarkupGenerationRequired: hasChanges);
}
else
{
((IRenderedComponent)parent).UpdateState(hasRendered: true, false);
}

UpdateParents(hasChanges, parent, in renderBatch);
root.UpdateMarkup();
}
}

static bool IsParentComponentAlreadyUpdated(int componentId, in RenderBatch renderBatch)
foreach (var renderedComponentId in returnedRenderedComponentIds)
{
for (var i = 0; i < renderBatch.UpdatedComponents.Count; i++)
var renderedComponent = GetRenderedComponent(renderedComponentId);
if (renderedComponent.IsDirty)
{
var diff = renderBatch.UpdatedComponents.Array[i];
if (diff.ComponentId == componentId)
{
return diff.Edits.Count > 0;
}
renderedComponent.UpdateMarkup();
}

return false;
}

return Task.CompletedTask;
}

/// <inheritdoc/>
internal new ArrayRange<RenderTreeFrame> GetCurrentRenderTreeFrames(int componentId)
=> base.GetCurrentRenderTreeFrames(componentId);

/// <inheritdoc/>
internal IRenderedComponent GetRenderedComponent(int componentId)
=> (IRenderedComponent)GetComponentState(componentId);

/// <inheritdoc/>
internal IRenderedComponent GetRenderedComponent(IComponent component)
=> (IRenderedComponent)GetComponentState(component);

/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
Expand Down Expand Up @@ -487,7 +472,7 @@ private List<IRenderedComponent<TComponent>> FindComponents<TComponent>(IRendere
FindComponentsInRenderTree(parentComponent.ComponentId);
foreach (var rc in result)
{
((IRenderedComponent)rc).UpdateState(hasRendered: false, isMarkupGenerationRequired: true);
((IRenderedComponent)rc).UpdateMarkup();
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/bunit/Rendering/IRenderedComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ internal interface IRenderedComponent : IDisposable
int ComponentId { get; }

/// <summary>
/// Called by the owning <see cref="BunitRenderer"/> when it finishes a render.
/// Gets the total number times the fragment has been through its render life-cycle.
/// </summary>
void UpdateState(bool hasRendered, bool isMarkupGenerationRequired);
int RenderCount { get; set; }

void UpdateMarkup();

void SetMarkupIndices(int start, int end);

bool IsDirty { get; set; }

IRenderedComponent? Root { get; }
}

/// <summary>
Expand Down
27 changes: 21 additions & 6 deletions src/bunit/Rendering/Internal/Htmlizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,35 @@ public static string ToBlazorAttribute(string attributeName)
public static string GetHtml(int componentId, BunitRenderer renderer)
{
var context = new HtmlRenderingContext(renderer);
var componentState = renderer.GetRenderedComponent(componentId);
var frames = context.GetRenderTreeFrames(componentId);
var newPosition = RenderFrames(context, frames, 0, frames.Count);

componentState.SetMarkupIndices(0, context.Result.Length);

Debug.Assert(
newPosition == frames.Count,
$"frames.Length = {frames.Count}. newPosition = {newPosition}"
);

return context.Result.ToString();
}

private static RenderTreeFrame RenderComponent(HtmlRenderingContext context, in RenderTreeFrame frame)
{
var startIndex = context.Result.Length;
var frames = context.GetRenderTreeFrames(frame.ComponentId);
RenderFrames(context, frames, 0, frames.Count);
var endIndex = context.Result.Length;
context.GetRenderedComponent(frame.ComponentId).SetMarkupIndices(startIndex, endIndex);
return frame;
}

private static int RenderFrames(
HtmlRenderingContext context,
ArrayRange<RenderTreeFrame> frames,
int position,
int maxElements
)
int maxElements)
{
var nextPosition = position;
var endPosition = position + maxElements;
Expand Down Expand Up @@ -130,12 +144,10 @@ int position
private static int RenderChildComponent(
HtmlRenderingContext context,
ArrayRange<RenderTreeFrame> frames,
int position
)
int position)
{
var frame = frames.Array[position];
var childFrames = context.GetRenderTreeFrames(frame.ComponentId);
RenderFrames(context, childFrames, 0, childFrames.Count);
frame = RenderComponent(context, in frame);
return position + frame.ComponentSubtreeLength;
}

Expand Down Expand Up @@ -405,6 +417,9 @@ public HtmlRenderingContext(BunitRenderer renderer)
public ArrayRange<RenderTreeFrame> GetRenderTreeFrames(int componentId)
=> renderer.GetCurrentRenderTreeFrames(componentId);

public IRenderedComponent GetRenderedComponent(int componentId)
=> renderer.GetRenderedComponent(componentId);

public StringBuilder Result { get; } = new();

public string? ClosestSelectValueAsString { get; set; }
Expand Down
130 changes: 76 additions & 54 deletions src/bunit/Rendering/RenderedComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ internal sealed class RenderedComponent<TComponent> : ComponentState, IRenderedC

[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Owned by BunitServiceProvider, disposed by it.")]
private readonly BunitHtmlParser htmlParser;

private int renderCount;
private string markup = string.Empty;
private int markupStartIndex;
private int markupEndIndex;
private INodeList? latestRenderNodes;

public bool IsDirty { get; set; }

/// <summary>
/// Gets the component under test.
/// </summary>
Expand Down Expand Up @@ -53,10 +57,22 @@ public string Markup
}
}

/// <summary>
/// Adds or removes an event handler that will be triggered after
/// each render of this <see cref="RenderedComponent{T}"/>.
/// </summary>
public event EventHandler? OnAfterRender;

/// <summary>
/// An event that is raised after the markup of the
/// <see cref="RenderedComponent{T}"/> is updated.
/// </summary>
public event EventHandler? OnMarkupUpdated;

/// <summary>
/// Gets the total number times the fragment has been through its render life-cycle.
/// </summary>
public int RenderCount { get; private set; }
public int RenderCount => renderCount;

/// <summary>
/// Gets the AngleSharp <see cref="INodeList"/> based
Expand All @@ -77,6 +93,10 @@ public INodeList Nodes
/// </summary>
public IServiceProvider Services { get; }

int IRenderedComponent.RenderCount { get => renderCount; set { renderCount = value; } }

public IRenderedComponent? Root { get; }

public RenderedComponent(
BunitRenderer renderer,
int componentId,
Expand All @@ -89,57 +109,76 @@ public RenderedComponent(
this.renderer = renderer;
this.instance = (TComponent)instance;
htmlParser = Services.GetRequiredService<BunitHtmlParser>();
var parentRenderedComponent = parentComponentState as IRenderedComponent;
Root = parentRenderedComponent?.Root ?? parentRenderedComponent;
}

/// <summary>
/// Adds or removes an event handler that will be triggered after each render of this <see cref="RenderedComponent{T}"/>.
/// </summary>
public event EventHandler? OnAfterRender;
/// <inheritdoc/>
public void Dispose()
{
if (IsDisposed)
return;

/// <summary>
/// An event that is raised after the markup of the <see cref="RenderedComponent{T}"/> is updated.
/// </summary>
public event EventHandler? OnMarkupUpdated;
if (Root is not null)
Root.IsDirty = true;

IsDisposed = true;
markup = string.Empty;
OnAfterRender = null;
OnMarkupUpdated = null;
}

/// <inheritdoc/>
public override ValueTask DisposeAsync()
{
Dispose();
return base.DisposeAsync();
}

public void SetMarkupIndices(int start, int end)
{
markupStartIndex = start;
markupEndIndex = end;
IsDirty = true;
}

/// <summary>
/// Called by the owning <see cref="BunitRenderer"/> when it finishes a render.
/// </summary>
public void UpdateState(bool hasRendered, bool isMarkupGenerationRequired)
public void UpdateMarkup()
{
if (IsDisposed)
return;

if (hasRendered)
if (Root is RenderedComponent<BunitRootComponent> root)
{
RenderCount++;
var newMarkup = root.markup[markupStartIndex..markupEndIndex];
if (markup != newMarkup)
{
Volatile.Write(ref markup, newMarkup);
latestRenderNodes = null;
OnMarkupUpdated?.Invoke(this, EventArgs.Empty);
}
else
{
// no change
}
}

if (isMarkupGenerationRequired)
else
{
UpdateMarkup();
var newMarkup = Htmlizer.GetHtml(ComponentId, renderer);

// Volatile write is necessary to ensure the updated markup
// is available across CPU cores. Without it, the pointer to the
// markup string can be stored in a CPUs register and not
// get updated when another CPU changes the string.
Volatile.Write(ref markup, newMarkup);
latestRenderNodes = null;
OnMarkupUpdated?.Invoke(this, EventArgs.Empty);
}

// The order here is important, since consumers of the events
// expect that markup has indeed changed when OnAfterRender is invoked
// (assuming there are markup changes)
if (hasRendered)
OnAfterRender?.Invoke(this, EventArgs.Empty);
}

/// <summary>
/// Updates the markup of the rendered fragment.
/// </summary>
private void UpdateMarkup()
{
latestRenderNodes = null;
var newMarkup = Htmlizer.GetHtml(ComponentId, renderer);

// Volatile write is necessary to ensure the updated markup
// is available across CPU cores. Without it, the pointer to the
// markup string can be stored in a CPUs register and not
// get updated when another CPU changes the string.
Volatile.Write(ref markup, newMarkup);
IsDirty = false;
OnAfterRender?.Invoke(this, EventArgs.Empty);
}

/// <summary>
Expand All @@ -151,22 +190,5 @@ private void EnsureComponentNotDisposed()
if (IsDisposed)
throw new ComponentDisposedException(ComponentId);
}

/// <inheritdoc/>
public void Dispose()
{
if (IsDisposed)
return;

IsDisposed = true;
markup = string.Empty;
OnAfterRender = null;
OnMarkupUpdated = null;
}

public override ValueTask DisposeAsync()
{
Dispose();
return base.DisposeAsync();
}
}

Loading
Loading