Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions Fluid.Benchmarks/NestedRenderBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;

namespace Fluid.Benchmarks
{
[MemoryDiagnoser]
public class NestedRenderBenchmarks
{
private const int ItemCount = 100;

private readonly CountingFluidOutput _output = new();
private readonly TemplateContext _context;
private readonly IFluidTemplate _renderTemplate;
private readonly IFluidTemplate _renderForTemplate;

public NestedRenderBenchmarks()
{
var parser = new FluidParser();
var options = new TemplateOptions
{
FileProvider = new InMemoryTemplateFileProvider("product.liquid", "{{ product }};")
};

_context = new TemplateContext(options);
_context.SetValue("products", Enumerable.Range(1, ItemCount).ToArray());

_renderTemplate = parser.Parse("{% render 'product', product: products[0] %}");
_renderForTemplate = parser.Parse("{% render 'product' for products as product %}");
}

[Benchmark]
public async ValueTask<int> Render()
{
_output.Reset();
await _renderTemplate.RenderAsync(_output, NullEncoder.Default, _context);
return _output.Written;
}

[Benchmark]
public async ValueTask<int> RenderFor()
{
_output.Reset();
await _renderForTemplate.RenderAsync(_output, NullEncoder.Default, _context);
return _output.Written;
}

private sealed class CountingFluidOutput : IFluidOutput
{
private char[] _buffer = new char[1024];

public int Written { get; private set; }

public int FlushCount { get; private set; }

public void Advance(int count) => Written += count;

public Memory<char> GetMemory(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsMemory(Written);
}

public Span<char> GetSpan(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsSpan(Written);
}

public void Write(string value)
{
EnsureCapacity(value.Length);
value.CopyTo(0, _buffer, Written, value.Length);
Written += value.Length;
}

public void Write(char[] buffer, int index, int count)
{
EnsureCapacity(count);
buffer.AsSpan(index, count).CopyTo(_buffer.AsSpan(Written));
Written += count;
}

public ValueTask FlushAsync()
{
FlushCount++;
return default;
}

public void Reset()
{
Written = 0;
FlushCount = 0;
}

private void EnsureCapacity(int sizeHint)
{
var required = Written + Math.Max(sizeHint, 1);
if (required > _buffer.Length)
{
Array.Resize(ref _buffer, Math.Max(required, _buffer.Length * 2));
}
}
}

private sealed class InMemoryTemplateFileProvider : ITemplateFileProvider
{
private readonly byte[] _content;
private readonly string _path;

public InMemoryTemplateFileProvider(string path, string content)
{
_path = path;
_content = Encoding.UTF8.GetBytes(content);
}

public ValueTask<TemplateSourceInfo> GetFileInfoAsync(
string subpath,
TemplateContext context,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

if (!string.Equals(subpath, _path, StringComparison.Ordinal))
{
return default;
}

return new ValueTask<TemplateSourceInfo>(
new TemplateSourceInfo(
DateTimeOffset.UnixEpoch,
_ => new ValueTask<Stream>(new MemoryStream(_content, writable: false))));
}
}
}
}
111 changes: 111 additions & 0 deletions Fluid.Tests/RenderFlushTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using Fluid.Tests.Mocks;
using System;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Xunit;

namespace Fluid.Tests
{
public class RenderFlushTests
{
#if COMPILED
private static readonly FluidParser _parser = new FluidParser().Compile();
#else
private static readonly FluidParser _parser = new FluidParser();
#endif

[Fact]
public async Task PublicOutputRender_FlushesOnce()
{
var template = _parser.Parse("Hello");
var output = new FlushTrackingOutput();

await template.RenderAsync(output, NullEncoder.Default, new TemplateContext());

Assert.Equal("Hello", output.ToString());
Assert.Equal(1, output.FlushCount);
}

[Fact]
public async Task RenderFor_FlushesOnlyAtOuterBoundary()
{
var fileProvider = new MockFileProvider()
.Add("product.liquid", "{{ product }};");
var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider });
context.SetValue("products", new[] { 1, 2, 3 });
var template = _parser.Parse("{% render 'product' for products as product %}");
var output = new FlushTrackingOutput();

await template.RenderAsync(output, NullEncoder.Default, context);

Assert.Equal("1;2;3;", output.ToString());
Assert.Equal(1, output.FlushCount);
}

[Fact]
public async Task IncludeFor_FlushesOnlyAtOuterBoundary()
{
var fileProvider = new MockFileProvider()
.Add("product.liquid", "{{ product }};");
var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider });
context.SetValue("products", new[] { 1, 2, 3 });
var template = _parser.Parse("{% include 'product' for products as product %}");
var output = new FlushTrackingOutput();

await template.RenderAsync(output, NullEncoder.Default, context);

Assert.Equal("1;2;3;", output.ToString());
Assert.Equal(1, output.FlushCount);
}

[Fact]
public async Task RenderFor_MaxOutputSizeIsCumulativeAcrossChildren()
{
var fileProvider = new MockFileProvider()
.Add("product.liquid", "{{ product }}");
var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider })
{
MaxOutputSize = 5
};
context.SetValue("products", new[] { 12, 34, 56 });
var template = _parser.Parse("{% render 'product' for products as product %}");

await Assert.ThrowsAsync<InvalidOperationException>(
() => template.RenderAsync(new FlushTrackingOutput(), NullEncoder.Default, context).AsTask());
}

private sealed class FlushTrackingOutput : IFluidOutput
{
private char[] _buffer = new char[256];
private int _index;

public int FlushCount { get; private set; }

public void Advance(int count) => _index += count;

public Memory<char> GetMemory(int sizeHint = 0) => _buffer.AsMemory(_index);

public Span<char> GetSpan(int sizeHint = 0) => _buffer.AsSpan(_index);

public void Write(string value)
{
value.CopyTo(0, _buffer, _index, value.Length);
_index += value.Length;
}

public void Write(char[] buffer, int index, int count)
{
buffer.AsSpan(index, count).CopyTo(_buffer.AsSpan(_index));
_index += count;
}

public ValueTask FlushAsync()
{
FlushCount++;
return default;
}

public override string ToString() => new string(_buffer, 0, _index);
}
}
}
32 changes: 29 additions & 3 deletions Fluid.Tests/SourceGeneration/SourceGenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,33 @@ public async Task GeneratedTemplate_FlushesOutput()

await instance.RenderAsync(output, HtmlEncoder.Default, new TemplateContext());

Assert.True(output.Flushed);
Assert.Equal(1, output.FlushCount);
}

[Fact]
public async Task GeneratedNestedRender_FlushesOnlyAtOuterBoundary()
{
var provider = new MockFileProvider()
.Add("partial", "{{ item }}");

var parser = new FluidParser();
var template = parser.Parse("{% render 'partial' for items as item %}");
var source = template.Compile(new SourceGenerationOptions
{
Namespace = "Fluid.Tests.Generated",
ClassName = "T" + Guid.NewGuid().ToString("N"),
FileProvider = provider
});

var generated = CompileToAssembly(source.SourceCode);
var type = generated.GetType(source.FullTypeName, throwOnError: true);
var instance = (IFluidTemplate)Activator.CreateInstance(type, nonPublic: true);
var output = new FlushTrackingOutput();
var context = new TemplateContext().SetValue("items", new[] { 1, 2, 3 });

await instance.RenderAsync(output, HtmlEncoder.Default, context);

Assert.Equal(1, output.FlushCount);
}

[Fact]
Expand Down Expand Up @@ -240,7 +266,7 @@ private sealed class FlushTrackingOutput : IFluidOutput
private char[] _buffer = new char[256];
private int _index;

public bool Flushed { get; private set; }
public int FlushCount { get; private set; }

public void Advance(int count) => _index += count;

Expand All @@ -262,7 +288,7 @@ public void Write(char[] buffer, int index, int count)

public ValueTask FlushAsync()
{
Flushed = true;
FlushCount++;
return default;
}
}
Expand Down
39 changes: 4 additions & 35 deletions Fluid/Ast/IncludeStatement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, Te
}
}

return await RenderStatementsAsync(template, output, encoder, context);
return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context);
}
else if (AssignStatements.Count > 0)
{
Expand All @@ -83,7 +83,7 @@ public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, Te
context.LocalScope.SetOwnValue(stmt.Identifier, await stmt.Value.EvaluateAsync(context));
}

return await RenderStatementsAsync(template, output, encoder, context);
return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context);
}
else if (For != null)
{
Expand Down Expand Up @@ -118,7 +118,7 @@ public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, Te
forloop.First = i == 0;
forloop.Last = i == length - 1;

var completion = await RenderStatementsAsync(template, output, encoder, context);
var completion = await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context);

if (completion == Completion.Break)
{
Expand All @@ -140,7 +140,7 @@ public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, Te
else
{
// no with, for or assignments, e.g. {% include 'products' %}
return await RenderStatementsAsync(template, output, encoder, context);
return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context);
}
}
finally
Expand All @@ -158,37 +158,6 @@ public override async ValueTask<Completion> WriteToAsync(IFluidOutput output, Te
}
}

/// <summary>
/// Renders template statements and returns the completion status.
/// This allows break/continue signals to propagate from included templates.
/// </summary>
private static async ValueTask<Completion> RenderStatementsAsync(IFluidTemplate template, IFluidOutput output, TextEncoder encoder, TemplateContext context)
{
if (template is IStatementList statementList)
{
var statements = statementList.Statements;
var count = statements.Count;
for (var i = 0; i < count; i++)
{
var completion = await statements[i].WriteToAsync(output, encoder, context);

if (completion != Completion.Normal)
{
return completion;
}
}
}
else
{
// Fallback for non-standard template implementations
await template.RenderAsync(output, encoder, context);
}

context.CancellationToken.ThrowIfCancellationRequested();
await output.FlushAsync();
return Completion.Normal;
}

protected internal override Statement Accept(AstVisitor visitor) => visitor.VisitIncludeStatement(this);

private sealed record CachedTemplate(IFluidTemplate Template, string Name);
Expand Down
Loading