diff --git a/samples/MvcSample.Web/HomeController.cs b/samples/MvcSample.Web/HomeController.cs index 343d2a0f84..00ca9e73a7 100644 --- a/samples/MvcSample.Web/HomeController.cs +++ b/samples/MvcSample.Web/HomeController.cs @@ -165,6 +165,11 @@ namespace MvcSample.Web return View(CreateUser()); } + public ActionResult FlushPoint() + { + return View(); + } + private static IEnumerable CreateAddresses() { var addresses = new[] diff --git a/samples/MvcSample.Web/Views/Home/FlushPoint.cshtml b/samples/MvcSample.Web/Views/Home/FlushPoint.cshtml new file mode 100644 index 0000000000..1f79342528 --- /dev/null +++ b/samples/MvcSample.Web/Views/Home/FlushPoint.cshtml @@ -0,0 +1,62 @@ +@{ + Layout = "/Views/Shared/_FlushPointLayout.cshtml"; + ViewBag.Title = "Home Page"; +} + +@section content +{ +
+

ASP.NET

+

ASP.NET is a free web framework for building great Web sites and Web applications using HTML, CSS and JavaScript.

+

Learn more »

+
+ +
+
+

Getting started

+

+ ASP.NET MVC gives you a powerful, patterns-based way to build dynamic websites that + enables a clean separation of concerns and gives you full control over markup + for enjoyable, agile development. +

+

Learn more »

+
+
+

Get more libraries

+

NuGet is a free Visual Studio extension that makes it easy to add, remove, and update libraries and tools in Visual Studio projects.

+

Learn more »

+
+
+

Web Hosting

+

You can easily find a web hosting company that offers the right mix of features and price for your applications.

+

Learn more »

+
+
+ +
+ One Microsoft Way
+ Redmond, WA 98052-6399
+ P: + 425.555.0100 +
+ +
+ Support: Support@example.com
+ Marketing: Marketing@example.com +
+ +@* Remove the Wait() calls once we add support for async sections *@ +@{ + FlushAsync().Wait(); + Task.Delay(TimeSpan.FromSeconds(1)).Wait(); +} +
+
+ Integer pharetra dignissim tortor, quis facilisis tellus faucibus in. Phasellus fringilla pellentesque justo eu tempor. Sed eget viverra lacus, eget gravida turpis. Donec dapibus sodales leo, non pharetra lacus volutpat quis. Quisque et auctor nulla. Nunc a orci ut libero luctus ornare ut eget erat. Integer eu risus tempor, scelerisque lacus nec, dignissim nisl. Ut ac leo nec velit tempus fringilla. Phasellus id nisi tortor. Aliquam magna nunc, congue eget sem quis, porta accumsan magna. Sed sit amet dapibus sem, placerat malesuada felis. Quisque vel dui ut est luctus congue. +
+ +
+ Aliquam nec elementum orci, ut interdum mi. Sed lorem lacus, malesuada in nisi a, auctor lobortis ipsum. Ut id erat suscipit, pharetra dui eu, sodales erat. Duis nibh turpis, vestibulum sit amet leo vitae, faucibus iaculis lectus. Nam lacinia purus fringilla dolor dictum posuere nec in libero. Integer id tempor elit. Mauris adipiscing ut sem id tincidunt. Cras turpis elit, dignissim vitae adipiscing vitae, ullamcorper in felis. Nullam et adipiscing neque. Integer ullamcorper eget tortor in sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Suspendisse pretium tristique libero, ut gravida est placerat quis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Sed sed lorem cursus, porttitor nibh eu, bibendum turpis. +
+
+} \ No newline at end of file diff --git a/samples/MvcSample.Web/Views/Shared/_FlushPointLayout.cshtml b/samples/MvcSample.Web/Views/Shared/_FlushPointLayout.cshtml new file mode 100644 index 0000000000..36b59fd1bd --- /dev/null +++ b/samples/MvcSample.Web/Views/Shared/_FlushPointLayout.cshtml @@ -0,0 +1,36 @@ + + + + + + @ViewBag.Title - My ASP.NET Application + + + + +
+ @{ await FlushAsync(); } + @RenderBody() + @RenderSection("content") +
+
+

© @DateTime.Now.Year - My ASP.NET Application

+
+
+ + diff --git a/src/Microsoft.AspNet.Mvc.Razor/HelperResult.cs b/src/Microsoft.AspNet.Mvc.Razor/HelperResult.cs index 37408df4bf..58bee7b1a3 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/HelperResult.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/HelperResult.cs @@ -6,16 +6,35 @@ using System.IO; namespace Microsoft.AspNet.Mvc.Razor { + /// + /// Represents a deferred write operation in a . + /// public class HelperResult { private readonly Action _action; + /// + /// Creates a new instance of . + /// + /// The delegate to invoke when is called. public HelperResult([NotNull] Action action) { _action = action; } - public void WriteTo([NotNull] TextWriter writer) + /// + /// Gets the delegate to invoke when is called. + /// + public Action WriteAction + { + get { return _action; } + } + + /// + /// Method invoked to produce content from the . + /// + /// The instance to write to. + public virtual void WriteTo([NotNull] TextWriter writer) { _action(writer); } diff --git a/src/Microsoft.AspNet.Mvc.Razor/IRazorPage.cs b/src/Microsoft.AspNet.Mvc.Razor/IRazorPage.cs index d8a8780415..f79fc0b351 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/IRazorPage.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/IRazorPage.cs @@ -24,6 +24,16 @@ namespace Microsoft.AspNet.Mvc.Razor // TODO: https://github.com/aspnet/Mvc/issues/845 tracks making this async Action RenderBodyDelegate { get; set; } + /// + /// Gets or sets a flag that determines if the layout of this page is being rendered. + /// + /// + /// Sections defined in a page are deferred and executed as part of the layout page. + /// When this flag is set, all write operations performed by the page are part of a + /// section being rendered. + /// + bool IsLayoutBeingRendered { get; set; } + /// /// Gets the application base relative path to the page. /// diff --git a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs index dd8c6568fb..0f8f2058a9 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs @@ -42,6 +42,22 @@ namespace Microsoft.AspNet.Mvc.Razor return string.Format(CultureInfo.CurrentCulture, GetString("CompilationFailed"), p0); } + /// + /// '{0}' cannot be invoked when a Layout page is set to be executed. + /// + internal static string FlushPointCannotBeInvoked + { + get { return GetString("FlushPointCannotBeInvoked"); } + } + + /// + /// '{0}' cannot be invoked when a Layout page is set to be executed. + /// + internal static string FormatFlushPointCannotBeInvoked(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("FlushPointCannotBeInvoked"), p0); + } + /// /// The layout view '{0}' could not be located. /// @@ -58,6 +74,22 @@ namespace Microsoft.AspNet.Mvc.Razor return string.Format(CultureInfo.CurrentCulture, GetString("LayoutCannotBeLocated"), p0); } + /// + /// A layout page cannot be rendered after '{0}' has been invoked. + /// + internal static string LayoutCannotBeRendered + { + get { return GetString("LayoutCannotBeRendered"); } + } + + /// + /// A layout page cannot be rendered after '{0}' has been invoked. + /// + internal static string FormatLayoutCannotBeRendered(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("LayoutCannotBeRendered"), p0); + } + /// /// The 'inherits' keyword is not allowed when a '{0}' keyword is used. /// diff --git a/src/Microsoft.AspNet.Mvc.Razor/RazorPage.cs b/src/Microsoft.AspNet.Mvc.Razor/RazorPage.cs index 2c3c55990e..b332759323 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/RazorPage.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/RazorPage.cs @@ -19,8 +19,8 @@ namespace Microsoft.AspNet.Mvc.Razor /// public abstract class RazorPage : IRazorPage { - private IUrlHelper _urlHelper; private readonly HashSet _renderedSections = new HashSet(StringComparer.OrdinalIgnoreCase); + private IUrlHelper _urlHelper; private bool _renderedBody; public RazorPage() @@ -90,6 +90,9 @@ namespace Microsoft.AspNet.Mvc.Razor /// public Action RenderBodyDelegate { get; set; } + /// + public bool IsLayoutBeingRendered { get; set; } + /// public Dictionary PreviousSectionWriters { get; set; } @@ -283,13 +286,19 @@ namespace Microsoft.AspNet.Mvc.Razor return new HelperResult(RenderBodyDelegate); } - public void DefineSection(string name, HelperResult action) + /// + /// Creates a named content section in the page that can be invoked in a Layout page using + /// or . + /// + /// The name of the section to create. + /// The to execute when rendering the section. + public void DefineSection(string name, HelperResult section) { if (SectionWriters.ContainsKey(name)) { throw new InvalidOperationException(Resources.FormatSectionAlreadyDefined(name)); } - SectionWriters[name] = action; + SectionWriters[name] = section; } public bool IsSectionDefined([NotNull] string name) @@ -329,6 +338,24 @@ namespace Microsoft.AspNet.Mvc.Razor } } + /// + /// Invokes on writing out any buffered + /// content to the . + /// + /// A that represents the asynchronous flush operation. + public Task FlushAsync() + { + // Calls to Flush are allowed if the page does not specify a Layout or if it is executing a section in the + // Layout. + if (!IsLayoutBeingRendered && !string.IsNullOrEmpty(Layout)) + { + var message = Resources.FormatLayoutCannotBeRendered(nameof(FlushAsync)); + throw new InvalidOperationException(message); + } + + return Output.FlushAsync(); + } + /// public void EnsureBodyAndSectionsWereRendered() { diff --git a/src/Microsoft.AspNet.Mvc.Razor/RazorTextWriter.cs b/src/Microsoft.AspNet.Mvc.Razor/RazorTextWriter.cs index 5159e48a19..ba06edc82f 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/RazorTextWriter.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/RazorTextWriter.cs @@ -9,7 +9,10 @@ using System.Threading.Tasks; namespace Microsoft.AspNet.Mvc.Razor { /// - /// A that represents individual write operations as a sequence of strings. + /// A that represents individual write operations as a sequence of strings when buffering. + /// The writer is backed by an unbuffered writer. When Flush or FlushAsync is invoked, the writer + /// copies all content to the unbuffered writier and switches to writing to the unbuffered writer for all further + /// write operations. /// /// /// This is primarily designed to avoid creating large in-memory strings. @@ -18,25 +21,49 @@ namespace Microsoft.AspNet.Mvc.Razor public class RazorTextWriter : TextWriter { private static readonly Task _completedTask = Task.FromResult(0); + private readonly TextWriter _unbufferedWriter; private readonly Encoding _encoding; - public RazorTextWriter(Encoding encoding) + /// + /// Creates a new instance of . + /// + /// The to write output to when this instance + /// is no longer buffering. + /// The character in which the output is written. + public RazorTextWriter(TextWriter unbufferedWriter, Encoding encoding) { + _unbufferedWriter = unbufferedWriter; _encoding = encoding; Buffer = new BufferEntryCollection(); } + /// public override Encoding Encoding { get { return _encoding; } } + /// + /// Gets a flag that determines if this instance of is buffering content. + /// + public bool IsBuffering { get; private set; } = true; + + /// + /// A collection of entries buffered by this instance of . + /// public BufferEntryCollection Buffer { get; private set; } /// public override void Write(char value) { - Buffer.Add(value.ToString()); + if (IsBuffering) + { + Buffer.Add(value.ToString()); + } + else + { + _unbufferedWriter.Write(value); + } } /// @@ -55,7 +82,14 @@ namespace Microsoft.AspNet.Mvc.Razor throw new ArgumentOutOfRangeException("count"); } - Buffer.Add(buffer, index, count); + if (IsBuffering) + { + Buffer.Add(buffer, index, count); + } + else + { + _unbufferedWriter.Write(buffer, index, count); + } } /// @@ -63,70 +97,174 @@ namespace Microsoft.AspNet.Mvc.Razor { if (!string.IsNullOrEmpty(value)) { - Buffer.Add(value); + if (IsBuffering) + { + Buffer.Add(value); + } + else + { + _unbufferedWriter.Write(value); + } } } /// public override Task WriteAsync(char value) { - Write(value); - return _completedTask; + if (IsBuffering) + { + Write(value); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteAsync(value); + } } /// public override Task WriteAsync([NotNull] char[] buffer, int index, int count) { - Write(buffer, index, count); - return _completedTask; + if (IsBuffering) + { + Write(buffer, index, count); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteAsync(buffer, index, count); + } + } /// public override Task WriteAsync(string value) { - Write(value); - return _completedTask; + if (IsBuffering) + { + Write(value); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteAsync(value); + } } /// public override void WriteLine() { - Buffer.Add(Environment.NewLine); + if (IsBuffering) + { + Buffer.Add(Environment.NewLine); + } + else + { + _unbufferedWriter.WriteLine(); + } } /// public override void WriteLine(string value) { - Write(value); - WriteLine(); + if (IsBuffering) + { + Write(value); + WriteLine(); + } + else + { + _unbufferedWriter.WriteLine(value); + } } /// public override Task WriteLineAsync(char value) { - WriteLine(value); - return _completedTask; + if (IsBuffering) + { + WriteLine(value); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteLineAsync(value); + } } /// public override Task WriteLineAsync(char[] value, int start, int offset) { - WriteLine(value, start, offset); - return _completedTask; + if (IsBuffering) + { + WriteLine(value, start, offset); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteLineAsync(value, start, offset); + } } /// public override Task WriteLineAsync(string value) { - WriteLine(value); - return _completedTask; + if (IsBuffering) + { + WriteLine(value); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteLineAsync(value); + } } /// public override Task WriteLineAsync() { - WriteLine(); - return _completedTask; + if (IsBuffering) + { + WriteLine(); + return _completedTask; + } + else + { + return _unbufferedWriter.WriteLineAsync(); + } + } + + /// + /// Copies the buffered content to the unbuffered writer and invokes flush on it. + /// Additionally causes this instance to no longer buffer and direct all write operations + /// to the unbuffered writer. + /// + public override void Flush() + { + if (IsBuffering) + { + IsBuffering = false; + CopyTo(_unbufferedWriter); + } + + _unbufferedWriter.Flush(); + } + + /// + /// Copies the buffered content to the unbuffered writer and invokes flush on it. + /// Additionally causes this instance to no longer buffer and direct all write operations + /// to the unbuffered writer. + /// + /// A that represents the asynchronous copy and flush operations. + public override async Task FlushAsync() + { + if (IsBuffering) + { + IsBuffering = false; + await CopyToAsync(_unbufferedWriter); + } + + await _unbufferedWriter.FlushAsync(); } /// @@ -136,13 +274,15 @@ namespace Microsoft.AspNet.Mvc.Razor public void CopyTo(TextWriter writer) { var targetRazorTextWriter = writer as RazorTextWriter; - if (targetRazorTextWriter != null) + if (targetRazorTextWriter != null && targetRazorTextWriter.IsBuffering) { targetRazorTextWriter.Buffer.Add(Buffer); } else { - WriteList(writer, Buffer); + // If the target writer is not buffering, we can directly copy to it's unbuffered writer + var targetWriter = targetRazorTextWriter != null ? targetRazorTextWriter._unbufferedWriter : writer; + WriteList(targetWriter, Buffer); } } @@ -154,13 +294,15 @@ namespace Microsoft.AspNet.Mvc.Razor public Task CopyToAsync(TextWriter writer) { var targetRazorTextWriter = writer as RazorTextWriter; - if (targetRazorTextWriter != null) + if (targetRazorTextWriter != null && targetRazorTextWriter.IsBuffering) { targetRazorTextWriter.Buffer.Add(Buffer); } else { - return WriteListAsync(writer, Buffer); + // If the target writer is not buffering, we can directly copy to it's unbuffered writer + var targetWriter = targetRazorTextWriter != null ? targetRazorTextWriter._unbufferedWriter : writer; + return WriteListAsync(targetWriter, Buffer); } return _completedTask; diff --git a/src/Microsoft.AspNet.Mvc.Razor/RazorView.cs b/src/Microsoft.AspNet.Mvc.Razor/RazorView.cs index d2587b5bd7..b9d076de0f 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/RazorView.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/RazorView.cs @@ -65,12 +65,13 @@ namespace Microsoft.AspNet.Mvc.Razor ViewContext context, bool executeViewStart) { - using (var bufferedWriter = new RazorTextWriter(context.Writer.Encoding)) + using (var bufferedWriter = new RazorTextWriter(context.Writer, context.Writer.Encoding)) { // The writer for the body is passed through the ViewContext, allowing things like HtmlHelpers // and ViewComponents to reference it. var oldWriter = context.Writer; context.Writer = bufferedWriter; + try { if (executeViewStart) @@ -117,6 +118,17 @@ namespace Microsoft.AspNet.Mvc.Razor var previousPage = _razorPage; while (!string.IsNullOrEmpty(previousPage.Layout)) { + if (!bodyWriter.IsBuffering) + { + // Once a call to RazorPage.FlushAsync is made, we can no longer render Layout pages - content has + // already been written to the client and the layout content would be appended rather than surround + // the body content. Throwing this exception wouldn't return a 500 (since content has already been + // written), but a diagnostic component should be able to capture it. + + var message = Resources.FormatLayoutCannotBeRendered("FlushAsync"); + throw new InvalidOperationException(message); + } + var layoutPage = _pageFactory.CreateInstance(previousPage.Layout); if (layoutPage == null) { @@ -124,6 +136,9 @@ namespace Microsoft.AspNet.Mvc.Razor throw new InvalidOperationException(message); } + // Notify the previous page that any writes that are performed on it are part of sections being written + // in the layout. + previousPage.IsLayoutBeingRendered = true; layoutPage.PreviousSectionWriters = previousPage.SectionWriters; layoutPage.RenderBodyDelegate = bodyWriter.CopyTo; bodyWriter = await RenderPageAsync(layoutPage, context, executeViewStart: false); @@ -134,7 +149,11 @@ namespace Microsoft.AspNet.Mvc.Razor previousPage = layoutPage; } - await bodyWriter.CopyToAsync(context.Writer); + if (bodyWriter.IsBuffering) + { + // Only copy buffered content to the Output if we're currently buffering. + await bodyWriter.CopyToAsync(context.Writer); + } } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx index 39439205d0..611ea48847 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx @@ -123,9 +123,15 @@ Compilation for '{0}' failed: + + '{0}' cannot be invoked when a Layout page is set to be executed. + The layout view '{0}' could not be located. + + A layout page cannot be rendered after '{0}' has been invoked. + The 'inherits' keyword is not allowed when a '{0}' keyword is used. diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/FlushPointTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/FlushPointTest.cs new file mode 100644 index 0000000000..aa758a4e93 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/FlushPointTest.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.DependencyInjection.Fallback; +using RazorWebSite; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class FlushPointTest + { + private readonly IServiceProvider _provider = TestHelper.CreateServices("RazorWebSite"); + private readonly Action _app = new Startup().Configure; + + [Fact] + public async Task FlushPointsAreExecutedForPagesWithLayouts() + { + var waitService = new WaitService(); + var serviceProvider = GetServiceProvider(waitService); + var server = TestServer.Create(serviceProvider, _app); + var client = server.CreateClient(); + + // Act + var stream = await client.GetStreamAsync("http://localhost/FlushPoint/PageWithLayout"); + + // Assert - 1 + Assert.Equal(@"Page With Layout", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 2 + Assert.Equal(@"RenderBody content", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 3 + Assert.Equal(@"Content that takes time to produce", + GetTrimmedString(stream)); + } + + [Fact] + public async Task FlushPointsAreExecutedForPagesWithoutLayouts() + { + var waitService = new WaitService(); + var serviceProvider = GetServiceProvider(waitService); + + var server = TestServer.Create(serviceProvider, _app); + var client = server.CreateClient(); + + // Act + var stream = await client.GetStreamAsync("http://localhost/FlushPoint/PageWithoutLayout"); + + // Assert - 1 + Assert.Equal("Initial content", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 2 + Assert.Equal("Secondary content", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 3 + Assert.Equal("Final content", GetTrimmedString(stream)); + } + + [Fact] + public async Task FlushPointsAreExecutedForPagesWithComponentsAndPartials() + { + var waitService = new WaitService(); + var serviceProvider = GetServiceProvider(waitService); + + var server = TestServer.Create(serviceProvider, _app); + var client = server.CreateClient(); + + // Act + var stream = await client.GetStreamAsync("http://localhost/FlushPoint/PageWithPartialsAndViewComponents"); + + // Assert - 1 + Assert.Equal( +@"Page With Components and Partials + +RenderBody content", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 2 + Assert.Equal("partial-content", GetTrimmedString(stream)); + waitService.WaitForServer(); + + // Assert - 3 + Assert.Equal( +@"component-content + Content that takes time to produce", GetTrimmedString(stream)); + } + + private IServiceProvider GetServiceProvider(WaitService waitService) + { + var services = new ServiceCollection(); + services.AddInstance(waitService); + return services.BuildServiceProvider(_provider); + } + + private string GetTrimmedString(Stream stream) + { + var buffer = new byte[1024]; + var count = stream.Read(buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer, 0, count).Trim(); + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorPageTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorPageTest.cs index 6a871c8833..75a14a7bd1 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorPageTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorPageTest.cs @@ -7,6 +7,7 @@ using System.IO; using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.PipelineCore; using Microsoft.AspNet.Testing; using Moq; using Xunit; @@ -280,8 +281,7 @@ Layout end var services = new Mock(); services.Setup(s => s.GetService(typeof(IUrlHelper))) .Returns(helper.Object); - Mock.Get(page.Context).Setup(c => c.RequestServices) - .Returns(services.Object); + page.Context.RequestServices = services.Object; // Act await page.ExecuteAsync(); @@ -292,8 +292,69 @@ Layout end helper.Verify(); } - private static TestableRazorPage CreatePage(Action executeAction) + [Fact] + public async Task FlushAsync_InvokesFlushOnWriter() { + // Arrange + var writer = new Mock(); + var context = CreateViewContext(writer.Object); + var page = CreatePage(p => + { + p.FlushAsync().Wait(); + }, context); + + // Act + await page.ExecuteAsync(); + + // Assert + writer.Verify(v => v.FlushAsync(), Times.Once()); + } + + [Fact] + public async Task FlushAsync_ThrowsIfTheLayoutHasBeenSet() + { + // Arrange + var expected = @"A layout page cannot be rendered after 'FlushAsync' has been invoked."; + var writer = new Mock(); + var context = CreateViewContext(writer.Object); + var page = CreatePage(p => + { + p.Layout = "foo"; + p.FlushAsync().Wait(); + }, context); + + // Act and Assert + var ex = await Assert.ThrowsAsync(() => page.ExecuteAsync()); + Assert.Equal(expected, ex.Message); + } + + [Fact] + public async Task FlushAsync_DoesNotThrowWhenIsRenderingLayoutIsSet() + { + // Arrange + var writer = new Mock(); + var context = CreateViewContext(writer.Object); + var page = CreatePage(p => + { + p.Layout = "bar"; + p.DefineSection("test-section", new HelperResult(_ => + { + p.FlushAsync().Wait(); + })); + }, context); + + // Act + await page.ExecuteAsync(); + page.IsLayoutBeingRendered = true; + + // Assert + Assert.DoesNotThrow(() => page.SectionWriters["test-section"].WriteTo(TextWriter.Null)); + } + + private static TestableRazorPage CreatePage(Action executeAction, + ViewContext context = null) + { + context = context ?? CreateViewContext(); var view = new Mock { CallBase = true }; if (executeAction != null) { @@ -301,19 +362,20 @@ Layout end .Callback(() => executeAction(view.Object)) .Returns(Task.FromResult(0)); } - view.Object.ViewContext = CreateViewContext(); + view.Object.ViewContext = context; return view.Object; } - private static ViewContext CreateViewContext() + private static ViewContext CreateViewContext(TextWriter writer = null) { - var actionContext = new ActionContext(Mock.Of(), routeData: null, actionDescriptor: null); + writer = writer ?? new StringWriter(); + var actionContext = new ActionContext(new DefaultHttpContext(), routeData: null, actionDescriptor: null); return new ViewContext( actionContext, Mock.Of(), null, - new StringWriter()); + writer); } private static Action CreateBodyAction(string value) diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorTextWriterTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorTextWriterTest.cs index ee3a49ae37..bc845188d7 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorTextWriterTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorTextWriterTest.cs @@ -7,6 +7,7 @@ using System.IO; using System.Text; using System.Threading.Tasks; using Microsoft.AspNet.Testing; +using Moq; using Xunit; namespace Microsoft.AspNet.Mvc.Razor.Test @@ -19,7 +20,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test { // Arrange var expected = new[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718", "m" }; - var writer = new RazorTextWriter(Encoding.UTF8); + var writer = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act writer.Write(true); @@ -34,6 +35,84 @@ namespace Microsoft.AspNet.Mvc.Razor.Test Assert.Equal(expected, writer.Buffer.BufferEntries); } + [Fact] + [ReplaceCulture] + public void Write_WritesDataTypes_ToUnderlyingStream_WhenNotBuffering() + { + // Arrange + var expected = new[] { "True", "3", "18446744073709551615", "Hello world", "3.14", "2.718" }; + var unbufferedWriter = new Mock(); + var writer = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + var testClass = new TestClass(); + + // Act + writer.Flush(); + writer.Write(true); + writer.Write(3); + writer.Write(ulong.MaxValue); + writer.Write(testClass); + writer.Write(3.14); + writer.Write(2.718m); + + // Assert + Assert.Empty(writer.Buffer.BufferEntries); + foreach (var item in expected) + { + unbufferedWriter.Verify(v => v.Write(item), Times.Once()); + } + } + + [Fact] + [ReplaceCulture] + public async Task Write_WritesCharValues_ToUnderlyingStream_WhenNotBuffering() + { + // Arrange + var unbufferedWriter = new Mock { CallBase = true }; + var writer = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + var buffer1 = new[] { 'a', 'b', 'c', 'd' }; + var buffer2 = new[] { 'd', 'e', 'f' }; + + // Act + writer.Flush(); + writer.Write('x'); + writer.Write(buffer1, 1, 2); + writer.Write(buffer2); + await writer.WriteAsync(buffer2, 1, 1); + await writer.WriteLineAsync(buffer1); + + // Assert + Assert.Empty(writer.Buffer.BufferEntries); + unbufferedWriter.Verify(v => v.Write('x'), Times.Once()); + unbufferedWriter.Verify(v => v.Write(buffer1, 1, 2), Times.Once()); + unbufferedWriter.Verify(v => v.Write(buffer1, 0, 4), Times.Once()); + unbufferedWriter.Verify(v => v.Write(buffer2, 0, 3), Times.Once()); + unbufferedWriter.Verify(v => v.WriteAsync(buffer2, 1, 1), Times.Once()); + unbufferedWriter.Verify(v => v.WriteLine(), Times.Once()); + } + + [Fact] + [ReplaceCulture] + public async Task Write_WritesStringValues_ToUnbufferedStream_WhenNotBuffering() + { + // Arrange + var unbufferedWriter = new Mock(); + var writer = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + + // Act + await writer.FlushAsync(); + writer.Write("a"); + writer.WriteLine("ab"); + await writer.WriteAsync("ef"); + await writer.WriteLineAsync("gh"); + + // Assert + Assert.Empty(writer.Buffer.BufferEntries); + unbufferedWriter.Verify(v => v.Write("a"), Times.Once()); + unbufferedWriter.Verify(v => v.WriteLine("ab"), Times.Once()); + unbufferedWriter.Verify(v => v.WriteAsync("ef"), Times.Once()); + unbufferedWriter.Verify(v => v.WriteLineAsync("gh"), Times.Once()); + } + [Fact] [ReplaceCulture] public void WriteLine_WritesDataTypes_ToBuffer() @@ -41,7 +120,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test // Arrange var newLine = Environment.NewLine; var expected = new List { "False", newLine, "1.1", newLine, "3", newLine }; - var writer = new RazorTextWriter(Encoding.UTF8); + var writer = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act writer.WriteLine(false); @@ -52,6 +131,28 @@ namespace Microsoft.AspNet.Mvc.Razor.Test Assert.Equal(expected, writer.Buffer.BufferEntries); } + [Fact] + [ReplaceCulture] + public void WriteLine_WritesDataTypes_ToUnbufferedStream_WhenNotBuffering() + { + // Arrange + var unbufferedWriter = new Mock(); + var writer = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + + // Act + writer.Flush(); + writer.WriteLine(false); + writer.WriteLine(1.1f); + writer.WriteLine(3L); + + // Assert + Assert.Empty(writer.Buffer.BufferEntries); + unbufferedWriter.Verify(v => v.Write("False"), Times.Once()); + unbufferedWriter.Verify(v => v.Write("1.1"), Times.Once()); + unbufferedWriter.Verify(v => v.Write("3"), Times.Once()); + unbufferedWriter.Verify(v => v.WriteLine(), Times.Exactly(3)); + } + [Fact] public async Task Write_WritesCharBuffer() { @@ -59,7 +160,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var input1 = new ArraySegment(new char[] { 'a', 'b', 'c', 'd' }, 1, 3); var input2 = new ArraySegment(new char[] { 'e', 'f' }, 0, 2); var input3 = new ArraySegment(new char[] { 'g', 'h', 'i', 'j' }, 3, 1); - var writer = new RazorTextWriter(Encoding.UTF8); + var writer = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act writer.Write(input1.Array, input1.Offset, input1.Count); @@ -80,7 +181,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test { // Arrange var newLine = Environment.NewLine; - var writer = new RazorTextWriter(Encoding.UTF8); + var writer = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act writer.WriteLine(); @@ -100,7 +201,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var input2 = "from"; var input3 = "ASP"; var input4 = ".Net"; - var writer = new RazorTextWriter(Encoding.UTF8); + var writer = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act writer.Write(input1); @@ -114,11 +215,11 @@ namespace Microsoft.AspNet.Mvc.Razor.Test } [Fact] - public void Copy_CopiesContent_IfTargetTextWriterIsARazorTextWriter() + public void Copy_CopiesContent_IfTargetTextWriterIsARazorTextWriterAndBuffering() { // Arrange - var source = new RazorTextWriter(Encoding.UTF8); - var target = new RazorTextWriter(Encoding.UTF8); + var source = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); + var target = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act source.Write("Hello world"); @@ -132,11 +233,33 @@ namespace Microsoft.AspNet.Mvc.Razor.Test Assert.Same(source.Buffer.BufferEntries, target.Buffer.BufferEntries[0]); } + [Fact] + public void Copy_CopiesContent_IfTargetTextWriterIsARazorTextWriterAndNotBuffering() + { + // Arrange + var unbufferedWriter = new Mock(); + var source = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); + var target = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + + // Act + target.Flush(); + source.Write("Hello world"); + source.Write(new [] { 'a', 'b', 'c', 'd' }, 1, 2); + source.CopyTo(target); + + // Assert + // Make sure content was written to the source. + Assert.Equal(2, source.Buffer.BufferEntries.Count); + Assert.Empty(target.Buffer.BufferEntries); + unbufferedWriter.Verify(v => v.Write("Hello world"), Times.Once()); + unbufferedWriter.Verify(v => v.Write("bc"), Times.Once()); + } + [Fact] public void Copy_WritesContent_IfTargetTextWriterIsNotARazorTextWriter() { // Arrange - var source = new RazorTextWriter(Encoding.UTF8); + var source = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); var target = new StringWriter(); var expected = @"Hello world abc"; @@ -151,11 +274,11 @@ abc"; } [Fact] - public async Task CopyAsync_WritesContent_IfTargetTextWriterIsARazorTextWriter() + public async Task CopyAsync_WritesContent_IfTargetTextWriterIsARazorTextWriterAndBuffering() { // Arrange - var source = new RazorTextWriter(Encoding.UTF8); - var target = new RazorTextWriter(Encoding.UTF8); + var source = new RazorTextWriter(TextWriter.Null,Encoding.UTF8); + var target = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); // Act source.WriteLine("Hello world"); @@ -168,11 +291,34 @@ abc"; Assert.Same(source.Buffer.BufferEntries, target.Buffer.BufferEntries[0]); } + [Fact] + public async Task CopyAsync_WritesContent_IfTargetTextWriterIsARazorTextWriterAndNotBuffering() + { + // Arrange + var unbufferedWriter = new Mock(); + var source = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); + var target = new RazorTextWriter(unbufferedWriter.Object, Encoding.UTF8); + + // Act + await target.FlushAsync(); + source.WriteLine("Hello from Asp.Net"); + await source.WriteAsync(new [] { 'x', 'y', 'z', 'u' }, 0, 3); + await source.CopyToAsync(target); + + // Assert + // Make sure content was written to the source. + Assert.Equal(3, source.Buffer.BufferEntries.Count); + Assert.Empty(target.Buffer.BufferEntries); + unbufferedWriter.Verify(v => v.WriteAsync("Hello from Asp.Net"), Times.Once()); + unbufferedWriter.Verify(v => v.WriteAsync(Environment.NewLine), Times.Once()); + unbufferedWriter.Verify(v => v.WriteAsync("xyz"), Times.Once()); + } + [Fact] public async Task CopyAsync_WritesContent_IfTargetTextWriterIsNotARazorTextWriter() { // Arrange - var source = new RazorTextWriter(Encoding.UTF8); + var source = new RazorTextWriter(TextWriter.Null, Encoding.UTF8); var target = new StringWriter(); var expected = @"Hello world "; diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewTest.cs index 00ea8e44ed..e3a50390b6 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewTest.cs @@ -414,6 +414,161 @@ body-content"; Assert.Equal(expected, viewContext.Writer.ToString()); } + [Fact] + public async Task RenderAsync_DoesNotCopyContentOnceRazorTextWriterIsNoLongerBuffering() + { + // Arrange + var expected = +@"layout-1 +body content +section-content-1 +section-content-2"; + + var page = new TestableRazorPage(v => + { + v.Layout = "layout-1"; + v.WriteLiteral("body content" + Environment.NewLine); + v.DefineSection("foo", new HelperResult(_ => + { + v.WriteLiteral("section-content-1" + Environment.NewLine); + v.FlushAsync().Wait(); + v.WriteLiteral("section-content-2"); + })); + }); + + var layout1 = new TestableRazorPage(v => + { + v.Write("layout-1" + Environment.NewLine); + v.RenderBodyPublic(); + v.Write(v.RenderSection("foo")); + }); + + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("layout-1")) + .Returns(layout1); + + var view = new RazorView(pageFactory.Object, + Mock.Of(), + CreateViewStartProvider()); + view.Contextualize(page, isPartial: false); + var viewContext = CreateViewContext(view); + + // Act + await view.RenderAsync(viewContext); + + // Assert + Assert.Equal(expected, viewContext.Writer.ToString()); + } + + [Fact] + public async Task FlushAsync_DoesNotThrowWhenInvokedInsideOfASection() + { + // Arrange + var expected = +@"layout-1 +section-content-1 +section-content-2"; + + var page = new TestableRazorPage(v => + { + v.Layout = "layout-1"; + v.DefineSection("foo", new HelperResult(_ => + { + v.WriteLiteral("section-content-1" + Environment.NewLine); + v.FlushAsync().Wait(); + v.WriteLiteral("section-content-2"); + })); + }); + + var layout1 = new TestableRazorPage(v => + { + v.Write("layout-1" + Environment.NewLine); + v.RenderBodyPublic(); + v.Write(v.RenderSection("foo")); + }); + + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("layout-1")) + .Returns(layout1); + + var view = new RazorView(pageFactory.Object, + Mock.Of(), + CreateViewStartProvider()); + view.Contextualize(page, isPartial: false); + var viewContext = CreateViewContext(view); + + // Act + await view.RenderAsync(viewContext); + + // Assert + Assert.Equal(expected, viewContext.Writer.ToString()); + } + + [Fact] + public async Task RenderAsync_ThrowsIfLayoutIsSpecifiedWhenNotBuffered() + { + // Arrange + var expected = @"A layout page cannot be rendered after 'FlushAsync' has been invoked."; + var page = new TestableRazorPage(v => + { + v.WriteLiteral("before-flush" + Environment.NewLine); + v.FlushAsync().Wait(); + v.Layout = "test-layout"; + v.WriteLiteral("after-flush"); + }); + + var view = new RazorView(Mock.Of(), + Mock.Of(), + CreateViewStartProvider()); + view.Contextualize(page, isPartial: false); + var viewContext = CreateViewContext(view); + + // Act and Assert + var ex = await Assert.ThrowsAsync(() => view.RenderAsync(viewContext)); + Assert.Equal(expected, ex.Message); + } + + [Fact] + public async Task RenderAsync_ThrowsIfFlushWasInvokedInsideRenderedSectionAndLayoutWasSet() + { + // Arrange + var expected = @"A layout page cannot be rendered after 'FlushAsync' has been invoked."; + var page = new TestableRazorPage(v => + { + v.DefineSection("foo", new HelperResult(writer => + { + writer.WriteLine("foo-content"); + v.FlushAsync().Wait(); + })); + v.Layout = "~/Shared/Layout1.cshtml"; + v.WriteLiteral("body-content"); + }); + var layout1 = new TestableRazorPage(v => + { + v.Write("layout-1" + Environment.NewLine); + v.Write(v.RenderSection("foo")); + v.DefineSection("bar", new HelperResult(writer => + { + writer.WriteLine("bar-content"); + })); + v.RenderBodyPublic(); + v.Layout = "~/Shared/Layout2.cshtml"; + }); + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("~/Shared/Layout1.cshtml")) + .Returns(layout1); + + var view = new RazorView(pageFactory.Object, + Mock.Of(), + CreateViewStartProvider()); + view.Contextualize(page, isPartial: false); + var viewContext = CreateViewContext(view); + + // Act and Assert + var ex = await Assert.ThrowsAsync(() => view.RenderAsync(viewContext)); + Assert.Equal(expected, ex.Message); + } + private static ViewContext CreateViewContext(RazorView view) { var httpContext = new DefaultHttpContext(); diff --git a/test/WebSites/RazorWebSite/Controllers/FlushPoint.cs b/test/WebSites/RazorWebSite/Controllers/FlushPoint.cs new file mode 100644 index 0000000000..021ca21a6f --- /dev/null +++ b/test/WebSites/RazorWebSite/Controllers/FlushPoint.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Mvc; + +namespace RazorWebSite +{ + public class FlushPoint : Controller + { + public ViewResult PageWithLayout() + { + return View(); + } + + public ViewResult PageWithoutLayout() + { + return View(); + } + + public ViewResult PageWithPartialsAndViewComponents() + { + return View(); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Services/WaitService.cs b/test/WebSites/RazorWebSite/Services/WaitService.cs new file mode 100644 index 0000000000..2a87a718b6 --- /dev/null +++ b/test/WebSites/RazorWebSite/Services/WaitService.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; + +namespace RazorWebSite +{ + public class WaitService + { + private static readonly TimeSpan _waitTime = TimeSpan.FromSeconds(10); + private readonly ManualResetEventSlim _serverResetEvent = new ManualResetEventSlim(); + private readonly ManualResetEventSlim _clientResetEvent = new ManualResetEventSlim(); + + public void NotifyClient() + { + _clientResetEvent.Set(); + } + + public void WaitForClient() + { + _clientResetEvent.Set(); + _serverResetEvent.Wait(_waitTime); + _serverResetEvent.Reset(); + } + + public void WaitForServer() + { + _serverResetEvent.Set(); + _clientResetEvent.Wait(_waitTime); + _clientResetEvent.Reset(); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithLayout.cshtml b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithLayout.cshtml new file mode 100644 index 0000000000..b038ad9f42 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithLayout.cshtml @@ -0,0 +1,14 @@ +@inject WaitService WaitService +@{ + Layout = "/Views/Shared/_LayoutWithFlush.cshtml"; + ViewBag.Title = "Page With Layout"; +} +RenderBody content +@section content +{ + @{ + FlushAsync().Wait(); + WaitService.WaitForClient(); + } + Content that takes time to produce +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithPartialsAndViewComponents.cshtml b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithPartialsAndViewComponents.cshtml new file mode 100644 index 0000000000..b8f092fcd5 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithPartialsAndViewComponents.cshtml @@ -0,0 +1,15 @@ +@inject WaitService WaitService +@{ + Layout = "/Views/Shared/_LayoutWithPartialAndFlush.cshtml"; + ViewBag.Title = "Page With Components and Partials"; +} +RenderBody content +@section content +{ + @{ + FlushAsync().Wait(); + WaitService.WaitForClient(); + } + @Component.InvokeAsync("ComponentThatSetsTitle").Result + Content that takes time to produce +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithoutLayout.cshtml b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithoutLayout.cshtml new file mode 100644 index 0000000000..9c702434da --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/FlushPoint/PageWithoutLayout.cshtml @@ -0,0 +1,15 @@ +@inject WaitService WaitService +Initial content +@{ + await FlushAsync(); + WaitService.WaitForClient(); +} +Secondary content +@{ + await FlushAsync(); + WaitService.WaitForClient(); +} +Final content +@{ + WaitService.NotifyClient(); +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithFlush.cshtml b/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithFlush.cshtml new file mode 100644 index 0000000000..b6de9fc0cf --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithFlush.cshtml @@ -0,0 +1,11 @@ +@inject WaitService WaitService +@ViewBag.Title +@{ + await FlushAsync(); + WaitService.WaitForClient(); +} +@RenderBody() +@RenderSection("content") +@{ + WaitService.NotifyClient(); +} diff --git a/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithPartialAndFlush.cshtml b/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithPartialAndFlush.cshtml new file mode 100644 index 0000000000..d425369da1 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/Shared/_LayoutWithPartialAndFlush.cshtml @@ -0,0 +1,12 @@ +@inject WaitService WaitService +@ViewBag.Title +@RenderBody() +@{ + await FlushAsync(); + WaitService.WaitForClient(); +} +@await Html.PartialAsync("_PartialThatSetsTitle") +@RenderSection("content") +@{ + WaitService.NotifyClient(); +}