-
Notifications
You must be signed in to change notification settings - Fork 10.3k
API review: Component rendering to HTML for libraries (outside Blazor) #47018
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
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
Usage Examples
Render
SomeComponent
withparameters
to 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
/RenderComponentAsync
distinction, we could have had a single set of overloads that included awaitForQuiescence
bool flag. The reasons I don't prefer that are:Async
and 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
RenderComponentAsync
overloads and only keep the fourBeginRenderingComponent
ones. 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:
RenderComponentAsync
automatically dispatched to the sync contextBeginRenderingComponent
was actuallyasync
and also automatically dispatched to the sync contextToHtmlString
andWriteHtmlTo
were both alsoasync
and 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
/WriteHtmlTo
synchronously. 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. IfToHtmlString
was 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.RenderComponentAsync
helper 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: