Browse Source

Provide a better error (#50311)

Steve Sanderson 2 years ago
parent
commit
5f081b81a7

+ 9 - 0
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;
+}

+ 5 - 3
src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs

@@ -98,10 +98,12 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp
         {
             var endpoints = new List<Endpoint>();
             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<IComponentRenderMode> renderModes = Options.ConfiguredRenderModes;
@@ -127,8 +129,8 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp
                 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().");
                 }
             }
 

+ 3 - 1
src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs

@@ -24,7 +24,8 @@ internal class RazorComponentEndpointFactory
         [DynamicallyAccessedMembers(Component)] Type rootComponent,
         PageComponentInfo pageDefinition,
         IReadOnlyList<Action<EndpointBuilder>> conventions,
-        IReadOnlyList<Action<EndpointBuilder>> finallyConventions)
+        IReadOnlyList<Action<EndpointBuilder>> 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 class RazorComponentEndpointFactory
         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)
         {

+ 2 - 2
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

@@ -29,7 +29,7 @@ internal partial class EndpointHtmlRenderer
         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 @@ internal partial class EndpointHtmlRenderer
         {
             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);
 

+ 51 - 1
src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs

@@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis;
 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<string, object?>? _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 @@ internal class SSRRenderModeBoundary : IComponent
         };
     }
 
+    private static void AssertRenderModeIsConfigured(HttpContext httpContext, Type componentType, IComponentRenderMode renderMode)
+    {
+        var configuredRenderModesMetadata = httpContext.GetEndpoint()?.Metadata.GetMetadata<ConfiguredRenderModesMetadata>();
+        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<ServerRenderMode>(componentType, renderMode, configuredModes, "AddServerRenderMode");
+        }
+
+        if (renderMode is WebAssemblyRenderMode || renderMode is AutoRenderMode)
+        {
+            AssertRenderModeIsConfigured<WebAssemblyRenderMode>(componentType, renderMode, configuredModes, "AddWebAssemblyRenderMode");
+        }
+    }
+
+    private static void AssertRenderModeIsConfigured<TRequiredMode>(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;

+ 16 - 5
src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs

@@ -18,13 +18,16 @@ public class RazorComponentEndpointFactoryTest
         var factory = new RazorComponentEndpointFactory();
         var conventions = new List<Action<EndpointBuilder>>();
         var finallyConventions = new List<Action<EndpointBuilder>>();
+        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 class RazorComponentEndpointFactoryTest
         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<HttpMethodMetadata>());
@@ -63,7 +68,8 @@ public class RazorComponentEndpointFactoryTest
                 "/",
                 Array.Empty<object>()),
             conventions,
-            finallyConventions);
+            finallyConventions,
+            new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));
 
         var endpoint = Assert.Single(endpoints);
         Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute);
@@ -90,7 +96,8 @@ public class RazorComponentEndpointFactoryTest
                 "/",
                 Array.Empty<object>()),
             conventions,
-            finallyConventions);
+            finallyConventions,
+            new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));
 
         var endpoint = Assert.Single(endpoints);
         Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute);
@@ -117,7 +124,8 @@ public class RazorComponentEndpointFactoryTest
                 "/",
                 Array.Empty<object>()),
             conventions,
-            finallyConventions);
+            finallyConventions,
+            new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));
 
         var endpoint = Assert.Single(endpoints);
         var routeEndpoint = Assert.IsType<RouteEndpoint>(endpoint);
@@ -148,9 +156,12 @@ public class RazorComponentEndpointFactoryTest
                 "/",
                 Array.Empty<object>()),
             conventions,
-            finallyConventions);
+            finallyConventions,
+            new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));
 
         var endpoint = Assert.Single(endpoints);
         Assert.DoesNotContain(endpoint.Metadata, m => m is AuthorizeAttribute);
     }
+
+    class TestRenderMode : IComponentRenderMode { }
 }

+ 102 - 0
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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 { }
+}