Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support templated Razor delegates #67

Merged
merged 5 commits into from
Dec 13, 2024
Merged
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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,16 @@ The library is still new and features are being actively added.
- [Looping](https://learn.microsoft.com/aspnet/core/mvc/views/razor#looping-for-foreach-while-and-do-while), e.g. `@for`, `@foreach`, `@while`, `@do`
- [Code blocks](https://learn.microsoft.com/aspnet/core/mvc/views/razor#razor-code-blocks), e.g. `@{ var someThing = someOtherThing; }`
- [Conditional attribute rendering](https://learn.microsoft.com/aspnet/core/mvc/views/razor#conditional-attribute-rendering)
- Functions, e.g.
- [Functions](https://learn.microsoft.com/aspnet/core/mvc/views/razor#functions):

```cshtml
@functions {
private readonly string _someString = "A very important string";
private int DoAThing() => 123;
}
```
- [Templated Razor delegates](https://learn.microsoft.com/aspnet/core/mvc/views/razor#templated-razor-delegates), e.g.

- [Templated Razor methods](https://learn.microsoft.com/aspnet/core/mvc/views/razor#code-try-48), e.g.

```cshtml
@inherits RazorSlice<Todo>
Expand All @@ -126,6 +126,22 @@ The library is still new and features are being actively added.
}
```

- [Templated Razor delegates](https://learn.microsoft.com/aspnet/core/mvc/views/razor#templated-razor-delegates), e.g.

```cshtml
@inherits RazorSlice<Todo>

@{
var tmpl = @<div>
This is a templated Razor delegate. The following value was passed in: @item
</div>;
}

@tmpl(DateTime.Now)
```

**NOTE: Async templated Razor delegates are *NOT* supported and will throw an exception at runtime**

- DI-activated properties via `@inject`
- Rendering slices from slices (aka partials) via `@(await RenderPartialAsync<MyPartial>())`
- Using slices as layouts for other slices, including layouts with strongly-typed models:
Expand Down Expand Up @@ -179,7 +195,7 @@ The library is still new and features are being actively added.
}
```

**Note: The `@section` directive is not supported as it's incompatible with the rendering approach of Razor Slices**
**NOTE: The `@section` directive is not supported as it's incompatible with the rendering approach of Razor Slices**

- Asynchronous rendering, i.e. the template can contain `await` statements, e.g. `@await WriteTheThing()`
- Writing UTF8 `byte[]` values directly to the output
Expand Down
1 change: 1 addition & 0 deletions samples/RazorSlices.Samples.WebApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
});
app.MapGet("/encoding", () => Results.Extensions.RazorSlice<Slices.Encoding>());
app.MapGet("/unicode", () => Results.Extensions.RazorSlice<Slices.Unicode>());
app.MapGet("/templated", (bool async = false) => Results.Extensions.RazorSlice<Slices.Templated, bool>(async));
app.MapGet("/library", () => Results.Extensions.RazorSlice<LibrarySlices.FromLibrary>());
app.MapGet("/render-to-string", async () =>
{
Expand Down
90 changes: 90 additions & 0 deletions samples/RazorSlices.Samples.WebApp/Slices/Templated.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@using Microsoft.AspNetCore.Html
@using Microsoft.AspNetCore.Mvc.Razor

@inherits RazorSliceHttpResult<bool>
@implements IUsesLayout<_Layout, LayoutModel>

@{
var async = Model;
Func<object, HelperResult> tmpl = !async ?
@<p>
Hello from a templated Razor delegate! The following value was passed in: @item
</p>
:
@<p>
@{await Task.Yield();}
Hello from an async templated Razor delegate! The following value was passed in: @item
</p>;
}

<h1 class="mt-5">@Title()</h1>

<div class="list-group">
<div class="list-group-item">
<p>This is from the page. What follows is from rendering the templated delegate declared on this slice:</p>
<figure>
<blockquote class="blockquote">
@tmpl(DateTime.Now)
</blockquote>
<figcaption class="blockquote-footer">
Rendered by the templated delegate
</figcaption>
</figure>
</div>
<div class="list-group-item">
<p>This is from the page. What follows is from rendering a templated method declared in the <code>@@functions</code> block on this slice:</p>
<figure>
<blockquote class="blockquote">
@if (!async)
{
@Content(DateTime.Now)
}
else
{
@await AsyncContent(DateTime.Now)
}
</blockquote>
<figcaption class="blockquote-footer">
Rendered by the templated method
</figcaption>
</figure>
</div>
<div class="list-group-item">
<p>This is from the page. What follows is from rendering a partial that has a templated delegated passed in as the model:</p>
<figure>
<blockquote class="blockquote">
@await RenderPartialAsync(_TemplatedPartial.Create(tmpl))
</blockquote>
<figcaption class="blockquote-footer">
Rendered by the partial
</figcaption>
</figure>
</div>
</div>

@functions {
public LayoutModel LayoutModel => new() { Title = Title() };

static string Title() => "Templated Razor Delegates/Methods";

private IHtmlContent Content<T>(T item)
{
<div>
Hello from a templated method! The following value was passed in: @item
</div>

// Returning HtmlContent.Empty makes it possible to call this using a Razor expression instead of a block
return HtmlString.Empty;
}

private async Task<IHtmlContent> AsyncContent<T>(T item)
{
await Task.Delay(16);
<div>
Hello from a templated async method! The following value was passed in: @item
</div>

// Returning HtmlContent.Empty makes it possible to call this using a Razor expression instead of a block
return HtmlString.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- _FooterLinks.cshtml -->
<a href="/"><span class="text-muted">Home</span></a> |
<a href="/lorem"><span class="text-muted">Lorem</span></a> |
<a href="/templated"><span class="text-muted">Templated</span></a> |
<a href="/encoding"><span class="text-muted">Encoding</span></a> |
<a href="/unicode"><span class="text-muted">Unicode</span></a> |
<a href="/render-to-string"><span class="text-muted">Render to string</span></a> |
Expand Down
13 changes: 13 additions & 0 deletions samples/RazorSlices.Samples.WebApp/Slices/_TemplatedPartial.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@using Microsoft.AspNetCore.Mvc.Razor
@inherits RazorSlice<Func<object?, HelperResult>>
<div>
<p>This is from a partial with a templated model. What follows is from the templated delegate passed in as the model:</p>
<figure>
<blockquote class="blockquote">
@Model(DateTime.Now)
</blockquote>
<figcaption class="blockquote-footer">
Rendered by the templated delegate
</figcaption>
</figure>
</div>
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>

<PropertyGroup>
<VersionPrefix>0.8.2</VersionPrefix>
<VersionPrefix>0.9.0</VersionPrefix>
<!-- VersionSuffix used for local builds -->
<VersionSuffix>dev</VersionSuffix>
<!-- VersionSuffix to be used for CI builds -->
Expand Down
4 changes: 2 additions & 2 deletions src/RazorSlices/IUsesLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public interface IUsesLayout
/// Use this interface to specify the layout type for a Razor Slice, e.g:
/// <example>
/// <code>
/// @inherits IUseLayout&lt;_Layout[]&gt;
/// @implements IUseLayout&lt;_Layout[]&gt;
/// </code>
/// </example>
/// </remarks>
Expand All @@ -47,7 +47,7 @@ public interface IUsesLayout<TLayout> : IUsesLayout
/// Use this interface to specify the layout type for a Razor Slice, e.g:
/// <example>
/// <code>
/// @inherits IUseLayout&lt;_Layout[], LayoutModel&gt;
/// @implements IUseLayout&lt;_Layout[], LayoutModel&gt;
/// </code>
/// </example>
/// </remarks>
Expand Down
2 changes: 1 addition & 1 deletion src/RazorSlices/RazorSlice.Partials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ protected internal ValueTask<HtmlString> RenderPartialAsync<TSlice, TModel>(TMod

internal ValueTask<HtmlString> RenderChildSliceAsync(RazorSlice child)
{
Debug.WriteLine($"Rendering child slice of type '{child.GetType().Name}' from layout slice of type '{GetType().Name}'");
Debug.WriteLine($"Rendering child slice of type '{child.GetType().Name}' from parent slice of type '{GetType().Name}'");

CopySliceState(this, child);

Expand Down
107 changes: 101 additions & 6 deletions src/RazorSlices/RazorSlice.Write.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Buffers;
using System.Globalization;
Expand Down Expand Up @@ -250,6 +251,11 @@ protected HtmlString WriteUtf8SpanFormattable<T>(T? formattable, ReadOnlySpan<ch
return HtmlString.Empty;
}

private static bool IsTaskFromAsyncMethod(Task task)
{
return task.GetType().FullName is { } fullName && fullName.StartsWith(nameof(System.Runtime.CompilerServices.AsyncTaskMethodBuilder), StringComparison.Ordinal);
}

/// <summary>
/// Writes the specified <see cref="IHtmlContent"/> value to the output.
/// </summary>
Expand All @@ -258,16 +264,105 @@ protected HtmlString WriteUtf8SpanFormattable<T>(T? formattable, ReadOnlySpan<ch
protected HtmlString WriteHtml<T>(T htmlContent)
where T : IHtmlContent
{
if (htmlContent is not null)
#pragma warning disable CA2000 // Dispose objects before losing scope: Utf8PipeTextWriter is returned to the pool in the finally block
TextWriter textWriter = _textWriter ?? Utf8PipeTextWriter.Get(_pipeWriter!);
#pragma warning restore CA2000
var faulted = false;

try
{
if (_pipeWriter is not null)
if (htmlContent is HelperResult helperResult)
{
_utf8BufferTextWriter ??= Utf8PipeTextWriter.Get(_pipeWriter);
htmlContent.WriteTo(_utf8BufferTextWriter, _htmlEncoder);
// A templated Razor delegate is being rendered: https://learn.microsoft.com/aspnet/core/mvc/views/razor#templated-razor-delegates
// HelperResult captures the generated async templated delegate and blocks synchronously when calling it!
// This is not ideal for performance and in our case breaks the optimization used by Utf8PipeTextWriter which
// is cached in a thread static, but it can't be helped without changing the Razor compiler (writes are synchronous).
// However we can access the captured delegate, invoke it to get the Task and detect the case where it hasn't
// completed (i.e. has gone async) and in that case throw an exception.
var actionResult = helperResult.WriteAction(textWriter);

#if DEBUG
// Force a small delay when debugging to make it easier to create scenario where method is async but has already completed
Thread.Sleep(20);
#endif

// If the Task is not completed or it's from a generated async method (i.e. one with an 'await' in it) throw an exception
if (!actionResult.IsCompleted || IsTaskFromAsyncMethod(actionResult))
{
// NOTE: There's still a chance here that the Task run asynchronously but is completed by the time we check it (albeit it's a small window)
// and in that case it's very likely the Utf8PipeTextWriter will fault as it can't handle cross-thread writes (pooled via thread static).
// I don't think this causes any issues as the exception will be thrown and the request will fail, but it's worth noting.

throw new InvalidOperationException("""
----------------------------
!!! Razor Slices Error !!!
----------------------------
The WriteAction of a HelperResult instance returned a Task from an async templated Razor delegate.
This causes performance and scale issues and is not supported in Razor Slices.
This happens when a templated Razor delegate does async work (i.e. has `@await SomethingAsync()` in it).
Use async templated methods instead of async templated Razor delegates. They have the advantage of supporting
regular method features too like generics and multiple parameters!

Do this:

```
<div>
@await TemplatedMethod(DateTime.Now);
</div>

@functions {
private async Task<HtmlContent> TemplatedMethod<T>(T data, IHtmlContent? htmlPrefix = null)
{
@htmlPrefix
<p>
@await SomeAsyncThing();
The following data was passed: @data
</p>

// Returning HtmlContent.Empty makes it possible to call this using a Razor expression instead of a block
return HtmlContent.Empty;
}
}
```

Instead of doing this:

```
@{
Func<object, HelperResult> templatedRazorDelegate = @<p>
@{ await SomeAsyncThing(); }
Hello! The following value was passed: @item
</p>;
}

<div>
@templatedRazorDelegate(DateTime.Now)
</div>
```
""");
}

actionResult.GetAwaiter().GetResult();
}
if (_textWriter is not null)
else
{
htmlContent?.WriteTo(textWriter, _htmlEncoder);
}
}
catch
{
faulted = true;
throw;
}
finally
{
if (textWriter is Utf8PipeTextWriter utf8PipeTextWriter)
{
htmlContent.WriteTo(_textWriter, _htmlEncoder);
if (!faulted)
{
utf8PipeTextWriter.Flush();
}
Utf8PipeTextWriter.Return(utf8PipeTextWriter);
}
}

Expand Down
Loading
Loading