Skip to content

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

Closed
SteveSandersonMS opened this issue Mar 3, 2023 · 22 comments
Closed
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components feature-full-stack-web-ui Full stack web UI with Blazor
Milestone

Comments

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Mar 3, 2023

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 SomeComponent with parameters to a string:

await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
    var output = await htmlRenderer.RenderComponentAsync<SomeComponent>(parameters);
    return output.ToHtmlString();
});

Add multiple root components to the same renderer (so they can interact with each other):

await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var (headOutput, bodyOutput) = await htmlRenderer.Dispatcher.InvokeAsync(() => (
    htmlRenderer.BeginRenderingComponent<HeadOutlet>(),
    htmlRenderer.BeginRenderingComponent<App>()));

// We can observe the HTML *before* loading completes
var initialBodyHtml = await htmlRenderer.Dispatcher.InvokeAsync(() => bodyOutput.ToHtmlString());

// ... then later ...
await bodyOutput.WaitForQuiescenceAsync();

// Now we get the HTML after loading completes. This might have involved changing the `<head>` output:
var (headHtml, bodyHtml) = await htmlRenderer.Dispatcher.InvokeAsync(() =>
{
    return (headOutput.ToHtmlString(), bodyOutput.ToHtmlString());
});

Writing it directly to a textwriter:

var writer = new StringWriter();
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
    (await htmlRenderer.RenderComponentAsync<SomeComponent>(parameters)).WriteHtmlTo(writer);
});

Alternative Designs

Quiescence handling

Instead of the BeginRenderingComponent/RenderComponentAsync distinction, we could have had a single set of overloads that included a waitForQuiescence bool flag. The reasons I don't prefer that are:

  1. Some of the overloads would have to be async, and some would be better as sync. It's strange if the overloads have different return types (especially if the name ends with Async and some of them aren't). It's more natural for the async and sync variants to have different names.
  2. It would be 8 overloads of a single method name, which makes it tough for developers to reason about

Technically we could even drop the four RenderComponentAsync overloads and only keep the four BeginRenderingComponent ones. Developers would then have to await result.WaitForQuiescenceAsync() before reading the output to get the same behavior as with RenderComponentAsync. 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 context
  • BeginRenderingComponent was actually async and also automatically dispatched to the sync context
  • ToHtmlString and WriteHtmlTo were both also async and automatically dispatched to the sync context

However 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. If ToHtmlString 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 older Html.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.

@SteveSandersonMS SteveSandersonMS added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Mar 3, 2023
@SteveSandersonMS SteveSandersonMS self-assigned this Mar 3, 2023
@SteveSandersonMS SteveSandersonMS added this to the 8.0-preview3 milestone Mar 3, 2023
@DamianEdwards
Copy link
Member

What do we think about adding support for rendering to non-string-based outputs to allow for more optimized rendering modes, e.g. IBufferWriter<byte>? UTF8-based outputs allow for rendering to be much lower allocation (even allocation-free) through use of things like Span and Utf8Formatter in the renderer. RazorSlices utilizes this (but with .cshtml-based templates) to support rendering directly to something like a PipeWriter (e.g. Response.BodyWriter) with basically zero allocations.

That said, it's possible something similar could be achieved with a custom TextWriter too, but perhaps not quite to the same extent as there isn't complete alignment in the Write overloads available on TextWriter and the Utf8Formatter.TryFormat overloads (WRT to type of the value being written, e.g. DateTime).

@egil
Copy link
Contributor

egil commented Mar 7, 2023

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 API

The ParameterView type is not super user-friendly to work with, especially if you need to pass a render fragment (e.g. child content) to a parameter on a component, thus, in bUnit, we offer users the ability to render a component/render fragment via methods like this:

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 approach

The RenderComponentAsync method provides the user with a strongly typed builder pattern experience, that will help them pass in parameters of the right type to the right parameters of their component. And if the user refactors a parameter's name, it will not break their code.

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 Add method on the ComponentParameterCollectionBuilder<TComponent> type usually takes two arguments, a parameter selector (lambda) that is used to select the parameter of TComponent to pass a value to, and based on the selected parameter's type, the second argument to the Add method is at compile time know, and only the right type is allowed to be passed. In the example above, the parameter selector in the first call to Add selects the Numbers parameter which is of type int, and thus only an int can be passed to the second argument of the Add method.

More examples of the builder pattern can be seen in the bUnit docs.

RenderFragment approach

The second variant, RenderAsync(RenderFragment) is useful if the user wants to write their HTML-generating code in a .razor file, which allows them to pass parameters or render multiple components together in a "Blazor native" way. This makes it much easier to pass child content (render fragments) to components.

For example, if a user wants to render NonBlazorTypesParams from above, they create a .razor file, and write the following:

@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 <text> element.

More examples of the render fragment pattern can be seen in the bUnit docs.

Sync Context

If 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 htmlRenderer.Dispatcher.InvokeAsync(() => ...).

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 HtmlRenderer could serve as a "enter rendering scope" thingy, e.g.:

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 handling

It 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 OnAfterRenderAsync followed by one or more StateHasChanged calls be supported or will the WaitForQuiescenceAsync task complete once the entire component tree has finished its first complete render cycle (including async data loading in OnInitializedAsync).

I could imagine somebody would want to asynchronously listen to a stream of data, e.g. IAsyncEnumerable, and whenever new data is pushed, rerender and push the latest HTML to a TextWriter. In that case, does the WaitForQuiescenceAsync task first complete when the IAsyncEnumerable stream completes?

@SteveSandersonMS
Copy link
Member Author

What do we think about adding support for rendering to non-string-based outputs to allow for more optimized rendering modes, e.g. IBufferWriter?

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 RenderTreeFrame representation. If we did have UTF8 data at the point where we were writing to the output, I'm sure it would be more efficient to skip the UTF16->8 conversion. However changing the whole pipeline would have to be a long-term project and certainly well outside the scope of this PR.

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 TextWriter to IBufferWriter<byte>. I don't think the UTF16->8 conversions should innately cause allocations - haven't actually verified but would expect the TextWriter to be encoding directly into its output buffer rather than allocating and encoding separate byte arrays per chunk. However if I'm missing something please let me know!

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Mar 8, 2023

The ParameterView type is not super user-friendly to work with,

Agreed - it's optimized more for efficiency since we expect it to be rare for people to interact with it directly. We do have ParameterView.FromDictionary which I think will be convenient enough for most uses of this new functionality. If people are working heavily with RenderFragment-typed parameters they could certainly build further helpers like you have done.

Builder approach / RenderFragment approach

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 .razor file, and in any case we may want to support an even more basic and strongly-typed way to set parameters by directly using the property setters:

var content = await htmlRenderer.RenderComponentAsync(new MyComponent { Param1 = "Hello", Param2 = 123 });

Sync Context

Yes, we are requiring API users to dispatch to the sync context, and will throw explanatory exceptions otherwise.

Perhaps another dedicated method on HtmlRenderer could serve as a "enter rendering scope" thingy, e.g.:

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.

For example, will async code in OnAfterRenderAsync

As with the existing prerendering support, OnAfterRenderAsync never fires in static HTML rendering scenarios, because that lifecycle event refers to "after the DOM is updated" and of course that never happens if there is no DOM. As such, OnAfterRenderAsync remains a good place to do JS interop, safely knowing it won't happen (and hence fail) in render-to-string cases.

will the WaitForQuiescenceAsync task complete once the entire component tree has finished its first complete render cycle (including async data loading in OnInitializedAsync).

That's right.

In that case, does the WaitForQuiescenceAsync task first complete when the IAsyncEnumerable stream completes?

It completes when all Task values returned from lifecycle methods have completed, which is the same as with how the existing prerendering mechanism knows when to write out the response.

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 Renderer subclass and overriding UpdateDisplayAsync to get notification about each renderbatch. I understand that's what you already do in bUnit.

@egil
Copy link
Contributor

egil commented Mar 8, 2023

... 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 .razor file...

Makes sense. In any case, I could quite easily package up some extension methods that would enable more advanced scenarios.

... and in any case we may want to support an even more basic and strongly-typed way to set parameters by directly using the property setters:

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 ParameterView and assigns properties via the SetParametersAsync method would override already assigned parameters. I guess in that case, users would have to create their own "wrapper component" that passes values to child components in a normal way.

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.

@SteveSandersonMS
Copy link
Member Author

Regarding setting parameters directly, won't that break Blazor component life-cycle expectations?

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.

@DamianEdwards
Copy link
Member

@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 ReadOnlySpan<byte> or byte[] literals at compile time. However, while small, the cost of UTF16 -> UTF8 conversion when rendering to a UTF8 output is measurable (it shows up in the profiles for the benchmarking Razor slices work) but not significant.

The more impactful area is when writing non-string values to the TextWriter, the default implementation simply calls ToString() on them, which allocates. As mentioned, a custom TextWriter can change this, but it's limited by the surface area of TextWriter itself.

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Mar 8, 2023

The more impactful area is when writing non-string values

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 @someValue, we immediately call ToString() on it as part of writing someValue into a RenderTreeFrame struct. Historically the rationale was that we know it's going to surface as a string in the RenderBatch anyway, and if someValue is a value type we would have to box it to put it on the struct anyway, so the least allocatey thing we can do is to ensure it's a string from the start (then we know it's either zero or 1 allocation based on whether it was already a string). Also the desired diffing semantics are in terms of the string representations, since the UI must update if and only if that changes.

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 byte[] buffers we could use to hold the UTF8-stringified representations of the value types we're given. Again, outside the scope of this PR, but definitely a possible area to pursue as we become more focused on the static rendering performance of components.

@DamianEdwards
Copy link
Member

DamianEdwards commented Mar 10, 2023

Given a Razor snippet like @someValue, we immediately call ToString() on it as part of writing someValue into a RenderTreeFrame struct.

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 RenderValue<T> where T is the type of the original value to be rendered, thus allowing for optimized rendering later?

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Mar 10, 2023

What's preventing them being stored in the tree as something like RenderValue where T is the type of the original value to be rendered, thus allowing for optimized rendering later?

Nothing really stops that - the field type could even just be object. But either way, the value (or the RenderValue<T> struct value) would still have to be boxed to be stored in a heterogeneous array of frames of different types.

The only boxing-free alternative I can think of is to change the backing store from a RenderTreeFrame[] to a plain byte buffer and have variable-length frames. That would mean storing a bunch more pointers to make up for the loss in random access to frames at known offsets, and might involve uses of unsafe conversions from Span<byte> to different struct types, but may well be possible.

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Mar 10, 2023

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 bool values, so it was really only numeric/date values left.

@DamianEdwards
Copy link
Member

My guess is the savings from avoiding ToString() calls on rendered values that could be emitted via more efficient means is likely to outweigh the cost of boxing those values, which of course we should validate before committing to such a change, but that's what I saw when implementing something similar in Razor Slices, i.e. all values from the Write<T>(T value) calls in the view were added to a List<RenderValue> field and RenderValue had fields for specific value types other than object that are commonly used in write expressions and were known to be able to be rendered in an optimized fashion (e.g. via Utf8Formatter.TryFormat(int value, ..), obviously each field increases the size of the struct so it could be limited just to types like int and DateTime that to avoid boxing those very commonly rendered values and just box everything else into the object field which still allows for optimized rendering of them later.

@SteveSandersonMS
Copy link
Member Author

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.

@SteveSandersonMS SteveSandersonMS changed the title Blazor component rendering as a library API review: Blazor component rendering as a library Mar 15, 2023
@danroth27
Copy link
Member

@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?

@danroth27
Copy link
Member

@daveaglick Given your work on Statiq we'd be interested in your feedback on this API design as well.

@jsakamoto
Copy link

@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 "<html>" to "</html>". So I guess this feature will not be so helpful in that scenario.

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!

@mkArtakMSFT mkArtakMSFT added the area-blazor Includes: Blazor, Razor Components label Apr 19, 2023
@sulmar
Copy link

sulmar commented May 10, 2023

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; }    

}

@sulmar
Copy link

sulmar commented May 10, 2023

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.

@codemonkey85
Copy link

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.

@egil
Copy link
Contributor

egil commented May 10, 2023

@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 HtmlRenderer in a razor file and leverage the Razor compiler to validate the parameters for you.

E.g. in a HtmlGenerator.razor file:

@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();
    });
  }
}

@mkArtakMSFT mkArtakMSFT modified the milestones: 8.0-preview7, 8.0-rc1 Jul 24, 2023
@SteveSandersonMS SteveSandersonMS changed the title API review: Blazor component rendering as a library API review: Component rendering to HTML for libraries (outside Blazor environment) Aug 15, 2023
@SteveSandersonMS SteveSandersonMS changed the title API review: Component rendering to HTML for libraries (outside Blazor environment) API review: Component rendering to HTML for libraries (outside Blazor) Aug 15, 2023
@SteveSandersonMS SteveSandersonMS modified the milestones: 8.0-rc1, 8.0-rc2 Aug 15, 2023
@SteveSandersonMS SteveSandersonMS added the feature-full-stack-web-ui Full stack web UI with Blazor label Aug 15, 2023
@halter73
Copy link
Member

halter73 commented Aug 15, 2023

API Review Notes:

  • Why do we have both an HtmlRenderer and StaticHtmlRenderer?
    • We expect HtmlRenderer is the simpler thing that will be used more, but we want to also expose the StaticHtmlRenderer as a lower level API with more control.
    • Unlike HtmlRenderer, StaticHtmlRenderer cannot be sealed since EndpointHtmlRenderer derives from it.
  • Do we want an IServiceProvider-only constructor for HtmlRenderer for convenience?
    • It might not make it clear the ILoggerFactory is necessary, but it could be more convenient. @javiercn
    • This is similar to Renderer.cs
  • Why not add an AddHtmlRenderer IServiceCollection extension method
    • Applications would have to use DI scopes to manage lifetime which would be inconvenient.
    • And you could register it as a scoped service yourself if you really wanted.
  • Why do we have BeginRender and Render methods.
    • Sometimes you need the HtmlRootComponent before the Task<HtmlRootComponent> would complete. The QuiescenceTask is meant for people calling the BeginRendering... methods. It will always be pre-completed when returned from a Task-returning method.
      • We could make the BeginRendering methods the only methods and force the use of the QuiescenceTask but it would be error prone. In the example before ToHtmlString() might return while in the loading state if you do not await properly.
      await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
      var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
      {
          var output = htmlRenderer.RenderComponent<SomeComponent>(parameters);
          //await output.QuiescenceTask;
          return output.ToHtmlString();
      });
      
  • Do we need HtmlRootComponent.ComponentId?
    • Probably not. Let's remove it.
  • Do we want to make any other changes to HtmlRootComponent?
    • We like it as a readonly struct for perf reasons
    • We could make it implement IHtmlContent, but see no benefit

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) {}
   }
}

@halter73 halter73 added api-approved API was approved in API review, it can be implemented and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Aug 15, 2023
@SteveSandersonMS
Copy link
Member Author

Implemented in #50181

@ghost ghost locked as resolved and limited conversation to collaborators Sep 22, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components feature-full-stack-web-ui Full stack web UI with Blazor
Projects
None yet
Development

No branches or pull requests

11 participants