From 735645c410c46248181bb6aab55d868dca0ebbdb Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Thu, 24 Aug 2023 12:27:46 +0100 Subject: [PATCH] Provide a better error --- .../Builder/ConfiguredRenderModesMetadata.cs | 9 ++ .../RazorComponentEndpointDataSource.cs | 8 +- .../Builder/RazorComponentEndpointFactory.cs | 4 +- .../EndpointHtmlRenderer.Prerendering.cs | 4 +- .../src/Rendering/SSRRenderModeBoundary.cs | 52 ++++++++- .../test/RazorComponentEndpointFactoryTest.cs | 21 +++- .../test/SSRRenderModeBoundaryTest.cs | 102 ++++++++++++++++++ 7 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/Components/Endpoints/src/Builder/ConfiguredRenderModesMetadata.cs create mode 100644 src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs diff --git a/src/Components/Endpoints/src/Builder/ConfiguredRenderModesMetadata.cs b/src/Components/Endpoints/src/Builder/ConfiguredRenderModesMetadata.cs new file mode 100644 index 000000000000..65c99a924843 --- /dev/null +++ b/src/Components/Endpoints/src/Builder/ConfiguredRenderModesMetadata.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class ConfiguredRenderModesMetadata(IComponentRenderMode[] configuredRenderModes) +{ + public IComponentRenderMode[] ConfiguredRenderModes => configuredRenderModes; +} diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs index 415df2f33f87..8013ab54794d 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs @@ -98,10 +98,12 @@ private void UpdateEndpoints() { var endpoints = new List(); var context = _builder.Build(); + var configuredRenderModesMetadata = new ConfiguredRenderModesMetadata( + Options.ConfiguredRenderModes.ToArray()); foreach (var definition in context.Pages) { - _factory.AddEndpoints(endpoints, typeof(TRootComponent), definition, _conventions, _finallyConventions); + _factory.AddEndpoints(endpoints, typeof(TRootComponent), definition, _conventions, _finallyConventions, configuredRenderModesMetadata); } ICollection renderModes = Options.ConfiguredRenderModes; @@ -127,8 +129,8 @@ private void UpdateEndpoints() if (!found) { throw new InvalidOperationException($"Unable to find a provider for the render mode: {renderMode.GetType().FullName}. This generally " + - $"means that a call to 'AddWebAssemblyComponents' or 'AddServerComponents' is missing. " + - $"Alternatively call 'AddWebAssemblyRenderMode', 'AddServerRenderMode' might be missing if you have set UseDeclaredRenderModes = false."); + "means that a call to 'AddWebAssemblyComponents' or 'AddServerComponents' is missing. " + + "For example, change builder.Services.AddRazorComponents() to builder.Services.AddRazorComponents().AddServerComponents()."); } } diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs index c14ae30a8f2b..117aaea090f3 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs @@ -24,7 +24,8 @@ internal void AddEndpoints( [DynamicallyAccessedMembers(Component)] Type rootComponent, PageComponentInfo pageDefinition, IReadOnlyList> conventions, - IReadOnlyList> finallyConventions) + IReadOnlyList> finallyConventions, + ConfiguredRenderModesMetadata configuredRenderModesMetadata) { // We do not provide a way to establish the order or the name for the page routes. // Order is not supported in our client router. @@ -48,6 +49,7 @@ internal void AddEndpoints( builder.Metadata.Add(HttpMethodsMetadata); builder.Metadata.Add(new ComponentTypeMetadata(pageDefinition.Type)); builder.Metadata.Add(new RootComponentMetadata(rootComponent)); + builder.Metadata.Add(configuredRenderModesMetadata); foreach (var convention in conventions) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 979cfaa17e16..9a8663ec5098 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -29,7 +29,7 @@ protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessed else { // This component is the start of a subtree with a rendermode, so introduce a new rendermode boundary here - return new SSRRenderModeBoundary(componentType, renderMode); + return new SSRRenderModeBoundary(_httpContext, componentType, renderMode); } } @@ -84,7 +84,7 @@ public async ValueTask PrerenderComponentAsync( { var rootComponent = prerenderMode is null ? InstantiateComponent(componentType) - : new SSRRenderModeBoundary(componentType, prerenderMode); + : new SSRRenderModeBoundary(_httpContext, componentType, prerenderMode); var htmlRootComponent = await Dispatcher.InvokeAsync(() => BeginRenderingComponent(rootComponent, parameters)); var result = new PrerenderedComponentHtmlContent(Dispatcher, htmlRootComponent); diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index 8f1529e3e0a0..f0ac9aaba6db 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Http; @@ -31,8 +32,13 @@ internal class SSRRenderModeBoundary : IComponent private IReadOnlyDictionary? _latestParameters; private string? _markerKey; - public SSRRenderModeBoundary([DynamicallyAccessedMembers(Component)] Type componentType, IComponentRenderMode renderMode) + public SSRRenderModeBoundary( + HttpContext httpContext, + [DynamicallyAccessedMembers(Component)] Type componentType, + IComponentRenderMode renderMode) { + AssertRenderModeIsConfigured(httpContext, componentType, renderMode); + _componentType = componentType; _renderMode = renderMode; _prerender = renderMode switch @@ -44,6 +50,50 @@ public SSRRenderModeBoundary([DynamicallyAccessedMembers(Component)] Type compon }; } + private static void AssertRenderModeIsConfigured(HttpContext httpContext, Type componentType, IComponentRenderMode renderMode) + { + var configuredRenderModesMetadata = httpContext.GetEndpoint()?.Metadata.GetMetadata(); + if (configuredRenderModesMetadata is null) + { + // This is not a Razor Components endpoint. It might be that the app is using RazorComponentResult, + // or perhaps something else has changed the endpoint dynamically. In this case we don't know how + // the app is configured so we just proceed and allow any errors to happen if the client-side code + // later tries to reach endpoints that aren't mapped. + return; + } + + var configuredModes = configuredRenderModesMetadata.ConfiguredRenderModes; + + // We have to allow for specified rendermodes being subclases of the known types + if (renderMode is ServerRenderMode || renderMode is AutoRenderMode) + { + AssertRenderModeIsConfigured(componentType, renderMode, configuredModes, "AddServerRenderMode"); + } + + if (renderMode is WebAssemblyRenderMode || renderMode is AutoRenderMode) + { + AssertRenderModeIsConfigured(componentType, renderMode, configuredModes, "AddWebAssemblyRenderMode"); + } + } + + private static void AssertRenderModeIsConfigured(Type componentType, IComponentRenderMode specifiedMode, IComponentRenderMode[] configuredModes, string expectedCall) where TRequiredMode: IComponentRenderMode + { + foreach (var configuredMode in configuredModes) + { + // We have to allow for configured rendermodes being subclases of the known types + if (configuredMode is TRequiredMode) + { + return; + } + } + + throw new InvalidOperationException($"A component of type '{componentType}' has render mode '{specifiedMode.GetType().Name}', " + + $"but the required endpoints are not mapped on the server. When calling " + + $"'{nameof(RazorComponentsEndpointRouteBuilderExtensions.MapRazorComponents)}', add a call to " + + $"'{expectedCall}'. For example, " + + $"'builder.{nameof(RazorComponentsEndpointRouteBuilderExtensions.MapRazorComponents)}<...>.{expectedCall}()'"); + } + public void Attach(RenderHandle renderHandle) { _renderHandle = renderHandle; diff --git a/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs index 0e35b75e68f4..a7dbd751bf25 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs @@ -18,13 +18,16 @@ public void AddEndpoints_CreatesEndpointWithExpectedMetadata() var factory = new RazorComponentEndpointFactory(); var conventions = new List>(); var finallyConventions = new List>(); + var testRenderMode = new TestRenderMode(); + var configuredRenderModes = new ConfiguredRenderModesMetadata(new[] { testRenderMode }); factory.AddEndpoints(endpoints, typeof(App), new PageComponentInfo( "App", typeof(App), "/", new object[] { new AuthorizeAttribute() }), conventions, - finallyConventions); + finallyConventions, + configuredRenderModes); var endpoint = Assert.Single(endpoints); Assert.Equal("/ (App)", endpoint.DisplayName); @@ -35,6 +38,8 @@ public void AddEndpoints_CreatesEndpointWithExpectedMetadata() Assert.Contains(endpoint.Metadata, m => m is ComponentTypeMetadata); Assert.Contains(endpoint.Metadata, m => m is SuppressLinkGenerationMetadata); Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute); + Assert.Contains(endpoint.Metadata, m => m is ConfiguredRenderModesMetadata c + && c.ConfiguredRenderModes.Single() == testRenderMode); Assert.NotNull(endpoint.RequestDelegate); var methods = Assert.Single(endpoint.Metadata.GetOrderedMetadata()); @@ -63,7 +68,8 @@ public void AddEndpoints_RunsConventions() "/", Array.Empty()), conventions, - finallyConventions); + finallyConventions, + new ConfiguredRenderModesMetadata(Array.Empty())); var endpoint = Assert.Single(endpoints); Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute); @@ -90,7 +96,8 @@ public void AddEndpoints_RunsFinallyConventions() "/", Array.Empty()), conventions, - finallyConventions); + finallyConventions, + new ConfiguredRenderModesMetadata(Array.Empty())); var endpoint = Assert.Single(endpoints); Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute); @@ -117,7 +124,8 @@ public void AddEndpoints_RouteOrderCanNotBeChanged() "/", Array.Empty()), conventions, - finallyConventions); + finallyConventions, + new ConfiguredRenderModesMetadata(Array.Empty())); var endpoint = Assert.Single(endpoints); var routeEndpoint = Assert.IsType(endpoint); @@ -148,9 +156,12 @@ public void AddEndpoints_RunsFinallyConventionsAfterRegularConventions() "/", Array.Empty()), conventions, - finallyConventions); + finallyConventions, + new ConfiguredRenderModesMetadata(Array.Empty())); var endpoint = Assert.Single(endpoints); Assert.DoesNotContain(endpoint.Metadata, m => m is AuthorizeAttribute); } + + class TestRenderMode : IComponentRenderMode { } } diff --git a/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs b/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs new file mode 100644 index 000000000000..09e7ec399feb --- /dev/null +++ b/src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class SSRRenderModeBoundaryTest +{ + // While most aspects of SSRRenderModeBoundary are only interesting to test E2E, + // the configuration validation aspect is better covered as unit tests because + // otherwise we would need many different E2E test app configurations. + + [Fact] + public void DoesNotAssertAboutConfiguredRenderModesOnUnknownEndpoints() + { + // Arrange: an endpoint with no ConfiguredRenderModesMetadata + var httpContext = new DefaultHttpContext(); + httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(), null)); + + // Act/Assert: no exception means we're OK + new SSRRenderModeBoundary(httpContext, typeof(TestComponent), new ServerRenderMode()); + new SSRRenderModeBoundary(httpContext, typeof(TestComponent), new WebAssemblyRenderMode()); + new SSRRenderModeBoundary(httpContext, typeof(TestComponent), new AutoRenderMode()); + } + + [Fact] + public void ThrowsIfServerRenderModeUsedAndNotConfigured() + { + // Arrange + var httpContext = new DefaultHttpContext(); + PrepareEndpoint(httpContext, new WebAssemblyRenderModeSubclass()); + + // Act/Assert + var ex = Assert.Throws(() => new SSRRenderModeBoundary( + httpContext, typeof(TestComponent), new ServerRenderModeSubclass())); + Assert.Contains($"A component of type '{typeof(TestComponent)}' has render mode '{nameof(ServerRenderModeSubclass)}'", ex.Message); + Assert.Contains($"add a call to 'AddServerRenderMode'", ex.Message); + } + + [Fact] + public void ThrowsIfWebAssemblyRenderModeUsedAndNotConfigured() + { + // Arrange + var httpContext = new DefaultHttpContext(); + PrepareEndpoint(httpContext, new ServerRenderModeSubclass()); + + // Act/Assert + var ex = Assert.Throws(() => new SSRRenderModeBoundary( + httpContext, typeof(TestComponent), new WebAssemblyRenderModeSubclass())); + Assert.Contains($"A component of type '{typeof(TestComponent)}' has render mode '{nameof(WebAssemblyRenderModeSubclass)}'", ex.Message); + Assert.Contains($"add a call to 'AddWebAssemblyRenderMode'", ex.Message); + } + + [Fact] + public void ThrowsIfAutoRenderModeUsedAndServerNotConfigured() + { + // Arrange + var httpContext = new DefaultHttpContext(); + PrepareEndpoint(httpContext, new WebAssemblyRenderModeSubclass()); + + // Act/Assert + var ex = Assert.Throws(() => new SSRRenderModeBoundary( + httpContext, typeof(TestComponent), new AutoRenderModeSubclass())); + Assert.Contains($"A component of type '{typeof(TestComponent)}' has render mode '{nameof(AutoRenderModeSubclass)}'", ex.Message); + Assert.Contains($"add a call to 'AddServerRenderMode'", ex.Message); + } + + [Fact] + public void ThrowsIfAutoRenderModeUsedAndWebAssemblyNotConfigured() + { + // Arrange + var httpContext = new DefaultHttpContext(); + PrepareEndpoint(httpContext, new ServerRenderModeSubclass()); + + // Act/Assert + var ex = Assert.Throws(() => new SSRRenderModeBoundary( + httpContext, typeof(TestComponent), new AutoRenderModeSubclass())); + Assert.Contains($"A component of type '{typeof(TestComponent)}' has render mode '{nameof(AutoRenderModeSubclass)}'", ex.Message); + Assert.Contains($"add a call to 'AddWebAssemblyRenderMode'", ex.Message); + } + + private static void PrepareEndpoint(HttpContext httpContext, params IComponentRenderMode[] configuredModes) + { + httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection( + new ConfiguredRenderModesMetadata(configuredModes)), null)); + } + + class TestComponent : IComponent + { + public void Attach(RenderHandle renderHandle) + => throw new NotImplementedException(); + + public Task SetParametersAsync(ParameterView parameters) + => throw new NotImplementedException(); + } + + class ServerRenderModeSubclass : ServerRenderMode { } + class WebAssemblyRenderModeSubclass : WebAssemblyRenderMode { } + class AutoRenderModeSubclass : AutoRenderMode { } +}