-
Notifications
You must be signed in to change notification settings - Fork 10.3k
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
SSR as library #46935
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 bb83570
Test case notes
SteveSandersonMS 0abf664
Support passing parameters
SteveSandersonMS e4c47cf
Add full set of HTMLification unit tests
SteveSandersonMS ff4ee43
Tidy
SteveSandersonMS 56ea411
Output to TextWriter
SteveSandersonMS 3e8e510
Renames for clarity
SteveSandersonMS 203dd8d
Another rename
SteveSandersonMS d60b942
Ensure we're on renderer sync context when HTMLifying
SteveSandersonMS b6881f7
Allow the caller optionally to render the state before and after quie…
SteveSandersonMS c2c3b6e
Make the API a bit more idiomatic
SteveSandersonMS e13d5ff
Update HtmlRendererTest.cs
SteveSandersonMS 7fe6711
Make the quiescence test more explicit
SteveSandersonMS 9d90149
Tests related to exception handling
SteveSandersonMS e3fe72d
Change APIs around asynchrony so the caller is responsible for dispat…
SteveSandersonMS 4bc113e
Renames
SteveSandersonMS 7a48877
Public API annotation fixes. Temporarily fix ambiguity.
SteveSandersonMS 7ea0f30
Add non-generic overloads of RenderComponentAsync / BeginRenderingCom…
SteveSandersonMS aaeba5a
Use new HtmlRenderer in real prerendering
SteveSandersonMS 5378880
Clean up the prerendering classes
SteveSandersonMS 41e164d
Remove unused line
SteveSandersonMS e52ec5d
Fix XML docs
SteveSandersonMS 60af0f7
Seal 🦭
SteveSandersonMS 1805ff5
Another thing can be private
SteveSandersonMS ab919af
Update existing tests
SteveSandersonMS d189018
Restore back-compat by implementing IDisposable
SteveSandersonMS File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
241
src/Components/Web/src/HtmlRendering/HtmlComponentWriter.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 ofM.A.Components
. It's same reason that you added the otherPersistStateAsync
overload before.