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