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

SSR as library #46935

Merged
merged 26 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
65cac04
Begin adding HtmlRenderer as public API
SteveSandersonMS Feb 27, 2023
bb83570
Test case notes
SteveSandersonMS Feb 27, 2023
0abf664
Support passing parameters
SteveSandersonMS Feb 27, 2023
e4c47cf
Add full set of HTMLification unit tests
SteveSandersonMS Feb 27, 2023
ff4ee43
Tidy
SteveSandersonMS Feb 27, 2023
56ea411
Output to TextWriter
SteveSandersonMS Feb 27, 2023
3e8e510
Renames for clarity
SteveSandersonMS Feb 27, 2023
203dd8d
Another rename
SteveSandersonMS Feb 27, 2023
d60b942
Ensure we're on renderer sync context when HTMLifying
SteveSandersonMS Feb 27, 2023
b6881f7
Allow the caller optionally to render the state before and after quie…
SteveSandersonMS Feb 27, 2023
c2c3b6e
Make the API a bit more idiomatic
SteveSandersonMS Feb 27, 2023
e13d5ff
Update HtmlRendererTest.cs
SteveSandersonMS Feb 27, 2023
7fe6711
Make the quiescence test more explicit
SteveSandersonMS Feb 27, 2023
9d90149
Tests related to exception handling
SteveSandersonMS Feb 28, 2023
e3fe72d
Change APIs around asynchrony so the caller is responsible for dispat…
SteveSandersonMS Feb 28, 2023
4bc113e
Renames
SteveSandersonMS Feb 28, 2023
7a48877
Public API annotation fixes. Temporarily fix ambiguity.
SteveSandersonMS Mar 1, 2023
7ea0f30
Add non-generic overloads of RenderComponentAsync / BeginRenderingCom…
SteveSandersonMS Mar 1, 2023
aaeba5a
Use new HtmlRenderer in real prerendering
SteveSandersonMS Mar 1, 2023
5378880
Clean up the prerendering classes
SteveSandersonMS Mar 2, 2023
41e164d
Remove unused line
SteveSandersonMS Mar 2, 2023
e52ec5d
Fix XML docs
SteveSandersonMS Mar 2, 2023
60af0f7
Seal 🦭
SteveSandersonMS Mar 2, 2023
1805ff5
Another thing can be private
SteveSandersonMS Mar 2, 2023
ab919af
Update existing tests
SteveSandersonMS Mar 2, 2023
d189018
Restore back-compat by implementing IDisposable
SteveSandersonMS Mar 3, 2023
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
Expand Up @@ -48,6 +48,15 @@ public async Task RestoreStateAsync(IPersistentComponentStateStore store)
/// <param name="renderer">The <see cref="Renderer"/> that components are being rendered.</param>
/// <returns>A <see cref="Task"/> that will complete when the state has been restored.</returns>
public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer renderer)
=> PersistStateAsync(store, renderer.Dispatcher);

/// <summary>
/// Persists the component application state into the given <see cref="IPersistentComponentStateStore"/>.
/// </summary>
/// <param name="store">The <see cref="IPersistentComponentStateStore"/> to restore the application state from.</param>
/// <param name="dispatcher">The <see cref="Dispatcher"/> corresponding to the components' renderer.</param>
/// <returns>A <see cref="Task"/> that will complete when the state has been restored.</returns>
public Task PersistStateAsync(IPersistentComponentStateStore store, Dispatcher dispatcher)
Copy link
Member

Choose a reason for hiding this comment

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

I do not think we need this API. We are the only ones that can provide a ComponentStatePersistenceManager to the system. Or am I missing something here?

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 a layering thing. The existing caller is in Microsoft.AspNetCore.Mvc.TagHelpers and it can't see the internals of M.A.Components. It's same reason that you added the other PersistStateAsync overload before.

{
if (_stateIsPersisted)
{
Expand All @@ -56,7 +65,7 @@ public Task PersistStateAsync(IPersistentComponentStateStore store, Renderer ren

_stateIsPersisted = true;

return renderer.Dispatcher.InvokeAsync(PauseAndPersistState);
return dispatcher.InvokeAsync(PauseAndPersistState);

async Task PauseAndPersistState()
{
Expand Down
1 change: 1 addition & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!
Expand Down
63 changes: 63 additions & 0 deletions src/Components/Web/src/HtmlRendering/HtmlComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.HtmlRendering;

namespace Microsoft.AspNetCore.Components.Web;

/// <summary>
/// Represents the output of rendering a component as HTML. The content can change if the component instance re-renders.
/// </summary>
public sealed class HtmlComponent
{
private readonly HtmlRendererCore? _renderer;
private readonly int _componentId;
private readonly Task _quiescenceTask;

internal HtmlComponent(HtmlRendererCore? renderer, int componentId, Task quiescenceTask)
{
_renderer = renderer;
_componentId = componentId;
_quiescenceTask = quiescenceTask;
}

/// <summary>
/// Gets an instance of <see cref="HtmlComponent"/> that produces no content.
/// </summary>
public static HtmlComponent Empty { get; } = new HtmlComponent(null, 0, Task.CompletedTask);

/// <summary>
/// Obtains a <see cref="Task"/> that completes when the component hierarchy has completed asynchronous tasks such as loading.
/// </summary>
/// <returns>A <see cref="Task"/> that completes when the component hierarchy has completed asynchronous tasks such as loading.</returns>
public Task WaitForQuiescenceAsync()
=> _quiescenceTask;

/// <summary>
/// Returns an HTML string representation of the component's latest output.
/// </summary>
/// <returns>An HTML string representation of the component's latest output.</returns>
public string ToHtmlString()
{
if (_renderer is null)
{
return string.Empty;
}

using var writer = new StringWriter();
WriteHtmlTo(writer);
return writer.ToString();
}

/// <summary>
/// Writes the component's latest output as HTML to the specified writer.
/// </summary>
/// <param name="output">The output destination.</param>
public void WriteHtmlTo(TextWriter output)
{
if (_renderer is not null)
{
HtmlComponentWriter.Write(_renderer, _componentId, output);
}
}
}
241 changes: 241 additions & 0 deletions src/Components/Web/src/HtmlRendering/HtmlComponentWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Components.HtmlRendering;
using Microsoft.AspNetCore.Components.RenderTree;

namespace Microsoft.AspNetCore.Components.Web;

// This is OK to be a struct because it never gets passed around anywhere. Other code can't even get an instance
// of it. It just keeps track of some contextual information during a single synchronous HTML output operation.
internal ref struct HtmlComponentWriter
{
private static readonly HashSet<string> SelfClosingElements = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
};

private static readonly HtmlEncoder _htmlEncoder = HtmlEncoder.Default;
private readonly HtmlRendererCore _renderer;
private readonly TextWriter _output;
private string? _closestSelectValueAsString;

public static void Write(HtmlRendererCore renderer, int componentId, TextWriter output)
{
// We're about to walk over some buffers inside the renderer that can be mutated during rendering.
// So, we require exclusive access to the renderer during this synchronous process.
renderer.Dispatcher.AssertAccess();

var context = new HtmlComponentWriter(renderer, output);
context.RenderComponent(componentId);
}

private HtmlComponentWriter(HtmlRendererCore renderer, TextWriter output)
{
_renderer = renderer;
_output = output;
}

private int RenderFrames(ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
{
var nextPosition = position;
var endPosition = position + maxElements;
while (position < endPosition)
{
nextPosition = RenderCore(frames, position);
if (position == nextPosition)
{
throw new InvalidOperationException("We didn't consume any input.");
}
position = nextPosition;
}

return nextPosition;
}

private int RenderCore(
ArrayRange<RenderTreeFrame> frames,
int position)
{
ref var frame = ref frames.Array[position];
switch (frame.FrameType)
{
case RenderTreeFrameType.Element:
return RenderElement(frames, position);
case RenderTreeFrameType.Attribute:
throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}");
case RenderTreeFrameType.Text:
_htmlEncoder.Encode(_output, frame.TextContent);
return ++position;
case RenderTreeFrameType.Markup:
_output.Write(frame.MarkupContent);
return ++position;
case RenderTreeFrameType.Component:
return RenderChildComponent(frames, position);
case RenderTreeFrameType.Region:
return RenderFrames(frames, position + 1, frame.RegionSubtreeLength - 1);
case RenderTreeFrameType.ElementReferenceCapture:
case RenderTreeFrameType.ComponentReferenceCapture:
return ++position;
default:
throw new InvalidOperationException($"Invalid element frame type '{frame.FrameType}'.");
}
}

private int RenderElement(ArrayRange<RenderTreeFrame> frames, int position)
{
ref var frame = ref frames.Array[position];
_output.Write('<');
_output.Write(frame.ElementName);
int afterElement;
var isTextArea = string.Equals(frame.ElementName, "textarea", StringComparison.OrdinalIgnoreCase);
// We don't want to include value attribute of textarea element.
var afterAttributes = RenderAttributes(frames, position + 1, frame.ElementSubtreeLength - 1, !isTextArea, out var capturedValueAttribute);

// When we see an <option> as a descendant of a <select>, and the option's "value" attribute matches the
// "value" attribute on the <select>, then we auto-add the "selected" attribute to that option. This is
// a way of converting Blazor's select binding feature to regular static HTML.
if (_closestSelectValueAsString != null
&& string.Equals(frame.ElementName, "option", StringComparison.OrdinalIgnoreCase)
&& string.Equals(capturedValueAttribute, _closestSelectValueAsString, StringComparison.Ordinal))
{
_output.Write(" selected");
}

var remainingElements = frame.ElementSubtreeLength + position - afterAttributes;
if (remainingElements > 0 || isTextArea)
{
_output.Write('>');

var isSelect = string.Equals(frame.ElementName, "select", StringComparison.OrdinalIgnoreCase);
if (isSelect)
{
_closestSelectValueAsString = capturedValueAttribute;
}

if (isTextArea && !string.IsNullOrEmpty(capturedValueAttribute))
{
// Textarea is a special type of form field where the value is given as text content instead of a 'value' attribute
// So, if we captured a value attribute, use that instead of any child content
_htmlEncoder.Encode(_output, capturedValueAttribute);
afterElement = position + frame.ElementSubtreeLength; // Skip descendants
}
else
{
afterElement = RenderChildren(frames, afterAttributes, remainingElements);
}

if (isSelect)
{
// There's no concept of nested <select> elements, so as soon as we're exiting one of them,
// we can safely say there is no longer any value for this
_closestSelectValueAsString = null;
}

_output.Write("</");
_output.Write(frame.ElementName);
_output.Write('>');
Debug.Assert(afterElement == position + frame.ElementSubtreeLength);
return afterElement;
}
else
{
if (SelfClosingElements.Contains(frame.ElementName))
{
_output.Write(" />");
}
else
{
_output.Write("></");
_output.Write(frame.ElementName);
_output.Write('>');
}
Debug.Assert(afterAttributes == position + frame.ElementSubtreeLength);
return afterAttributes;
}
}

private int RenderAttributes(
ArrayRange<RenderTreeFrame> frames, int position, int maxElements, bool includeValueAttribute, out string? capturedValueAttribute)
{
capturedValueAttribute = null;

if (maxElements == 0)
{
return position;
}

for (var i = 0; i < maxElements; i++)
{
var candidateIndex = position + i;
ref var frame = ref frames.Array[candidateIndex];

if (frame.FrameType != RenderTreeFrameType.Attribute)
{
if (frame.FrameType == RenderTreeFrameType.ElementReferenceCapture)
{
continue;
}

return candidateIndex;
}

if (frame.AttributeName.Equals("value", StringComparison.OrdinalIgnoreCase))
{
capturedValueAttribute = frame.AttributeValue as string;

if (!includeValueAttribute)
{
continue;
}
}

switch (frame.AttributeValue)
{
case bool flag when flag:
_output.Write(' ');
_output.Write(frame.AttributeName);
break;
case string value:
_output.Write(' ');
_output.Write(frame.AttributeName);
_output.Write('=');
_output.Write('\"');
_htmlEncoder.Encode(_output, value);
_output.Write('\"');
break;
default:
break;
}
}

return position + maxElements;
}

private int RenderChildren(ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
{
if (maxElements == 0)
{
return position;
}

return RenderFrames(frames, position, maxElements);
}

private void RenderComponent(int componentId)
{
var frames = _renderer.GetCurrentRenderTreeFrames(componentId);
RenderFrames(frames, 0, frames.Count);
}

private int RenderChildComponent(ArrayRange<RenderTreeFrame> frames, int position)
{
ref var frame = ref frames.Array[position];

RenderComponent(frame.ComponentId);

return position + frame.ComponentSubtreeLength;
}
}
Loading