Skip to content

Commit

Permalink
feat: IConditionalOutputComponent used to determine if a component sh…
Browse files Browse the repository at this point in the history
…ould produce markup
  • Loading branch information
egil committed May 2, 2024
1 parent b0e1a8c commit 8e82c60
Show file tree
Hide file tree
Showing 11 changed files with 165 additions and 101 deletions.
5 changes: 5 additions & 0 deletions src/Htmxor/Builder/HtmxorComponentEndpointDataSource.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Reflection;
using Htmxor.Components;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -53,6 +54,10 @@ private static List<Endpoint> UpdateEndpoints(IReadOnlyList<ComponentInfo> compo
{
builder.Metadata.Add(new HtmxorLayoutComponentMetadata(componentInfo.ComponentLayoutType));
}
else
{
builder.Metadata.Add(new HtmxorLayoutComponentMetadata(typeof(HtmxorLayoutComponentBase)));
}

builder.RequestDelegate = static httpContext =>
{
Expand Down
9 changes: 0 additions & 9 deletions src/Htmxor/Components/FragmentBase.cs

This file was deleted.

22 changes: 13 additions & 9 deletions src/Htmxor/Components/HtmxPartial.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components;

namespace Htmxor.Components;

public sealed class HtmxPartial : FragmentBase
public sealed class HtmxPartial : IComponent, IConditionalOutputComponent
{
private RenderHandle renderHandle;

[Parameter, EditorRequired]
public required RenderFragment ChildContent { get; set; }

[Parameter] public bool Condition { get; set; } = true;

public override Task SetParametersAsync(ParameterView parameters)

public Task SetParametersAsync(ParameterView parameters)
{
if (!parameters.TryGetValue<RenderFragment>(nameof(ChildContent), out var childContent))
{
Expand All @@ -20,12 +21,15 @@ public override Task SetParametersAsync(ParameterView parameters)

ChildContent = childContent;
Condition = parameters.GetValueOrDefault(nameof(Condition), true);
return base.SetParametersAsync(parameters);
renderHandle.Render(ChildContent);
return Task.CompletedTask;
}

protected override void BuildRenderTree([NotNull] RenderTreeBuilder builder)
=> builder.AddContent(0, ChildContent);
void IComponent.Attach(RenderHandle renderHandle)
{
this.renderHandle = renderHandle;
}

protected override bool ShouldRender() => Condition;
bool IConditionalOutputComponent.ShouldOutput(int _) => Condition;
}

13 changes: 13 additions & 0 deletions src/Htmxor/Components/HtmxorLayoutComponentBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace Htmxor.Components;

public class HtmxorLayoutComponentBase : LayoutComponentBase, IConditionalOutputComponent
{
public bool ShouldOutput(int conditionalChildren)
=> conditionalChildren == 0;

protected override void BuildRenderTree([NotNull] RenderTreeBuilder builder)
=> builder.AddContent(0, Body);
}
15 changes: 15 additions & 0 deletions src/Htmxor/Components/IConditionalOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Htmxor.Components;

/// <summary>
/// Represents a component that can conditionally produce markup.
/// </summary>
public interface IConditionalOutputComponent
{
/// <summary>
/// Determine whether this component should produce any markup during a request.
/// </summary>
/// <param name="conditionalChildren">The number of children that implements <see cref="IConditionalOutputComponent"/>.</param>
/// <returns><see langword="true"/> if the component should produce markup, <see langword="false"/> otherwise.</returns>
bool ShouldOutput(int conditionalChildren);
}

11 changes: 6 additions & 5 deletions src/Htmxor/HtmxorComponentEndpointInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public HtmxorComponentEndpointInvoker(HtmxorRenderer renderer, ILogger<HtmxorCom
this.logger = logger;
}

public Task Render(HttpContext context) => renderer.Dispatcher.InvokeAsync(() => RenderComponentCore(context));
public Task Render(HttpContext context)
=> renderer.Dispatcher.InvokeAsync(() => RenderComponentCore(context));

private async Task RenderComponentCore(HttpContext context)
{
Expand Down Expand Up @@ -129,10 +130,10 @@ private async Task RenderComponentCore(HttpContext context)
return;
}

// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
var defaultBufferSize = 16 * 1024;
await using var writer = new HttpResponseStreamWriter(context.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
using var bufferWriter = new BufferedTextWriter(writer);
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
var defaultBufferSize = 16 * 1024;
await using var writer = new HttpResponseStreamWriter(context.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
using var bufferWriter = new ConditionalBufferedTextWriter(writer);

// Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context)
// in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent
Expand Down
45 changes: 45 additions & 0 deletions src/Htmxor/Rendering/Buffering/ConditionalBufferedTextWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Htmxor.Rendering.Buffering;

/// <summary>
/// A text writer that will only output when its <see cref="ShouldWrite"/> returns true.
/// </summary>
internal sealed class ConditionalBufferedTextWriter : BufferedTextWriter
{
internal bool ShouldWrite { get; set; } = true;

public ConditionalBufferedTextWriter(TextWriter underlying) : base(underlying)
{
}

public override void Write(char value)
{
if (ShouldWrite)
{
base.Write(value);
}
}

public override void Write(char[] buffer, int index, int count)
{
if (ShouldWrite)
{
base.Write(buffer, index, count);
}
}

public override void Write(string? value)
{
if (ShouldWrite)
{
base.Write(value);
}
}

public override void Write(int value)
{
if (ShouldWrite)
{
base.Write(value);
}
}
}
43 changes: 38 additions & 5 deletions src/Htmxor/Rendering/HtmxorComponentState.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,56 @@
using Microsoft.AspNetCore.Components;
using System.Diagnostics;
using Htmxor.Components;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;

namespace Htmxor.Rendering;

internal class HtmxorComponentState : ComponentState
{
public HtmxorComponentState(Renderer renderer, int componentId, IComponent component, HtmxorComponentState? parentComponentState)
private readonly HtmxorComponentState? parentComponentState;
private int conditionalChildrenCount;
private IConditionalOutputComponent? conditionalOutput;
private bool isDisposed;

public HtmxorComponentState(HtmxorRenderer renderer, int componentId, IComponent component, HtmxorComponentState? parentComponentState)
: base(renderer, componentId, component, parentComponentState)
{
if (component is IConditionalOutputComponent conditionalOutput)
{
this.conditionalOutput = conditionalOutput;
parentComponentState?.ConditionalChildAdded();
}

this.parentComponentState = parentComponentState;
}

public override ValueTask DisposeAsync()
{
if (parentComponentState is not null && conditionalOutput is not null && !isDisposed)
{
parentComponentState.ConditionalChildDisposed();
}

isDisposed = true;

return base.DisposeAsync();
}

internal bool HasPartialFragments()
private void ConditionalChildAdded()
{
throw new NotImplementedException();
conditionalChildrenCount++;
parentComponentState?.ConditionalChildAdded();
}

private void ConditionalChildDisposed()
{
conditionalChildrenCount--;
parentComponentState?.ConditionalChildDisposed();
Debug.Assert(conditionalChildrenCount >= 0, "conditionalChildrenCount should never be able to be less than zero");
}

internal bool ShouldGenerateMarkup()
=> conditionalOutput?.ShouldOutput(conditionalChildrenCount)
?? parentComponentState?.ShouldGenerateMarkup()
?? true;
}
26 changes: 15 additions & 11 deletions src/Htmxor/Rendering/HtmxorRenderer.HtmlWriting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text;
using System.Text.Encodings.Web;
using Htmxor.Http;
using Htmxor.Rendering.Buffering;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Rendering;
Expand All @@ -30,22 +31,25 @@ internal partial class HtmxorRenderer
args: [new CascadingParameterAttribute(), string.Empty, typeof(FormMappingContext)],
culture: CultureInfo.InvariantCulture)!;

private readonly TextEncoder _javaScriptEncoder;
private TextEncoder _htmlEncoder;
private string? _closestSelectValueAsString;
private bool writeFromCompleteRenderTree;
private readonly TextEncoder javaScriptEncoder;
private TextEncoder htmlEncoder;
private string? closestSelectValueAsString;

private void WriteRootComponent(HtmxorComponentState rootComponentState, TextWriter output)
{
// We're about to walk over some buffers inside the renderer that can be mutated during rendering.
// So, we require exclusive access to the renderer during this synchronous process.
Dispatcher.AssertAccess();

WriteComponent(rootComponentState, output);
}

private void WriteComponent(HtmxorComponentState componentState, TextWriter output)
{
if (output is ConditionalBufferedTextWriter conditionalOutput)
{
conditionalOutput.ShouldWrite = componentState.ShouldGenerateMarkup();
}

var frames = GetCurrentRenderTreeFrames(componentState.ComponentId);
RenderFrames(componentState, output, frames, 0, frames.Count);
}
Expand Down Expand Up @@ -87,7 +91,7 @@ private int RenderFrames(HtmxorComponentState componentState, TextWriter output,
case RenderTreeFrameType.Attribute:
throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}");
case RenderTreeFrameType.Text:
_htmlEncoder.Encode(output, frame.TextContent);
htmlEncoder.Encode(output, frame.TextContent);
return ++position;
case RenderTreeFrameType.Markup:
output.Write(frame.MarkupContent);
Expand Down Expand Up @@ -144,7 +148,7 @@ private int RenderElement(HtmxorComponentState componentState, TextWriter output
{
// Textarea is a special type of form field where the value is given as text content instead of a 'value' attribute
// So, if we captured a value attribute, use that instead of any child content
_htmlEncoder.Encode(output, capturedValueAttribute);
htmlEncoder.Encode(output, capturedValueAttribute);
afterElement = position + frame.ElementSubtreeLength; // Skip descendants
}
else if (string.Equals(frame.ElementName, "script", StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -193,15 +197,15 @@ private int RenderScriptElementChildren(HtmxorComponentState componentState, Tex
// user-supplied content inside a <script> block, but that if someone does, we
// want the encoding style to match the context for correctness and safety. This is
// also consistent with .cshtml's treatment of <script>.
var originalEncoder = _htmlEncoder;
var originalEncoder = htmlEncoder;
try
{
_htmlEncoder = _javaScriptEncoder;
htmlEncoder = javaScriptEncoder;
return RenderChildren(componentState, output, frames, position, maxElements);
}
finally
{
_htmlEncoder = originalEncoder;
htmlEncoder = originalEncoder;
}
}

Expand All @@ -219,7 +223,7 @@ private void RenderHiddenFieldForNamedSubmitEvent(HtmxorComponentState component
if (TryCreateScopeQualifiedEventName(componentState.ComponentId, namedEventFrame.NamedEventAssignedName, out var combinedFormName))
{
output.Write("<input type=\"hidden\" name=\"_handler\" value=\"");
_htmlEncoder.Encode(output, combinedFormName);
htmlEncoder.Encode(output, combinedFormName);
output.Write("\" />");
}
}
Expand Down
14 changes: 0 additions & 14 deletions src/Htmxor/Rendering/HtmxorRenderer.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,6 @@ namespace Htmxor.Rendering;

internal partial class HtmxorRenderer
{
private HttpContext httpContext = default!; // Always set at the start of an inbound call

private void SetHttpContext(HttpContext httpContext)
{
if (this.httpContext is null)
{
this.httpContext = httpContext;
}
else if (this.httpContext != httpContext)
{
throw new InvalidOperationException("The HttpContext cannot change value once assigned.");
}
}

internal async ValueTask<RenderedComponentHtmlContent> RenderEndpointComponent(
HttpContext httpContext,
[DynamicallyAccessedMembers(Component)] Type rootComponentType,
Expand Down
Loading

0 comments on commit 8e82c60

Please sign in to comment.