diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs index 3b9a0e8d92..382346efce 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -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 { diff --git a/src/Components/Components/src/NavigationException.cs b/src/Components/Components/src/NavigationException.cs new file mode 100644 index 0000000000..2693ce39b0 --- /dev/null +++ b/src/Components/Components/src/NavigationException.cs @@ -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 +{ + /// + /// Exception thrown when an is not able to navigate to a different url. + /// + public class NavigationException : Exception + { + /// + /// Initializes a new instance. + /// + public NavigationException(string uri) + { + Location = uri; + } + + /// + /// Gets the uri to which navigation was attempted. + /// + public string Location { get; } + } +} diff --git a/src/Components/Server/src/Circuits/CircuitPrerenderer.cs b/src/Components/Server/src/Circuits/CircuitPrerenderer.cs index 5bb9254a89..e433fd1748 100644 --- a/src/Components/Server/src/Circuits/CircuitPrerenderer.cs +++ b/src/Components/Server/src/Circuits/CircuitPrerenderer.cs @@ -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 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()); + } + 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()); + } circuitHost.Descriptors.Add(new ComponentDescriptor { @@ -38,9 +71,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits Prerendered = true }); - var result = new[] { + var result = (new[] { $"", - }.Concat(renderResult.Tokens).Concat( + }).Concat(renderResult.Tokens).Concat( new[] { $"" }); @@ -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; } + } } } diff --git a/src/Components/Server/src/Circuits/RemoteUriHelper.cs b/src/Components/Server/src/Circuits/RemoteUriHelper.cs index 8271f4450d..03b492a1e0 100644 --- a/src/Components/Server/src/Circuits/RemoteUriHelper.cs +++ b/src/Components/Server/src/Circuits/RemoteUriHelper.cs @@ -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(Interop.NavigateTo, uri, forceLoad); } diff --git a/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs b/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs index ad1d0ae013..3b69925b91 100644 --- a/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs +++ b/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs @@ -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(); + 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(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(new + { + RedirectUri = "http://localhost/redirect" + }); + + // Act + var result = await helper.RenderComponentAsync(new { Name = "George" }); + + // Assert + Assert.NotNull(result); + result.WriteTo(stringWriter, HtmlEncoder.Default); + Assert.Equal("", stringWriter.ToString()); + } + [Fact] public async Task CanRender_AsyncComponent() { diff --git a/src/Mvc/Mvc.ViewFeatures/src/Infrastructure/HttpUriHelper.cs b/src/Mvc/Mvc.ViewFeatures/src/Infrastructure/HttpUriHelper.cs index ccb5e0f37d..8551f41310 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Infrastructure/HttpUriHelper.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Infrastructure/HttpUriHelper.cs @@ -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); } } } diff --git a/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs index 5d63d0def7..f3084ad5be 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/StaticComponentRenderer.cs @@ -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(); + } + return result.Tokens; } } diff --git a/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs b/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs index 7ab98690aa..f2cfc99b7e 100644 --- a/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs +++ b/src/Mvc/Mvc.ViewFeatures/test/HtmlHelperComponentExtensionsTests.cs @@ -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(); + 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(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() { diff --git a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs index ac096853f6..1d3eb95d7d 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs @@ -74,6 +74,37 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests AssertComponent("\nRouter component\n

Routed successfully

", "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 fixture) + private HttpClient CreateClient( + WebApplicationFactory fixture) { var loopHandler = new LoopHttpHandler(); diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs index 2234bf7ba6..fd82753b6c 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs @@ -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; } diff --git a/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/NavigationComponent.razor b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/NavigationComponent.razor new file mode 100644 index 0000000000..0e9259d691 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/NavigationComponent.razor @@ -0,0 +1,9 @@ +@inject IUriHelper Helper + +@functions{ + + protected override void OnInit() + { + Helper.NavigateTo("/navigation-redirect"); + } +} \ No newline at end of file diff --git a/src/Mvc/test/WebSites/BasicWebSite/Views/RazorComponents/Navigation.cshtml b/src/Mvc/test/WebSites/BasicWebSite/Views/RazorComponents/Navigation.cshtml new file mode 100644 index 0000000000..292ad7c7dc --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Views/RazorComponents/Navigation.cshtml @@ -0,0 +1,13 @@ +@using BasicWebSite.RazorComponents; +@model bool; +

Navigation components

+ \ No newline at end of file