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 static explicit operator Microsoft.AspNetCore.Components.MarkupString (string value) { throw null; }
public override string ToString() { 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)] [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly partial struct Parameter 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.Linq;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Features;
namespace Microsoft.AspNetCore.Components.Server.Circuits namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
internal class CircuitPrerenderer : IComponentPrerenderer internal class CircuitPrerenderer : IComponentPrerenderer
{ {
private static object CircuitHostKey = new object(); private static object CircuitHostKey = new object();
private static object NavigationStatusKey = new object();
private readonly CircuitFactory _circuitFactory; private readonly CircuitFactory _circuitFactory;
private readonly CircuitRegistry _registry; private readonly CircuitRegistry _registry;
@ -26,11 +29,41 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public async Task<ComponentPrerenderResult> PrerenderComponentAsync(ComponentPrerenderingContext prerenderingContext) public async Task<ComponentPrerenderResult> PrerenderComponentAsync(ComponentPrerenderingContext prerenderingContext)
{ {
var context = prerenderingContext.Context; 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( // Navigation was attempted during prerendering.
prerenderingContext.ComponentType, if (prerenderingContext.Context.Response.HasStarted)
prerenderingContext.Parameters); {
// 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 circuitHost.Descriptors.Add(new ComponentDescriptor
{ {
@ -38,9 +71,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
Prerendered = true Prerendered = true
}); });
var result = new[] { var result = (new[] {
$"<!-- M.A.C.Component:{{\"circuitId\":\"{circuitHost.CircuitId}\",\"rendererId\":\"{circuitHost.Renderer.Id}\",\"componentId\":\"{renderResult.ComponentId}\"}} -->", $"<!-- M.A.C.Component:{{\"circuitId\":\"{circuitHost.CircuitId}\",\"rendererId\":\"{circuitHost.Renderer.Id}\",\"componentId\":\"{renderResult.ComponentId}\"}} -->",
}.Concat(renderResult.Tokens).Concat( }).Concat(renderResult.Tokens).Concat(
new[] { new[] {
$"<!-- M.A.C.Component: {renderResult.ComponentId} -->" $"<!-- M.A.C.Component: {renderResult.ComponentId} -->"
}); });
@ -48,7 +81,28 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return new ComponentPrerenderResult(result); 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)) if (context.Items.TryGetValue(CircuitHostKey, out var existingHost))
{ {
@ -66,7 +120,11 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
context.Response.OnCompleted(() => context.Response.OnCompleted(() =>
{ {
result.UnhandledException -= CircuitHost_UnhandledException; result.UnhandledException -= CircuitHost_UnhandledException;
_registry.RegisterDisconnectedCircuit(result); if (!navigationStatus.Navigated)
{
_registry.RegisterDisconnectedCircuit(result);
}
return Task.CompletedTask; return Task.CompletedTask;
}); });
context.Items.Add(CircuitHostKey, result); context.Items.Add(CircuitHostKey, result);
@ -105,5 +163,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return result; 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) if (_jsRuntime == null)
{ {
throw new InvalidOperationException("Navigation commands can not be issued at this time. This is because the component is being " + throw new NavigationException(uri);
"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.");
} }
_jsRuntime.InvokeAsync<object>(Interop.NavigateTo, uri, forceLoad); _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.Components.RenderTree;
using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Microsoft.Net.Http.Headers;
using Moq; using Moq;
using Xunit; using Xunit;
@ -122,7 +124,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
} }
[Fact] [Fact]
public async Task UriHelperRedirect_ThrowsInvalidOperationException() public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted()
{ {
// Arrange // Arrange
var ctx = new DefaultHttpContext(); var ctx = new DefaultHttpContext();
@ -131,7 +133,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
ctx.Request.PathBase = "/base"; ctx.Request.PathBase = "/base";
ctx.Request.Path = "/path"; ctx.Request.Path = "/path";
ctx.Request.QueryString = new QueryString("?query=value"); 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 helper = CreateHelper(ctx);
var writer = new StringWriter(); var writer = new StringWriter();
@ -141,13 +145,62 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
RedirectUri = "http://localhost/redirect" RedirectUri = "http://localhost/redirect"
})); }));
Assert.Equal("Navigation commands can not be issued at this time. This is because the component is being " + Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " +
"prerendered and the page has not yet loaded in the browser or because the circuit is currently disconnected. " + "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
"Components must wrap any navigation calls in conditional logic to ensure those navigation calls are not " + "reponse and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
"attempted during prerendering or while the client is disconnected.",
exception.Message); 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] [Fact]
public async Task CanRender_AsyncComponent() public async Task CanRender_AsyncComponent()
{ {

View File

@ -10,10 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures
{ {
protected override void NavigateToCore(string uri, bool forceLoad) 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 NavigationException(uri);
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.");
} }
} }
} }

View File

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents namespace Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents
@ -33,9 +34,30 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.RazorComponents
InitializeUriHelper(httpContext); InitializeUriHelper(httpContext);
using (var htmlRenderer = new HtmlRenderer(httpContext.RequestServices, _encoder.Encode, dispatcher)) using (var htmlRenderer = new HtmlRenderer(httpContext.RequestServices, _encoder.Encode, dispatcher))
{ {
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync( ComponentRenderedText result = default;
componentType, try
parameters)); {
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; return result.Tokens;
} }
} }

View File

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

View File

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