diff --git a/README.md b/README.md
index 84c6688..a3e835e 100644
--- a/README.md
+++ b/README.md
@@ -196,6 +196,25 @@ The `RazorTemplate` base class provides `Render` and `RenderAsync` methods to ex
Templates are stateful and not thread-safe, so it is advised to always create new instances of the templates to render.
+### Flushing partial output
+
+By default, the output of a template is buffered while it is executing, then copied to the provided writer when finished. This is necessary for features such as layouts to be supported, but may not always be desired.
+
+The `RazorTemplate` class provides a `FlushAsync` method which will copy the buffered output to the provided `TextWriter` and then flush the writer:
+
+
+
+```cshtml
+
Lightweight content goes here.
+@await FlushAsync()
+Slower to render content goes here.
+```
+snippet source | anchor
+
+
+> [!IMPORTANT]
+> Flushing is not compatible with layout usage.
+
### MSBuild
The source generator will process `RazorBlade` MSBuild items which have the `.cshtml` file extension.
diff --git a/src/RazorBlade.IntegrationTest/Examples/TemplateWithFlush.cshtml b/src/RazorBlade.IntegrationTest/Examples/TemplateWithFlush.cshtml
new file mode 100644
index 0000000..f339dc4
--- /dev/null
+++ b/src/RazorBlade.IntegrationTest/Examples/TemplateWithFlush.cshtml
@@ -0,0 +1,6 @@
+@inherits RazorBlade.HtmlTemplate
+@* begin-snippet: TemplateWithFlush.Usage *@
+Lightweight content goes here.
+@await FlushAsync()
+Slower to render content goes here.
+@* end-snippet *@
diff --git a/src/RazorBlade.IntegrationTest/PageWithFlush.cshtml b/src/RazorBlade.IntegrationTest/PageWithFlush.cshtml
new file mode 100644
index 0000000..a3eb88e
--- /dev/null
+++ b/src/RazorBlade.IntegrationTest/PageWithFlush.cshtml
@@ -0,0 +1,7 @@
+@inherits RazorBlade.HtmlTemplate
+Hello, world!
+This is the body contents before flushing.
+@await FlushAsync()
+This is the body contents after flushing.
+@await FlushAsync()
+This is the body contents after a second flushing.
diff --git a/src/RazorBlade.IntegrationTest/Program.cs b/src/RazorBlade.IntegrationTest/Program.cs
index a1819fb..69de8c2 100644
--- a/src/RazorBlade.IntegrationTest/Program.cs
+++ b/src/RazorBlade.IntegrationTest/Program.cs
@@ -10,6 +10,7 @@ public static void Main()
WriteTemplate(new TestTemplate { Name = "World" });
WriteTemplate(new TestTemplateWithModel(new FooBarModelClass { Foo = "Foo", Bar = "Bar" }));
WriteTemplate(new PageWithLayout());
+ WriteTemplate(new PageWithFlush());
}
private static void WriteTemplate(RazorTemplate template)
diff --git a/src/RazorBlade.Library/HtmlLayout.cs b/src/RazorBlade.Library/HtmlLayout.cs
index d02b3f0..82cbf72 100644
--- a/src/RazorBlade.Library/HtmlLayout.cs
+++ b/src/RazorBlade.Library/HtmlLayout.cs
@@ -21,7 +21,7 @@ async Task IRazorLayout.ExecuteLayoutAsync(IRazorExecutio
try
{
_layoutInput = input;
- return await ExecuteAsyncCore(input.CancellationToken);
+ return await ExecuteAsyncCore(null, input.CancellationToken);
}
finally
{
@@ -29,12 +29,12 @@ async Task IRazorLayout.ExecuteLayoutAsync(IRazorExecutio
}
}
- private protected override Task ExecuteAsyncCore(CancellationToken cancellationToken)
+ private protected override Task ExecuteAsyncCore(TextWriter? targetOutput, CancellationToken cancellationToken)
{
if (_layoutInput is null)
throw new InvalidOperationException(_contentsRequiredErrorMessage);
- return base.ExecuteAsyncCore(cancellationToken);
+ return base.ExecuteAsyncCore(targetOutput, cancellationToken);
}
///
diff --git a/src/RazorBlade.Library/RazorTemplate.cs b/src/RazorBlade.Library/RazorTemplate.cs
index 11faf74..9a78915 100644
--- a/src/RazorBlade.Library/RazorTemplate.cs
+++ b/src/RazorBlade.Library/RazorTemplate.cs
@@ -15,24 +15,26 @@ namespace RazorBlade;
///
public abstract class RazorTemplate : IEncodedContent
{
- private Dictionary>? _sections;
-
- private Dictionary> Sections => _sections ??= new(StringComparer.OrdinalIgnoreCase);
+ private ExecutionScope? _executionScope;
///
/// The which receives the output.
///
- protected internal TextWriter Output { get; internal set; } = new StreamWriter(Stream.Null);
+ protected internal TextWriter Output => _executionScope?.BufferedOutput ?? TextWriter.Null;
///
/// The cancellation token.
///
- protected internal CancellationToken CancellationToken { get; private set; }
+ protected internal CancellationToken CancellationToken => _executionScope?.CancellationToken ?? CancellationToken.None;
///
/// The layout to use.
///
- private protected IRazorLayout? Layout { get; set; }
+ private protected IRazorLayout? Layout
+ {
+ get => _executionScope?.Layout;
+ set => (_executionScope ?? throw new InvalidOperationException("The layout can only be set while the template is executing.")).SetLayout(value);
+ }
///
/// Renders the template synchronously and returns the result as a string.
@@ -46,11 +48,12 @@ public string Render(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- var renderTask = RenderAsyncCore(cancellationToken);
+ var renderTask = RenderAsync(cancellationToken);
+
if (renderTask.IsCompleted)
- return renderTask.GetAwaiter().GetResult().ToString();
+ return renderTask.GetAwaiter().GetResult();
- return Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult().ToString();
+ return Task.Run(async () => await renderTask.ConfigureAwait(false), CancellationToken.None).GetAwaiter().GetResult();
}
///
@@ -67,6 +70,7 @@ public void Render(TextWriter textWriter, CancellationToken cancellationToken =
cancellationToken.ThrowIfCancellationRequested();
var renderTask = RenderAsync(textWriter, cancellationToken);
+
if (renderTask.IsCompleted)
{
renderTask.GetAwaiter().GetResult();
@@ -85,10 +89,18 @@ public void Render(TextWriter textWriter, CancellationToken cancellationToken =
///
public async Task RenderAsync(CancellationToken cancellationToken = default)
{
- cancellationToken.ThrowIfCancellationRequested();
+ var body = await RenderAsyncCore(null, cancellationToken).ConfigureAwait(false);
+
+ switch (body)
+ {
+ case BufferedContent { Output: var bufferedOutput }:
+ return bufferedOutput.ToString();
- var stringBuilder = await RenderAsyncCore(cancellationToken).ConfigureAwait(false);
- return stringBuilder.ToString();
+ default: // Fallback case, shouldn't happen
+ var writer = new StringWriter();
+ body.WriteTo(writer);
+ return writer.ToString();
+ }
}
///
@@ -101,25 +113,26 @@ public async Task RenderAsync(CancellationToken cancellationToken = defa
///
public async Task RenderAsync(TextWriter textWriter, CancellationToken cancellationToken = default)
{
- cancellationToken.ThrowIfCancellationRequested();
+ var body = await RenderAsyncCore(textWriter, cancellationToken).ConfigureAwait(false);
- var stringBuilder = await RenderAsyncCore(cancellationToken).ConfigureAwait(false);
+ switch (body)
+ {
+ case BufferedContent { Output: var bufferedOutput }:
+ await WriteStringBuilderToOutputAsync(bufferedOutput, textWriter, cancellationToken).ConfigureAwait(false);
+ break;
-#if NET6_0_OR_GREATER
- await textWriter.WriteAsync(stringBuilder, cancellationToken).ConfigureAwait(false);
-#else
- await textWriter.WriteAsync(stringBuilder.ToString()).ConfigureAwait(false);
-#endif
+ default: // Fallback case, shouldn't happen
+ body.WriteTo(textWriter);
+ break;
+ }
}
///
- /// Renders the template asynchronously including its layout and returns the result as a .
+ /// Renders the template and its layout stack.
///
- private async Task RenderAsyncCore(CancellationToken cancellationToken)
+ private async Task RenderAsyncCore(TextWriter? targetOutput, CancellationToken cancellationToken)
{
- cancellationToken.ThrowIfCancellationRequested();
-
- var executionResult = await ExecuteAsyncCore(cancellationToken);
+ var executionResult = await ExecuteAsyncCore(targetOutput, cancellationToken).ConfigureAwait(false);
while (executionResult.Layout is { } layout)
{
@@ -127,27 +140,39 @@ private async Task RenderAsyncCore(CancellationToken cancellation
executionResult = await layout.ExecuteLayoutAsync(executionResult).ConfigureAwait(false);
}
- if (executionResult.Body is EncodedContent { Output: var outputStringBuilder })
- return outputStringBuilder;
-
- // Fallback case, shouldn't happen
- var outputStringWriter = new StringWriter();
- executionResult.Body.WriteTo(outputStringWriter);
- return outputStringWriter.GetStringBuilder();
+ return executionResult.Body;
}
///
/// Calls the method in a new .
///
- private protected virtual async Task ExecuteAsyncCore(CancellationToken cancellationToken)
+ private protected virtual async Task ExecuteAsyncCore(TextWriter? targetOutput, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
- using var executionScope = new ExecutionScope(this, cancellationToken);
+ using var executionScope = ExecutionScope.StartBody(this, targetOutput, cancellationToken);
await ExecuteAsync().ConfigureAwait(false);
return new ExecutionResult(executionScope);
}
+ ///
+ /// Writes the buffered output to the target output then flushes the output stream.
+ ///
+ ///
+ /// An empty , which allows using a direct expression: @await FlushAsync()
+ ///
+ ///
+ /// This feature is not compatible with layouts.
+ ///
+ protected internal async Task FlushAsync()
+ {
+ if (_executionScope is not { } executionScope)
+ throw new InvalidOperationException("The template is not executing.");
+
+ await executionScope.FlushAsync().ConfigureAwait(false);
+ return HtmlString.Empty;
+ }
+
///
/// Executes the template and appends the result to .
///
@@ -219,14 +244,24 @@ protected internal virtual void Write(IEncodedContent? content)
[EditorBrowsable(EditorBrowsableState.Never)]
protected internal void DefineSection(string name, Func action)
{
+ if (_executionScope is not { } executionScope)
+ throw new InvalidOperationException("Sections can only be defined while the template is executing.");
+
+ executionScope.DefineSection(name, action);
+ }
+
+ ///
+ /// Writes the contents of a to a asynchronuously.
+ ///
+ private static Task WriteStringBuilderToOutputAsync(StringBuilder input, TextWriter output, CancellationToken cancellationToken)
+ {
+ if (input.Length == 0)
+ return Task.CompletedTask;
+
#if NET6_0_OR_GREATER
- if (!Sections.TryAdd(name, action))
- throw new InvalidOperationException($"Section '{name}' is already defined.");
+ return output.WriteAsync(input, cancellationToken);
#else
- if (Sections.ContainsKey(name))
- throw new InvalidOperationException($"Section '{name}' is already defined.");
-
- Sections[name] = action;
+ return output.WriteAsync(input.ToString());
#endif
}
@@ -234,39 +269,111 @@ void IEncodedContent.WriteTo(TextWriter textWriter)
=> Render(textWriter, CancellationToken.None);
///
- /// Saves the current state, resets it, and restores it when disposed.
+ /// Stores the state of a template execution.
///
private class ExecutionScope : IDisposable
{
- private readonly Dictionary>? _previousSections;
- private readonly TextWriter _previousOutput;
- private readonly CancellationToken _previousCancellationToken;
- private readonly IRazorLayout? _previousLayout;
+ private readonly RazorTemplate _page;
+ private readonly TextWriter? _targetOutput;
+ private readonly ExecutionScope? _previousExecutionScope;
- public RazorTemplate Page { get; }
- public StringBuilder Output { get; } = new();
+ private IRazorLayout? _layout;
+ private bool _layoutFrozen;
+ private Dictionary>? _sections;
- public ExecutionScope(RazorTemplate page, CancellationToken cancellationToken)
- {
- Page = page;
+ public StringWriter BufferedOutput { get; } = new();
+ public IRazorLayout? Layout => _layout;
+ public CancellationToken CancellationToken { get; }
+
+ public static ExecutionScope StartBody(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken)
+ => new(page, targetOutput, cancellationToken);
- _previousSections = page._sections;
- _previousOutput = page.Output;
- _previousCancellationToken = page.CancellationToken;
- _previousLayout = page.Layout;
+ private static ExecutionScope StartSection(ExecutionScope parent)
+ => new(parent._page, null, parent.CancellationToken)
+ {
+ _layout = parent._layout, // The section might reference the layout instance.
+ _layoutFrozen = true
+ };
+
+ private ExecutionScope(RazorTemplate page, TextWriter? targetOutput, CancellationToken cancellationToken)
+ {
+ _page = page;
+ _targetOutput = targetOutput;
+ CancellationToken = cancellationToken;
- page._sections = null;
- page.Output = new StringWriter(Output);
- page.CancellationToken = cancellationToken;
- page.Layout = null;
+ _previousExecutionScope = page._executionScope;
+ page._executionScope = this;
}
public void Dispose()
{
- Page._sections = _previousSections;
- Page.Output = _previousOutput;
- Page.CancellationToken = _previousCancellationToken;
- Page.Layout = _previousLayout;
+ if (ReferenceEquals(_page._executionScope, this))
+ _page._executionScope = _previousExecutionScope;
+ }
+
+ public void SetLayout(IRazorLayout? layout)
+ {
+ if (ReferenceEquals(layout, _layout))
+ return;
+
+ if (_layoutFrozen)
+ throw new InvalidOperationException("The layout can no longer be changed.");
+
+ _layout = layout;
+ }
+
+ public async Task FlushAsync()
+ {
+ if (_layout is not null)
+ throw new InvalidOperationException("The output cannot be flushed when a layout is used.");
+
+ // A part of the output will be written to the target output and discarded,
+ // so disallow setting a layout later on, as that would lead to inconsistent results.
+ _layoutFrozen = true;
+
+ if (_targetOutput is null)
+ return;
+
+ var bufferedOutput = BufferedOutput.GetStringBuilder();
+ await WriteStringBuilderToOutputAsync(bufferedOutput, _targetOutput, CancellationToken).ConfigureAwait(false);
+ bufferedOutput.Clear();
+
+#if NET8_0_OR_GREATER
+ await _targetOutput.FlushAsync(CancellationToken).ConfigureAwait(false);
+#else
+ await _targetOutput.FlushAsync().ConfigureAwait(false);
+#endif
+ }
+
+ public BufferedContent ToBufferedContent()
+ => new(BufferedOutput.GetStringBuilder());
+
+ public bool IsSectionDefined(string name)
+ => _sections is { } sections && sections.ContainsKey(name);
+
+ public void DefineSection(string name, Func action)
+ {
+ var sections = _sections ??= new(StringComparer.OrdinalIgnoreCase);
+
+#if NET6_0_OR_GREATER
+ if (!sections.TryAdd(name, action))
+ throw new InvalidOperationException($"Section '{name}' is already defined.");
+#else
+ if (sections.ContainsKey(name))
+ throw new InvalidOperationException($"Section '{name}' is already defined.");
+
+ sections[name] = action;
+#endif
+ }
+
+ public async Task RenderSectionAsync(string name)
+ {
+ if (_sections is not { } sections || !sections.TryGetValue(name, out var sectionAction))
+ return null;
+
+ using var sectionScope = StartSection(this);
+ await sectionAction().ConfigureAwait(false);
+ return sectionScope.ToBufferedContent();
}
}
@@ -275,35 +382,23 @@ public void Dispose()
///
private class ExecutionResult : IRazorExecutionResult
{
- private readonly RazorTemplate _page;
- private readonly IReadOnlyDictionary>? _sections;
+ private readonly ExecutionScope _executionScope;
public IEncodedContent Body { get; }
- public IRazorLayout? Layout { get; }
- public CancellationToken CancellationToken { get; }
+ public IRazorLayout? Layout => _executionScope.Layout;
+ public CancellationToken CancellationToken => _executionScope.CancellationToken;
public ExecutionResult(ExecutionScope executionScope)
{
- _page = executionScope.Page;
- _sections = _page._sections;
- Body = new EncodedContent(executionScope.Output);
- Layout = _page.Layout;
- CancellationToken = _page.CancellationToken;
+ _executionScope = executionScope;
+ Body = executionScope.ToBufferedContent();
}
public bool IsSectionDefined(string name)
- => _sections?.ContainsKey(name) is true;
-
- public async Task RenderSectionAsync(string name)
- {
- if (_sections is null || !_sections.TryGetValue(name, out var sectionAction))
- return null;
+ => _executionScope.IsSectionDefined(name);
- using var executionScope = new ExecutionScope(_page, CancellationToken);
- _page.Layout = Layout; // The section might reference this instance.
- await sectionAction().ConfigureAwait(false);
- return new EncodedContent(executionScope.Output);
- }
+ public Task RenderSectionAsync(string name)
+ => _executionScope.RenderSectionAsync(name);
}
///
@@ -311,13 +406,13 @@ public bool IsSectionDefined(string name)
///
///
/// StringBuilders can be combined more efficiently than strings, which is useful for layouts.
- /// has a dedicated Write overload for .
+ /// has a dedicated Write overload for in some frameworks.
///
- private class EncodedContent : IEncodedContent
+ private class BufferedContent : IEncodedContent
{
public StringBuilder Output { get; }
- public EncodedContent(StringBuilder value)
+ public BufferedContent(StringBuilder value)
=> Output = value;
public void WriteTo(TextWriter textWriter)
diff --git a/src/RazorBlade.Tests/HtmlLayoutTests.cs b/src/RazorBlade.Tests/HtmlLayoutTests.cs
index 44241fb..d588c4c 100644
--- a/src/RazorBlade.Tests/HtmlLayoutTests.cs
+++ b/src/RazorBlade.Tests/HtmlLayoutTests.cs
@@ -184,17 +184,51 @@ public void should_throw_on_render()
Assert.Throws(() => ((RazorTemplate)layout).Render(CancellationToken.None));
}
- private class Template : HtmlTemplate
+ [Test]
+ public void should_throw_when_setting_layout_after_flush()
{
- private readonly Action _executeAction;
+ var layout = new Layout(_ => { });
+ var page = new Template(async t =>
+ {
+ await t.FlushAsync();
+ t.Layout = layout;
+ });
+
+ Assert.Throws(() => page.Render())
+ .ShouldNotBeNull().Message.ShouldEqual("The layout can no longer be changed.");
+ }
+
+ [Test]
+ public void should_throw_when_flushing_with_layout()
+ {
+ var layout = new Layout(_ => { });
+
+ var page = new Template(async t =>
+ {
+ t.Layout = layout;
+ await t.FlushAsync();
+ });
+
+ Assert.Throws(() => page.Render())
+ .ShouldNotBeNull().Message.ShouldEqual("The output cannot be flushed when a layout is used.");
+ }
+
+ private class Template(Func executeAction) : HtmlTemplate
+ {
public Template(Action executeAction)
- => _executeAction = executeAction;
+ : this(t =>
+ {
+ executeAction(t);
+ return Task.CompletedTask;
+ })
+ {
+ }
- protected internal override Task ExecuteAsync()
+ protected internal override async Task ExecuteAsync()
{
- _executeAction(this);
- return base.ExecuteAsync();
+ await executeAction(this);
+ await base.ExecuteAsync();
}
public void SetSection(string name, string content)
@@ -210,18 +244,13 @@ public void SetSection(string name, string content)
}
}
- private class Layout : HtmlLayout
+ private class Layout(Action executeAction) : HtmlLayout
{
- private readonly Action _executeAction;
-
public bool WasExecuted { get; private set; }
- public Layout(Action executeAction)
- => _executeAction = executeAction;
-
protected internal override Task ExecuteAsync()
{
- _executeAction(this);
+ executeAction(this);
WasExecuted = true;
return base.ExecuteAsync();
}
diff --git a/src/RazorBlade.Tests/HtmlTemplateTests.cs b/src/RazorBlade.Tests/HtmlTemplateTests.cs
index 55c889e..3daa7f5 100644
--- a/src/RazorBlade.Tests/HtmlTemplateTests.cs
+++ b/src/RazorBlade.Tests/HtmlTemplateTests.cs
@@ -103,16 +103,11 @@ public void should_write_raw_string()
template.Render().ShouldEqual("&<>");
}
- private class Template : HtmlTemplate
+ private class Template(Action executeAction) : HtmlTemplate
{
- private readonly Action _executeAction;
-
- public Template(Action executeAction)
- => _executeAction = executeAction;
-
protected internal override Task ExecuteAsync()
{
- _executeAction(this);
+ executeAction(this);
return base.ExecuteAsync();
}
}
diff --git a/src/RazorBlade.Tests/PlainTextTemplateTests.cs b/src/RazorBlade.Tests/PlainTextTemplateTests.cs
index dd8f231..7ce0708 100644
--- a/src/RazorBlade.Tests/PlainTextTemplateTests.cs
+++ b/src/RazorBlade.Tests/PlainTextTemplateTests.cs
@@ -78,16 +78,11 @@ public void should_not_skip_prefixes_of_null_attribute_values()
template.Render().ShouldEqual("foo=\" a True b False c 42 d e bar\"");
}
- private class Template : PlainTextTemplate
+ private class Template(Action executeAction) : PlainTextTemplate
{
- private readonly Action _executeAction;
-
- public Template(Action executeAction)
- => _executeAction = executeAction;
-
protected internal override Task ExecuteAsync()
{
- _executeAction(this);
+ executeAction(this);
return base.ExecuteAsync();
}
}
diff --git a/src/RazorBlade.Tests/RazorTemplateTests.cs b/src/RazorBlade.Tests/RazorTemplateTests.cs
index 5b54e22..21faa10 100644
--- a/src/RazorBlade.Tests/RazorTemplateTests.cs
+++ b/src/RazorBlade.Tests/RazorTemplateTests.cs
@@ -14,54 +14,58 @@ public class RazorTemplateTests
public async Task should_write_literal()
{
var template = new Template(t => t.WriteLiteral("foo & bar < baz > foobar"));
- await template.ExecuteAsync();
- template.Output.ToString().ShouldEqual("foo & bar < baz > foobar");
+
+ var result = await template.RenderAsync();
+
+ result.ShouldEqual("foo & bar < baz > foobar");
}
[Test]
public void should_render_to_local_output()
{
var template = new Template(t => t.WriteLiteral("foo"));
- template.Output.Write("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
template.Render().ShouldEqual("foo");
- template.Output.ToString().ShouldEqual("bar");
+
+ template.Output.ShouldEqual(TextWriter.Null);
}
[Test]
public void should_render_to_text_writer()
{
var template = new Template(t => t.WriteLiteral("foo"));
- template.Output.Write("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
var output = new StringWriter();
template.Render(output);
output.ToString().ShouldEqual("foo");
- template.Output.ToString().ShouldEqual("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
}
[Test]
public async Task should_render_async_to_local_output()
{
var template = new Template(t => t.WriteLiteral("foo"));
- await template.Output.WriteAsync("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
(await template.RenderAsync()).ShouldEqual("foo");
- template.Output.ToString().ShouldEqual("bar");
+
+ template.Output.ShouldEqual(TextWriter.Null);
}
[Test]
public async Task should_render_async_to_text_writer()
{
var template = new Template(t => t.WriteLiteral("foo"));
- await template.Output.WriteAsync("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
var output = new StringWriter();
await template.RenderAsync(output);
output.ToString().ShouldEqual("foo");
- template.Output.ToString().ShouldEqual("bar");
+ template.Output.ShouldEqual(TextWriter.Null);
}
[Test]
@@ -119,27 +123,102 @@ public void should_not_execute_template_when_already_cancelled()
var cts = new CancellationTokenSource();
cts.Cancel();
- var semaphore = new SemaphoreSlim(0);
- var template = new Template(_ => semaphore.Release());
+ var executionCount = 0;
+ var template = new Template(_ => Interlocked.Increment(ref executionCount));
Assert.Catch(() => template.Render(cts.Token));
Assert.Catch(() => template.Render(StreamWriter.Null, cts.Token));
Assert.CatchAsync(() => template.RenderAsync(cts.Token));
Assert.CatchAsync(() => template.RenderAsync(StreamWriter.Null, cts.Token));
- semaphore.CurrentCount.ShouldEqual(0);
+ executionCount.ShouldEqual(0);
}
- private class Template : RazorTemplate
+ [Test]
+ public async Task should_flush_output()
{
- private readonly Func _executeAction;
+ var output = new StringWriter();
+ var stepSemaphore = new StepSemaphore();
- public Template(Func executeAction)
+ var template = new Template(async t =>
{
- _executeAction = executeAction;
- Output = new StringWriter();
- }
+ using var worker = stepSemaphore.CreateWorker();
+ await worker.WaitForNextStepAsync();
+
+ t.WriteLiteral("foo");
+ await t.FlushAsync();
+
+ await worker.WaitForNextStepAsync();
+
+ t.WriteLiteral(" bar");
+ await t.FlushAsync();
+
+ await worker.WaitForNextStepAsync();
+
+ t.WriteLiteral(" baz");
+ await t.FlushAsync();
+ });
+
+ var task = template.RenderAsync(output);
+ var controller = stepSemaphore.CreateController();
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ output.ToString().ShouldEqual("foo");
+ template.Output.ShouldBe().ToString().ShouldEqual(string.Empty);
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ output.ToString().ShouldEqual("foo bar");
+ template.Output.ShouldBe().ToString().ShouldEqual(string.Empty);
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ await task;
+ output.ToString().ShouldEqual("foo bar baz");
+ template.Output.ShouldEqual(TextWriter.Null);
+ }
+
+ [Test]
+ public async Task should_buffer_output_until_flushed()
+ {
+ var output = new StringWriter();
+ var stepSemaphore = new StepSemaphore();
+
+ var template = new Template(async t =>
+ {
+ using var worker = stepSemaphore.CreateWorker();
+ await worker.WaitForNextStepAsync();
+
+ t.WriteLiteral("foo");
+ await t.Output.FlushAsync();
+
+ await worker.WaitForNextStepAsync();
+ t.WriteLiteral(" bar");
+ await t.Output.FlushAsync();
+
+ await worker.WaitForNextStepAsync();
+
+ t.WriteLiteral(" baz");
+ await t.Output.FlushAsync();
+ });
+
+ var task = template.RenderAsync(output);
+ var controller = stepSemaphore.CreateController();
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ output.ToString().ShouldEqual(string.Empty);
+ template.Output.ShouldBe().ToString().ShouldEqual("foo");
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ output.ToString().ShouldEqual(string.Empty);
+ template.Output.ShouldBe().ToString().ShouldEqual("foo bar");
+
+ await controller.StartNextStepAndWaitForResultAsync();
+ await task;
+ output.ToString().ShouldEqual("foo bar baz");
+ }
+
+ private class Template(Func executeAction) : RazorTemplate
+ {
public Template(Action executeAction)
: this(t =>
{
@@ -151,7 +230,7 @@ public Template(Action executeAction)
protected internal override async Task ExecuteAsync()
{
- await _executeAction(this);
+ await executeAction(this);
await base.ExecuteAsync();
}
diff --git a/src/RazorBlade.Tests/Support/AssertExtensions.cs b/src/RazorBlade.Tests/Support/AssertExtensions.cs
index 1d0da29..cba304e 100644
--- a/src/RazorBlade.Tests/Support/AssertExtensions.cs
+++ b/src/RazorBlade.Tests/Support/AssertExtensions.cs
@@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using JetBrains.Annotations;
using NUnit.Framework;
namespace RazorBlade.Tests.Support;
+#if NET
+[StackTraceHidden]
+#endif
internal static class AssertExtensions
{
public static void ShouldEqual(this T? actual, T? expected)
@@ -19,6 +23,13 @@ public static void ShouldBeTrue(this bool actual)
public static void ShouldBeFalse(this bool actual)
=> Assert.That(actual, Is.False);
+ public static TExpected ShouldBe(this object? actual)
+ where TExpected : class
+ {
+ Assert.That(actual, Is.InstanceOf());
+ return actual as TExpected ?? throw new AssertionException($"Expected instance of {typeof(TExpected).Name}");
+ }
+
[ContractAnnotation("notnull => halt")]
public static void ShouldBeNull(this object? actual)
=> Assert.That(actual, Is.Null);
diff --git a/src/RazorBlade.Tests/Support/StepSemaphore.cs b/src/RazorBlade.Tests/Support/StepSemaphore.cs
new file mode 100644
index 0000000..4aacebb
--- /dev/null
+++ b/src/RazorBlade.Tests/Support/StepSemaphore.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace RazorBlade.Tests.Support;
+
+public class StepSemaphore
+{
+ private readonly SemaphoreSlim _semaphoreStartOfStep = new(0);
+ private readonly SemaphoreSlim _semaphoreEndOfStep = new(0);
+
+ private bool _hasWorker;
+ private bool _hasController;
+ private bool _isFirstStep = true;
+
+ public IWorker CreateWorker()
+ {
+ if (_hasWorker)
+ throw new InvalidOperationException("A worker has already been created");
+
+ _hasWorker = true;
+ return new Worker(this);
+ }
+
+ public IController CreateController()
+ {
+ if (_hasController)
+ throw new InvalidOperationException("A controller has already been created");
+
+ _hasController = true;
+ return new Controller(this);
+ }
+
+ private async Task WaitForNextStepAsync()
+ {
+ if (!_isFirstStep)
+ _semaphoreEndOfStep.Release();
+
+ _isFirstStep = false;
+ await WaitWithTimeout(_semaphoreStartOfStep);
+ }
+
+ private void NotifyEndOfLastStep()
+ => _semaphoreEndOfStep.Release();
+
+ private async Task StartNextStepAndWaitForResultAsync()
+ {
+ _semaphoreStartOfStep.Release();
+ await WaitWithTimeout(_semaphoreEndOfStep);
+ }
+
+ private static async Task WaitWithTimeout(SemaphoreSlim semaphore)
+ => (await semaphore.WaitAsync(10_000)).ShouldBeTrue();
+
+ public interface IWorker : IDisposable
+ {
+ Task WaitForNextStepAsync();
+ }
+
+ public interface IController
+ {
+ Task StartNextStepAndWaitForResultAsync();
+ }
+
+ private class Worker(StepSemaphore parent) : IWorker
+ {
+ public Task WaitForNextStepAsync()
+ => parent.WaitForNextStepAsync();
+
+ public void Dispose()
+ => parent.NotifyEndOfLastStep();
+ }
+
+ private class Controller(StepSemaphore parent) : IController
+ {
+ public Task StartNextStepAndWaitForResultAsync()
+ => parent.StartNextStepAndWaitForResultAsync();
+ }
+}