Skip to content

API review: Component rendering to HTML for libraries (outside Blazor) #47018

Closed
@SteveSandersonMS

Description

@SteveSandersonMS

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.

Metadata

Metadata

Labels

api-approvedAPI was approved in API review, it can be implementedarea-blazorIncludes: Blazor, Razor Componentsfeature-full-stack-web-uiFull stack web UI with Blazor

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions