Skip to content

Give a useful error if rendermode endpoints aren't mapped #50311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ private void UpdateEndpoints()
{
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;
Expand All @@ -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().");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reference to UseDeclaredRenderModes is obsolete as that no longer exists.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ internal void AddEndpoints(
[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.
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SteveSandersonMS might be "easier" to add this to pageDefinition.Metadata and that way we don't have to flow an extra parameter though?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like that would be less easy since we'd have to flow the list of configured render modes deeper through PageComponentBuilder and into the PageComponentInfo constructor.

It's internal and doesn't impact any public APIs in any case.


foreach (var convention in conventions)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -84,7 +84,7 @@ public async ValueTask<IHtmlAsyncContent> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semi-related: a possible way forwards for one of the UseExceptionHandler problems (#50287) might be here. If the error handling middleware leaves some clue that it's responsible for the current endpoint (e.g., if it drops some hint into httpContext.Items) then SSRRenderModeBoundary could simply behave as if there was no rendermode, thereby forcing the entire page to render SSR-only. The client-side code would no longer try to start a circuit/wasm-runtime because there would be no interactive component markers in the page.

That doesn't solve the "need a new DI scope" issue but it solves 50% of the problem at least.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SteveSandersonMS what if the page was able to say "Disallow interactivity" through an attribute or similar (only at the page level).

That would mean the Error page can apply that attribute and there's no dance needed between the exception handler and Blazor.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's probably reasonable. Let's do any follow-up about that in #50287

}

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ public void AddEndpoints_CreatesEndpointWithExpectedMetadata()
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);
Expand All @@ -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<HttpMethodMetadata>());
Expand Down Expand Up @@ -63,7 +68,8 @@ public void AddEndpoints_RunsConventions()
"/",
Array.Empty<object>()),
conventions,
finallyConventions);
finallyConventions,
new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));

var endpoint = Assert.Single(endpoints);
Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute);
Expand All @@ -90,7 +96,8 @@ public void AddEndpoints_RunsFinallyConventions()
"/",
Array.Empty<object>()),
conventions,
finallyConventions);
finallyConventions,
new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));

var endpoint = Assert.Single(endpoints);
Assert.Contains(endpoint.Metadata, m => m is AuthorizeAttribute);
Expand All @@ -117,7 +124,8 @@ public void AddEndpoints_RouteOrderCanNotBeChanged()
"/",
Array.Empty<object>()),
conventions,
finallyConventions);
finallyConventions,
new ConfiguredRenderModesMetadata(Array.Empty<IComponentRenderMode>()));

var endpoint = Assert.Single(endpoints);
var routeEndpoint = Assert.IsType<RouteEndpoint>(endpoint);
Expand Down Expand Up @@ -148,9 +156,12 @@ public void AddEndpoints_RunsFinallyConventionsAfterRegularConventions()
"/",
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 changes: 102 additions & 0 deletions src/Components/Endpoints/test/SSRRenderModeBoundaryTest.cs
Original file line number Diff line number Diff line change
@@ -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 { }
}