Support navigation during prerendering (#10694)
This commit is contained in:
parent
15b7dc5aee
commit
1165a6fb16
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
@inject IUriHelper Helper
|
||||
|
||||
@functions{
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
Helper.NavigateTo("/navigation-redirect");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue