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
API review: Component rendering to HTML for libraries (outside Blazor) #47018
Comments
|
What do we think about adding support for rendering to non-string-based outputs to allow for more optimized rendering modes, e.g. That said, it's possible something similar could be achieved with a custom |
|
I see quite a bit of overlap with what we do in bUnit. So let me share some from there that may be relevant: The HtmlRenderer APIThe public Task<HtmlComponent> RenderComponentAsync<TComponent>(Action<ComponentParameterCollectionBuilder<TComponent>> parameterBuilder) where TComponent : IComponent
public Task<HtmlComponent> RenderAsync(RenderFragment renderFragment)Both may be too high level for what the goal is with this feature and could be supported via a 3rd party library/extension methods, but let me explain how they work, anyway, as I do think they are worth considering as a 1st party feature. Builder approachThe For example, to render the component: public class NonBlazorTypesParams : ComponentBase
{
[Parameter]
public int Numbers { get; set; }
[Parameter]
public List<string> Lines { get; set; }
}Do the following to render the component with parameters passed to it, using the builder pattern: await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var lines = new List<string> { "Hello", "World" };
var output = await htmlRenderer.RenderComponentAsync<NonBlazorTypesParams>(parameters => parameters
.Add(p => p.Numbers, 42)
.Add(p => p.Lines, lines));
return output.ToHtmlString();
});The More examples of the builder pattern can be seen in the bUnit docs. RenderFragment approachThe second variant, For example, if a user wants to render @code {
public async Task<string> GenerateHtml()
{
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var lines = new List<string> { "Hello", "World" };
var output = await htmlRenderer.RenderAsync(
@<NonBlazorTypesParams
Numbers="42"
Lines="lines" />);
return output.ToHtmlString();
});
}
}It is also possible to include multiple "root" components in the same render call, e.g.: var output = await htmlRenderer.RenderAsync(
@<text>
<HeadOutlet />
<NonBlazorTypesParams Numbers="42" Lines="lines" />
</text>);We just have to wrap them in the special More examples of the render fragment pattern can be seen in the bUnit docs. Sync ContextIf I understand the proposal correctly, you favor having users explicitly call the HtmlRenderer and HtmlComponent's methods from inside the renderers sync context, e.g. inside a lambda passed to That does make sense and from what I've learned with the challenges I've experienced with bUnit (where we explicitly chose not to do this because of the testing context bUnits users are in), it will probably make things more simple, resulting in fewer edge cases. It does however result in a slightly odd/unusual code pattern, I think. Perhaps another dedicated method on await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
using(htmlRenderer.BeginRendering())
{
// anything inside this using scope will run in the right sync context.
var output = await htmlRenderer.RenderComponentAsync<SomeComponent>();
return output.ToHtmlString();
}(just a thought, haven't thought this through) Quiescence handlingIt would be interesting to know a bit more about what type of delayed render/rerender scenarios you are planning to support. For example, will async code in I could imagine somebody would want to asynchronously listen to a stream of data, e.g. |
In the long term it may well be interesting to look at trying to get UTF8 data to pass all the way through the pipeline, starting from the Razor compiler's output (since that's where most of the string data originates, as compile-time constants) and through the My guess is that, given we are working with .NET strings at the point of writing to the output, we wouldn't at this stage benefit from changing from |
Agreed - it's optimized more for efficiency since we expect it to be rare for people to interact with it directly. We do have
Those are nice APIs and are a great fit for bUnit scenarios. Maybe people will even want to wrap something similar around the new APIs from this PR. We probably don't need that built-in since it's not expected to be super common to want to render a component as a string from inside a var content = await htmlRenderer.RenderComponentAsync(new MyComponent { Param1 = "Hello", Param2 = 123 });
Yes, we are requiring API users to dispatch to the sync context, and will throw explanatory exceptions otherwise.
The suggested API doesn't quite work because dispatch has to be async. We could do something like: using (await DispatchToSyncContext()) {
// ...
}However that's a pretty dangerous API because here are two ways you could get it badly wrong without knowing: // We forgot 'await', but there's no compiler error because Task itself is IDisposable. So now you have a race condition.
using (DispatchToSyncContext()) {
// ...
}
// We tried to use the new 'using' syntax which looks nice, but now we're holding the sync context indefinitely
// until the end of the enclosing block
using var _ = await DispatchToSyncContext();The callback-based syntax is much less error-prone and is more idiomatic for dispatch operations across all the UI frameworks.
As with the existing prerendering support,
That's right.
It completes when all As for listening for all intermediate renderbatches, we don't currently plan to create an API for that. It would be a very advanced use case, and at that point, people may well be better implementing their own |
Makes sense. In any case, I could quite easily package up some extension methods that would enable more advanced scenarios.
Regarding setting parameters directly, won't that break Blazor component life-cycle expectations? E.g. a user using a 3rd party component that expects to be passed a Anyway, I appreciate you do not want to make the API more extensive than is needed, and that does leave room for others to build on top of that. |
Yes, you're absolutely correct, and that's why we've gone cautiously and not done that at this stage. I'm sure people will ask for the feature regardless, and then we'll have to make a judgement about how to trade the high convenience of the API against the risks that in some cases it will have incorrect behavior. |
|
@SteveSandersonMS you're right in that to get the full benefit of UTF8 we'd want to plumb it all the way through Razor, such that literals are emitted into the component type as The more impactful area is when writing non-string values to the |
That's a great point, but in the Razor Components case is another area where we'd only see a difference if we went deeper into the plumbing. Given a Razor snippet like I'm not sure how we could generally store arbitrary value types in the rendertree without boxing them, and we can't skip the rendertree representation as that's inherent to being able to diff, which in turn is how we have the component lifecycle that does things like preserving child component instances when a parent re-renders (which can also happen during static HTML rendering). Perhaps one approach would be to have a pool of small |
OK yeah that's definitely an issue and not the same as how .cshtml classes are emitted (for legitimate reasons). What's preventing them being stored in the tree as something like |
Nothing really stops that - the field type could even just be The only boxing-free alternative I can think of is to change the backing store from a |
|
BTW a major mitigation here is that in most cases (grids being the exception), the vast majority of values are strings anyway. Until recently we even had a special trick to avoid boxing |
|
My guess is the savings from avoiding |
|
Yes, if we do conclude that there's enough of a perf hit related to emitting value types to warrant a more sophisticated solution then we can look into some kind of side-channel way of storing the values. |
|
@jsakamoto is this functionality for rendering static HTML from Blazor components potentially useful for your BlazorWasmPreRendering.Build project? Any thoughts or feedback on the API design? |
|
@daveaglick Given your work on Statiq we'd be interested in your feedback on this API design as well. |
|
@danroth27 Thank you for pinging me! I have also read the section "Render Razor components outside of ASP.NET Core" of the dev blog "ASP.NET Core updates in .NET 8 Preview 3". I really welcome this function, and I feel the API design is good for now. By the way, from the owner's perspective of the "BlazorWasmPreRendering.Build", this new feature will not affect the "BlazorWasmPreRendering.Build", I think. Because the "BlazorWasmPreRendering.Build" needs to capture entire page contents, from " But anyway, I'll keep my eyes on this thread and post my ideas and opinions if I get some insights. Again, thank you for letting me know! |
|
How to pass parameters to component with using HtmlRenderer.RenderComponentAsync() in 8.0.0-preview.3 ? I have a razor component: <h4>@Message</h4>
@code {
[Parameter]
public string Message { get; set; }
} |
|
I found solution with using ParameterView.FromDictionary() method : var dictionary = new Dictionary<string, object>
{
{ "Message", "Hello World!" }
};
var parameters = ParameterView.FromDictionary(dictionary);
var output = await htmlRenderer.RenderComponentAsync<MessageComponent>(parameters);I have proposal to add a sample to documentation. |
|
It could be even better, if source generators were used to add the parameters to the RenderComponentAsync() method, forcing non-nullable parameters to be required and nullable parameters to be optional. |
|
@codemonkey85 it's probably not going to be supported out of the box, but if you want strongly typed, you write the code in a .razor file and get the validation at compile time via the Razor compiler. To do that with the proposed API, you "just" need the following extensions method: // appropriate using statements here
public static class HtmlRendererExtensions
{
public static Task<HtmlComponent> RenderAsync(this HtmlRenderer renderer, RenderFragment renderFragment)
{
var dictionary = new Dictionary<string, object>
{
{ "ChildContent", renderFragment }
};
var parameters = ParameterView.FromDictionary(dictionary);
return htmlRenderer.RenderComponentAsync<FragmentContainer>(parameters);
}
private sealed class FragmentContainer : IComponent
{
private RenderHandle renderHandle;
public void Attach(RenderHandle renderHandle) => this.renderHandle = renderHandle;
public Task SetParametersAsync(ParameterView parameters)
{
if (parameters.TryGetValue<RenderFragment>("ChildContent", out var childContent))
{
renderHandle.Render(childContent);
}
return Task.CompletedTask;
}
}
}With the above extensions method available, you can generate HTML via the E.g. in a @code {
public async Task<string> GenerateHtml()
{
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var lines = new List<string> { "Hello", "World" };
var output = await htmlRenderer.RenderAsync(
@<MyBlazorComponent
Numbers="42"
Lines="lines" />);
return output.ToHtmlString();
});
}
} |
|
API Review Notes:
API Approved after removing HtmlRootComponent.ComponentId! namespace Microsoft.AspNetCore.Components.Web
{
public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
{
public HtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory) {}
public Dispatcher Dispatcher { get; }
public HtmlRootComponent BeginRenderingComponent<TComponent>() where TComponent : IComponent {}
public HtmlRootComponent BeginRenderingComponent<TComponent>(ParameterView parameters) where TComponent : IComponent {}
public HtmlRootComponent BeginRenderingComponent(Type componentType) {}
public HtmlRootComponent BeginRenderingComponent(Type componentType, ParameterView parameters) {}
public Task<HtmlRootComponent> RenderComponentAsync<TComponent>() where TComponent : IComponent {}
public Task<HtmlRootComponent> RenderComponentAsync(Type componentType) {}
public Task<HtmlRootComponent> RenderComponentAsync<TComponent>(ParameterView parameters) where TComponent : IComponent {}
public Task<HtmlRootComponent> RenderComponentAsync(Type componentType, ParameterView parameters) {}
}
}
namespace Microsoft.AspNetCore.Components.Web.HtmlRendering
{
public readonly struct HtmlRootComponent
{
public Task QuiescenceTask { get; } = Task.CompletedTask;
public string ToHtmlString() {}
public void WriteHtmlTo(TextWriter output) {}
}
}
namespace Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure
{
public class StaticHtmlRenderer : Renderer
{
public StaticHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) {}
public HtmlRootComponent BeginRenderingComponent(Type componentType, ParameterView initialParameters) {}
public HtmlRootComponent BeginRenderingComponent(IComponent component, ParameterView initialParameters) {}
protected virtual void WriteComponentHtml(int componentId, TextWriter output) {}
protected bool TryCreateScopeQualifiedEventName(int componentId, string assignedEventName, [NotNullWhen(true)] out string? scopeQualifiedEventName) {}
}
} |
|
Implemented in #50181 |
Background and Motivation
This is a long-wanted feature described in #38114. TLDR is people would like to render Blazor components as HTML to strings/streams independently of the ASP.NET Core hosting environment. As per the issue: Many of these requests are based on things like generating HTML fragments for sending emails or even generating content for sites statically.
The reason we want to implement this right now is that it also unlocks some of the SSR scenarios needed for Blazor United by fixing the layering. Having it become public API is a further benefit because this becomes central to how many apps work and so we want to have thought really carefully about the exact capabilities and semantics around things like sync context usage, asynchrony, and error handling in all usage styles.
Proposed API
namespace Microsoft.AspNetCore.Components.Web { // Notice that this does not derive from StaticHtmlRenderer (below). Instead it wraps it, providing a convenient // API without exposing the more low-level public members from StaticHtmlRenderer. + /// <summary> + /// Provides a mechanism for rendering components non-interactively as HTML markup. + /// </summary> + public sealed class HtmlRenderer : IDisposable, IAsyncDisposable + { + /// <summary> + /// Constructs an instance of <see cref="HtmlRenderer"/>. + /// </summary> + /// <param name="services">The services to use when rendering components.</param> + /// <param name="loggerFactory">The logger factory to use.</param> + public HtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory) {} + + /// <summary> + /// Gets the <see cref="Components.Dispatcher" /> associated with this instance. Any calls to + /// <see cref="RenderComponentAsync{TComponent}()"/> or <see cref="BeginRenderingComponent{TComponent}()"/> + /// must be performed using this <see cref="Components.Dispatcher" />. + /// </summary> + public Dispatcher Dispatcher { get; } + // The reason for having both RenderComponentAsync and BeginRenderingComponent is: // - RenderComponentAsync is a more obvious, simple API if you just want to get the end result (after quiescence) // of rendering the component, and don't need to see any intermediate state // - BeginRenderingComponent is relevant if you want to do the above *plus* you want to be able to access its // initial synchronous output. We use this for streaming SSR. // In both cases you get the actual HTML using APIs on the returned HtmlRootComponent object. + + /// <summary> + /// Adds an instance of the specified component and instructs it to render. The resulting content represents the + /// initial synchronous rendering output, which may later change. To wait for the component hierarchy to complete + /// any asynchronous operations such as loading, await <see cref="HtmlRootComponent.QuiescenceTask"/> before + /// reading content from the <see cref="HtmlRootComponent"/>. + /// </summary> + /// <typeparam name="TComponent">The component type.</typeparam> + /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param> + /// <param name="parameters">Parameters for the component.</param> + /// <returns>An <see cref="HtmlRootComponent"/> instance representing the render output.</returns> + public HtmlRootComponent BeginRenderingComponent<TComponent>() where TComponent : IComponent {} + public HtmlRootComponent BeginRenderingComponent<TComponent>(ParameterView parameters) where TComponent : IComponent {} + public HtmlRootComponent BeginRenderingComponent(Type componentType) {} + public HtmlRootComponent BeginRenderingComponent(Type componentType, ParameterView parameters) {} + + /// <summary> + /// Adds an instance of the specified component and instructs it to render, waiting + /// for the component hierarchy to complete asynchronous tasks such as loading. + /// </summary> + /// <typeparam name="TComponent">The component type.</typeparam> + /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param> + /// <param name="parameters">Parameters for the component.</param> + /// <returns>A task that completes with <see cref="HtmlRootComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns> + public Task<HtmlRootComponent> RenderComponentAsync<TComponent>() where TComponent : IComponent {} + public Task<HtmlRootComponent> RenderComponentAsync(Type componentType) {} + public Task<HtmlRootComponent> RenderComponentAsync<TComponent>(ParameterView parameters) where TComponent : IComponent {} + public Task<HtmlRootComponent> RenderComponentAsync(Type componentType, ParameterView parameters) {} + } } +namespace Microsoft.AspNetCore.Components.Web.HtmlRendering +{ + /// <summary> + /// Represents the output of rendering a root component as HTML. The content can change if the component instance re-renders. + /// </summary> + public readonly struct HtmlRootComponent + { + /// <summary> + /// Gets the component ID. + /// </summary> + public int ComponentId { get; } // TODO: Does this really have to be public? What's it supposed to be used for? + + /// <summary> + /// Gets a <see cref="Task"/> that completes when the component hierarchy has completed asynchronous tasks such as loading. + /// </summary> + public Task QuiescenceTask { get; } = Task.CompletedTask; + + /// <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() {} + + /// <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) {} + } +} +namespace Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure +{ // Low-ish level renderer subclass that deals with producing HTML text output rather than // rendering to a browser DOM. App developers aren't expected to use this directly, but it's // public so that EndpointHtmlRenderer can derive from it. + /// <summary> + /// A <see cref="Renderer"/> subclass that is intended for static HTML rendering. Application + /// developers should not normally use this class directly. Instead, use + /// <see cref="HtmlRenderer"/> for a more convenient API. + /// </summary> + public class StaticHtmlRenderer : Renderer + { + public StaticHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) {} + + /// <summary> + /// Adds a root component of the specified type and begins rendering it. + /// </summary> + /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param> + /// <param name="initialParameters">Parameters for the component.</param> + /// <returns>An <see cref="HtmlRootComponent"/> that can be used to obtain the rendered HTML.</returns> + public HtmlRootComponent BeginRenderingComponent(Type componentType, ParameterView initialParameters) {} + + /// <summary> + /// Adds a root component and begins rendering it. + /// </summary> + /// <param name="component">The root component instance to be added and rendered. This must not already be associated with any renderer.</param> + /// <param name="initialParameters">Parameters for the component.</param> + /// <returns>An <see cref="HtmlRootComponent"/> that can be used to obtain the rendered HTML.</returns> + public HtmlRootComponent BeginRenderingComponent(IComponent component, ParameterView initialParameters) {} + + /// <summary> + /// Renders the specified component as HTML to the output. + /// </summary> + /// <param name="componentId">The ID of the component whose current HTML state is to be rendered.</param> + /// <param name="output">The output destination.</param> + protected virtual void WriteComponentHtml(int componentId, TextWriter output) {} + + // Returns false if there's no form mapping context (e.g., you're using this outside Blazor SSR, when there's no use case for event names) + /// <summary> + /// Creates the fully scope-qualified name for a named event, if the component is within + /// a <see cref="FormMappingContext"/> (whether or not that mapping context is named). + /// </summary> + /// <param name="componentId">The ID of the component that defines a named event.</param> + /// <param name="assignedEventName">The name assigned to the named event.</param> + /// <param name="scopeQualifiedEventName">The scope-qualified event name.</param> + /// <returns>A flag to indicate whether a value could be produced.</returns> + protected bool TryCreateScopeQualifiedEventName(int componentId, string assignedEventName, [NotNullWhen(true)] out string? scopeQualifiedEventName) {} + } +}Usage Examples
Render
SomeComponentwithparametersto a string:Add multiple root components to the same renderer (so they can interact with each other):
Writing it directly to a textwriter:
Alternative Designs
Quiescence handling
Instead of the
BeginRenderingComponent/RenderComponentAsyncdistinction, we could have had a single set of overloads that included awaitForQuiescencebool flag. The reasons I don't prefer that are:Asyncand some of them aren't). It's more natural for the async and sync variants to have different names.Technically we could even drop the four
RenderComponentAsyncoverloads and only keep the fourBeginRenderingComponentones. Developers would then have to awaitresult.WaitForQuiescenceAsync()before reading the output to get the same behavior as withRenderComponentAsync. But I don't think that's a good design because many people won't realise quiescence is even a concept and will just read the output straight away - then they will be confused about why they see things in a "loading" state. I think it's better for there to be a more obvious and approachable API (RenderComponentAsync) that automatically does the expected thing about quiescence.Sync context handling
Another pivot is around sync context handling. Originally I implemented it such that:
RenderComponentAsyncautomatically dispatched to the sync contextBeginRenderingComponentwas actuallyasyncand also automatically dispatched to the sync contextToHtmlStringandWriteHtmlTowere both alsoasyncand automatically dispatched to the sync contextHowever I think this design would be wrong because it takes away control from the developer about calling
BeginRenderingComponent/ToHtmlString/WriteHtmlTosynchronously. In UI scenarios, it's often important to observe the different states that occur through the rendering flow, so you can't afford to lose track of what's a synchronous vs async operation. IfToHtmlStringwas async, for example, the developer would have no way to know if they were going to get back the result matching the initial synchronous state or some future state after async operations completed.Altogether we have a general principle of leaving the app developer in control over dispatch to sync context where possible. It's a form of locking/mutex, so developers have good reasons for wanting to group certain operations into atomic dispatches. The failure behavior is quite straightforward and easy to reason about (you get an exception telling you that you were not on the right sync context) so developers will be guided to do the right things.
Risks
For anyone using the existing prerendering system in normal, expected ways (i.e., using the
<component>tag helper or the olderHtml.RenderComponentAsynchelper method), there should be no risk. If anyone was using the prerendering system in edge-case unexpected ways - for example outside a normal ASP.NET Core app with a custom service collection - it's possible they could observe the fact that sync context dispatch is now enforced properly when it wasn't before.The text was updated successfully, but these errors were encountered: