Support navigation during prerendering (#10694)

This commit is contained in:
Javier Calvarro Nelson 2019-06-01 01:40:45 +02:00 committed by GitHub
parent 15b7dc5aee
commit 1165a6fb16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 282 additions and 32 deletions

View File

@ -361,6 +361,11 @@ namespace Microsoft.AspNetCore.Components
public static explicit operator Microsoft.AspNetCore.Components.MarkupString (string value) { throw null; }
public override string ToString() { throw null; }
}
public partial class NavigationException : System.Exception
{
public NavigationException(string uri) { }
public string Location { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
}
[System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly partial struct Parameter
{

View File

@ -0,0 +1,26 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.AspNetCore.Components
{
/// <summary>
/// Exception thrown when an <see cref="IUriHelper"/> is not able to navigate to a different url.
/// </summary>
public class NavigationException : Exception
{
/// <summary>
/// Initializes a new <see cref="NavigationException"/> instance.
/// </summary>
public NavigationException(string uri)
{
Location = uri;
}
/// <summary>
/// Gets the uri to which navigation was attempted.
/// </summary>
public string Location { get; }
}
}

View File

@ -5,14 +5,17 @@ using System;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Features;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
internal class CircuitPrerenderer : IComponentPrerenderer
{
private static object CircuitHostKey = new object();
private static object NavigationStatusKey = new object();
private readonly CircuitFactory _circuitFactory;
private readonly CircuitRegistry _registry;
@ -26,11 +29,41 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public async Task<ComponentPrerenderResult> PrerenderComponentAsync(ComponentPrerenderingContext prerenderingContext)
{
var context = prerenderingContext.Context;
var circuitHost = GetOrCreateCircuitHost(context);
var navigationStatus = GetOrCreateNavigationStatus(context);
if (navigationStatus.Navigated)
{
// Avoid creating a circuit host if other component earlier in the pipeline already triggered
// a navigation request. Instead rendre nothing
return new ComponentPrerenderResult(Array.Empty<string>());
}
var circuitHost = GetOrCreateCircuitHost(context, navigationStatus);
ComponentRenderedText renderResult = default;
try
{
renderResult = await circuitHost.PrerenderComponentAsync(
prerenderingContext.ComponentType,
prerenderingContext.Parameters);
}
catch (NavigationException navigationException)
{
// Cleanup the state as we won't need it any longer.
// Signal callbacks that we don't have to register the circuit.
await CleanupCircuitState(context, navigationStatus, circuitHost);
var renderResult = await circuitHost.PrerenderComponentAsync(
prerenderingContext.ComponentType,
prerenderingContext.Parameters);
// Navigation was attempted during prerendering.
if (prerenderingContext.Context.Response.HasStarted)
{
// We can't perform a redirect as the server already started sending the response.
// This is considered an application error as the developer should buffer the response until
// all components have rendered.
throw new InvalidOperationException("A navigation command was attempted during prerendering after the server already started sending the response. " +
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
"reponse and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", navigationException);
}
context.Response.Redirect(navigationException.Location);
return new ComponentPrerenderResult(Array.Empty<string>());
}
circuitHost.Descriptors.Add(new ComponentDescriptor
{
@ -38,9 +71,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
Prerendered = true
});
var result = new[] {
var result = (new[] {
$"<!-- M.A.C.Component:{{\"circuitId\":\"{circuitHost.CircuitId}\",\"rendererId\":\"{circuitHost.Renderer.Id}\",\"componentId\":\"{renderResult.ComponentId}\"}} -->",
}.Concat(renderResult.Tokens).Concat(
}).Concat(renderResult.Tokens).Concat(
new[] {
$"<!-- M.A.C.Component: {renderResult.ComponentId} -->"
});
@ -48,7 +81,28 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return new ComponentPrerenderResult(result);
}
private CircuitHost GetOrCreateCircuitHost(HttpContext context)
private CircuitNavigationStatus GetOrCreateNavigationStatus(HttpContext context)
{
if (context.Items.TryGetValue(NavigationStatusKey, out var existingHost))
{
return (CircuitNavigationStatus)existingHost;
}
else
{
var navigationStatus = new CircuitNavigationStatus();
context.Items[NavigationStatusKey] = navigationStatus;
return navigationStatus;
}
}
private static async Task CleanupCircuitState(HttpContext context, CircuitNavigationStatus navigationStatus, CircuitHost circuitHost)
{
navigationStatus.Navigated = true;
context.Items.Remove(CircuitHostKey);
await circuitHost.DisposeAsync();
}
private CircuitHost GetOrCreateCircuitHost(HttpContext context, CircuitNavigationStatus navigationStatus)
{
if (context.Items.TryGetValue(CircuitHostKey, out var existingHost))
{
@ -66,7 +120,11 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
context.Response.OnCompleted(() =>
{
result.UnhandledException -= CircuitHost_UnhandledException;
_registry.RegisterDisconnectedCircuit(result);
if (!navigationStatus.Navigated)
{
_registry.RegisterDisconnectedCircuit(result);
}
return Task.CompletedTask;
});
context.Items.Add(CircuitHostKey, result);
@ -105,5 +163,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return result;
}
private class CircuitNavigationStatus
{
public bool Navigated { get; set; }
}
}
}

View File

@ -90,10 +90,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
if (_jsRuntime == null)
{
throw new InvalidOperationException("Navigation commands can not be issued at this time. This is because the component is being " +
"prerendered and the page has not yet loaded in the browser or because the circuit is currently disconnected. " +
"Components must wrap any navigation calls in conditional logic to ensure those navigation calls are not " +
"attempted during prerendering or while the client is disconnected.");
throw new NavigationException(uri);
}
_jsRuntime.InvokeAsync<object>(Interop.NavigateTo, uri, forceLoad);
}

View File

@ -7,9 +7,11 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Microsoft.Net.Http.Headers;
using Moq;
using Xunit;
@ -122,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
}
[Fact]
public async Task UriHelperRedirect_ThrowsInvalidOperationException()
public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted()
{
// Arrange
var ctx = new DefaultHttpContext();
@ -131,7 +133,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value");
var responseMock = new Mock<IHttpResponseFeature>();
responseMock.Setup(r => r.HasStarted).Returns(true);
ctx.Features.Set(responseMock.Object);
var helper = CreateHelper(ctx);
var writer = new StringWriter();
@ -141,13 +145,62 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
RedirectUri = "http://localhost/redirect"
}));
Assert.Equal("Navigation commands can not be issued at this time. This is because the component is being " +
"prerendered and the page has not yet loaded in the browser or because the circuit is currently disconnected. " +
"Components must wrap any navigation calls in conditional logic to ensure those navigation calls are not " +
"attempted during prerendering or while the client is disconnected.",
Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " +
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
"reponse and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
exception.Message);
}
[Fact]
public async Task HtmlHelper_Redirects_WhenComponentNavigates()
{
// Arrange
var ctx = new DefaultHttpContext();
ctx.Request.Scheme = "http";
ctx.Request.Host = new HostString("localhost");
ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value");
var helper = CreateHelper(ctx);
// Act
await helper.RenderComponentAsync<RedirectComponent>(new
{
RedirectUri = "http://localhost/redirect"
});
// Assert
Assert.Equal(302, ctx.Response.StatusCode);
Assert.Equal("http://localhost/redirect", ctx.Response.Headers[HeaderNames.Location]);
}
[Fact]
public async Task HtmlHelper_AvoidsRendering_WhenNavigationHasHappened()
{
// Arrange
var ctx = new DefaultHttpContext();
ctx.Request.Scheme = "http";
ctx.Request.Host = new HostString("localhost");
ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value");
var helper = CreateHelper(ctx);
var stringWriter = new StringWriter();
await helper.RenderComponentAsync<RedirectComponent>(new
{
RedirectUri = "http://localhost/redirect"
});
// Act
var result = await helper.RenderComponentAsync<GreetingComponent>(new { Name = "George" });
// Assert
Assert.NotNull(result);
result.WriteTo(stringWriter, HtmlEncoder.Default);
Assert.Equal("", stringWriter.ToString());
}
[Fact]
public async Task CanRender_AsyncComponent()
{

View File

@ -10,10 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
{
protected override void NavigateToCore(string uri, bool forceLoad)
{
// For now throw as we don't have a good way of aborting the request from here.
throw new InvalidOperationException("Navigation commands can not be issued during server-side prerendering because the page has not yet loaded in the browser" +
"Components must wrap any navigation commands in conditional logic to ensure those navigation calls are not " +
"attempted during prerendering.");
throw new NavigationException(uri);
}
}
}

View File

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents
@ -33,9 +34,30 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents
InitializeUriHelper(httpContext);
using (var htmlRenderer = new HtmlRenderer(httpContext.RequestServices, _encoder.Encode, dispatcher))
{
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(
componentType,
parameters));
ComponentRenderedText result = default;
try
{
result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(
componentType,
parameters));
}
catch (NavigationException navigationException)
{
// Navigation was attempted during prerendering.
if (httpContext.Response.HasStarted)
{
// We can't perform a redirect as the server already started sending the response.
// This is considered an application error as the developer should buffer the response until
// all components have rendered.
throw new InvalidOperationException("A navigation command was attempted during prerendering after the server already started sending the response. " +
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
"reponse and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", navigationException);
}
httpContext.Response.Redirect(navigationException.Location);
return Array.Empty<string>();
}
return result.Tokens;
}
}

View File

@ -9,10 +9,12 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Microsoft.Net.Http.Headers;
using Moq;
using Xunit;
@ -107,7 +109,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test
}
[Fact]
public async Task UriHelperRedirect_ThrowsInvalidOperationException()
public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted()
{
// Arrange
var ctx = new DefaultHttpContext();
@ -116,7 +118,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test
ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value");
var responseMock = new Mock<IHttpResponseFeature>();
responseMock.Setup(r => r.HasStarted).Returns(true);
ctx.Features.Set(responseMock.Object);
var helper = CreateHelper(ctx);
var writer = new StringWriter();
@ -125,13 +129,36 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test
{
RedirectUri = "http://localhost/redirect"
}));
Assert.Equal("Navigation commands can not be issued during server-side prerendering because the page has not yet loaded in the browser" +
"Components must wrap any navigation commands in conditional logic to ensure those navigation calls are not " +
"attempted during prerendering.",
Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " +
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
"reponse and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
exception.Message);
}
[Fact]
public async Task HtmlHelper_Redirects_WhenComponentNavigates()
{
// Arrange
var ctx = new DefaultHttpContext();
ctx.Request.Scheme = "http";
ctx.Request.Host = new HostString("localhost");
ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value");
var helper = CreateHelper(ctx);
// Act
await helper.RenderStaticComponentAsync<RedirectComponent>(new
{
RedirectUri = "http://localhost/redirect"
});
// Assert
Assert.Equal(302, ctx.Response.StatusCode);
Assert.Equal("http://localhost/redirect", ctx.Response.Headers[HeaderNames.Location]);
}
[Fact]
public async Task CanRender_AsyncComponent()
{

View File

@ -74,6 +74,37 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
AssertComponent("\nRouter component\n<p>Routed successfully</p>", "Routing", content);
}
[Fact]
public async Task Redirects_Navigation_Component()
{
// Arrange & Act
var fixture = Factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddServerSideBlazor()));
fixture.ClientOptions.AllowAutoRedirect = false;
var client = CreateClient(fixture);
var response = await client.GetAsync("http://localhost/components/Navigation");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.Redirect);
Assert.Equal("/navigation-redirect", response.Headers.Location.ToString());
}
[Fact]
public async Task Redirects_Navigation_ComponentInteractive()
{
// Arrange & Act
var fixture = Factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddServerSideBlazor()));
fixture.ClientOptions.AllowAutoRedirect = false;
var client = CreateClient(fixture);
var response = await client.GetAsync("http://localhost/components/Navigation/false");
// Assert
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.Redirect);
Assert.Equal("/navigation-redirect", response.Headers.Location.ToString());
}
[Fact]
public async Task Renders_RoutingComponent_UsingRazorComponents_Prerenderer()
{
@ -224,7 +255,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
}
private HttpClient CreateClient(WebApplicationFactory<BasicWebSite.StartupWithoutEndpointRouting> fixture)
private HttpClient CreateClient(
WebApplicationFactory<BasicWebSite.StartupWithoutEndpointRouting> fixture)
{
var loopHandler = new LoopHttpHandler();

View File

@ -70,6 +70,12 @@ namespace BasicWebSite.Controllers
return Ok(_weatherData);
}
[HttpGet("/components/Navigation/{staticPrerender=true}")]
public IActionResult Navigation(bool staticPrerender)
{
return View(staticPrerender);
}
private class WeatherRow
{
public string DateFormatted { get; internal set; }

View File

@ -0,0 +1,9 @@
@inject IUriHelper Helper
@functions{
protected override void OnInit()
{
Helper.NavigateTo("/navigation-redirect");
}
}

View File

@ -0,0 +1,13 @@
@using BasicWebSite.RazorComponents;
@model bool;
<h1>Navigation components</h1>
<div id="Navigation">
@if (Model)
{
@(await Html.RenderStaticComponentAsync<NavigationComponent>())
}
else
{
@(await Html.RenderComponentAsync<NavigationComponent>())
}
</div>