Skip to content

Commit 18b3b4a

Browse files
authored
Add NotFound method in NavigationManager for interactive and static rendering (#60752)
* Interactive NotFound event + SSR status code. * Remove circular DI, pass renderer on initialization instead. * Interactive NotFound tests. * Server test. * @javiercn's fixes * Fix failing test that was missing initialization.
1 parent 0a399d2 commit 18b3b4a

File tree

14 files changed

+191
-9
lines changed

14 files changed

+191
-9
lines changed

src/Components/Components/src/NavigationManager.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@ public event EventHandler<LocationChangedEventArgs> LocationChanged
3535

3636
private CancellationTokenSource? _locationChangingCts;
3737

38+
/// <summary>
39+
/// An event that fires when the page is not found.
40+
/// </summary>
41+
public event EventHandler<EventArgs> NotFoundEvent
42+
{
43+
add
44+
{
45+
AssertInitialized();
46+
_notFound += value;
47+
}
48+
remove
49+
{
50+
AssertInitialized();
51+
_notFound -= value;
52+
}
53+
}
54+
55+
private EventHandler<EventArgs>? _notFound;
56+
3857
// For the baseUri it's worth storing as a System.Uri so we can do operations
3958
// on that type. System.Uri gives us access to the original string anyway.
4059
private Uri? _baseUri;
@@ -177,6 +196,16 @@ protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)]
177196
public virtual void Refresh(bool forceReload = false)
178197
=> NavigateTo(Uri, forceLoad: true, replace: true);
179198

199+
/// <summary>
200+
/// Handles setting the NotFound state.
201+
/// </summary>
202+
public virtual void NotFound() => NotFoundCore();
203+
204+
private void NotFoundCore()
205+
{
206+
_notFound?.Invoke(this, new EventArgs());
207+
}
208+
180209
/// <summary>
181210
/// Called to initialize BaseURI and current URI before these values are used for the first time.
182211
/// Override <see cref="EnsureInitialized" /> and call this method to dynamically calculate these values.

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.NavigationManager.NotFoundEvent -> System.EventHandler<System.EventArgs!>!
3+
virtual Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
24
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
35
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
46
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions

src/Components/Components/src/Routing/Router.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ public void Attach(RenderHandle renderHandle)
105105
_baseUri = NavigationManager.BaseUri;
106106
_locationAbsolute = NavigationManager.Uri;
107107
NavigationManager.LocationChanged += OnLocationChanged;
108+
NavigationManager.NotFoundEvent += OnNotFound;
108109
RoutingStateProvider = ServiceProvider.GetService<IRoutingStateProvider>();
109110

110111
if (HotReloadManager.Default.MetadataUpdateSupported)
@@ -146,6 +147,7 @@ public async Task SetParametersAsync(ParameterView parameters)
146147
public void Dispose()
147148
{
148149
NavigationManager.LocationChanged -= OnLocationChanged;
150+
NavigationManager.NotFoundEvent -= OnNotFound;
149151
if (HotReloadManager.Default.MetadataUpdateSupported)
150152
{
151153
HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
@@ -320,6 +322,15 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
320322
}
321323
}
322324

325+
private void OnNotFound(object sender, EventArgs args)
326+
{
327+
if (_renderHandle.IsInitialized)
328+
{
329+
Log.DisplayingNotFound(_logger);
330+
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
331+
}
332+
}
333+
323334
async Task IHandleAfterRender.OnAfterRenderAsync()
324335
{
325336
if (!_navigationInterceptionEnabled)
@@ -345,5 +356,8 @@ private static partial class Log
345356

346357
[LoggerMessage(3, LogLevel.Debug, "Navigating to non-component URI '{ExternalUri}' in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToExternalUri")]
347358
internal static partial void NavigatingToExternalUri(ILogger logger, string externalUri, string path, string baseUri);
359+
360+
[LoggerMessage(4, LogLevel.Debug, $"Displaying {nameof(NotFound)} on request", EventName = "DisplayingNotFoundOnRequest")]
361+
internal static partial void DisplayingNotFound(ILogger logger);
348362
}
349363
}

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private async Task RenderComponentCore(HttpContext context)
7474
return Task.CompletedTask;
7575
});
7676

77-
await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
77+
await _renderer.InitializeStandardComponentServicesAsync(
7878
context,
7979
componentType: pageComponent,
8080
handler: result.HandlerName,

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ private Task ReturnErrorResponse(string detailedMessage)
7474
: Task.CompletedTask;
7575
}
7676

77+
private void SetNotFoundResponse(object? sender, EventArgs args)
78+
{
79+
if (_httpContext.Response.HasStarted)
80+
{
81+
throw new InvalidOperationException("Cannot set a NotFound response after the response has already started.");
82+
}
83+
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
84+
SignalRendererToFinishRendering();
85+
}
86+
7787
private void UpdateNamedSubmitEvents(in RenderBatch renderBatch)
7888
{
7989
if (renderBatch.NamedEventChanges is { } changes)

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
4343
private Task? _servicesInitializedTask;
4444
private HttpContext _httpContext = default!; // Always set at the start of an inbound call
4545
private ResourceAssetCollection? _resourceCollection;
46+
private bool _rendererIsStopped;
4647

4748
// The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e.,
4849
// when everything (regardless of streaming SSR) is fully complete. In this subclass we also track
@@ -71,14 +72,19 @@ private void SetHttpContext(HttpContext httpContext)
7172
}
7273
}
7374

74-
internal static async Task InitializeStandardComponentServicesAsync(
75+
internal async Task InitializeStandardComponentServicesAsync(
7576
HttpContext httpContext,
7677
[DynamicallyAccessedMembers(Component)] Type? componentType = null,
7778
string? handler = null,
7879
IFormCollection? form = null)
7980
{
80-
var navigationManager = (IHostEnvironmentNavigationManager)httpContext.RequestServices.GetRequiredService<NavigationManager>();
81-
navigationManager?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request));
81+
var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>();
82+
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request));
83+
84+
if (navigationManager != null)
85+
{
86+
navigationManager.NotFoundEvent += SetNotFoundResponse;
87+
}
8288

8389
var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
8490
if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider)
@@ -170,14 +176,31 @@ protected override void AddPendingTask(ComponentState? componentState, Task task
170176
base.AddPendingTask(componentState, task);
171177
}
172178

179+
private void SignalRendererToFinishRendering()
180+
{
181+
_rendererIsStopped = true;
182+
}
183+
184+
protected override void ProcessPendingRender()
185+
{
186+
if (_rendererIsStopped)
187+
{
188+
// When the application triggers a NotFound event, we continue rendering the current batch.
189+
// However, after completing this batch, we do not want to process any further UI updates,
190+
// as we are going to return a 404 status and discard the UI updates generated so far.
191+
return;
192+
}
193+
base.ProcessPendingRender();
194+
}
195+
173196
// For tests only
174197
internal Task? NonStreamingPendingTasksCompletion;
175198

176199
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
177200
{
178201
UpdateNamedSubmitEvents(in renderBatch);
179202

180-
if (_streamingUpdatesWriter is { } writer)
203+
if (_streamingUpdatesWriter is { } writer && !_rendererIsStopped)
181204
{
182205
// Important: SendBatchAsStreamingUpdate *must* be invoked synchronously
183206
// before any 'await' in this method. That's enforced by the compiler

src/Components/Server/src/Circuits/RemoteNavigationManager.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ public static void RequestingNavigation(ILogger logger, string uri, NavigationOp
197197
[LoggerMessage(5, LogLevel.Error, "Failed to refresh", EventName = "RefreshFailed")]
198198
public static partial void RefreshFailed(ILogger logger, Exception exception);
199199

200+
[LoggerMessage(1, LogLevel.Debug, "Requesting not found", EventName = "RequestingNotFound")]
201+
public static partial void RequestingNotFound(ILogger logger);
202+
200203
[LoggerMessage(6, LogLevel.Debug, "Navigation completed when changing the location to {Uri}", EventName = "NavigationCompleted")]
201204
public static partial void NavigationCompleted(ILogger logger, string uri);
202205

src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Net.Http;
45
using Components.TestServer.RazorComponents;
56
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
67
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;

src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Net;
5+
using System.Net.Http;
46
using Components.TestServer.RazorComponents;
7+
using Microsoft.AspNetCore.Components.E2ETest;
58
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
69
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
710
using Microsoft.AspNetCore.E2ETesting;
@@ -17,6 +20,40 @@ public class GlobalInteractivityTest(
1720
ITestOutputHelper output)
1821
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<GlobalInteractivityApp>>>(browserFixture, serverFixture, output)
1922
{
23+
24+
[Theory]
25+
[InlineData("server", true)]
26+
[InlineData("webassembly", true)]
27+
[InlineData("ssr", false)]
28+
public void CanRenderNotFoundInteractive(string renderingMode, bool isInteractive)
29+
{
30+
Navigate($"/subdir/render-not-found-{renderingMode}");
31+
32+
if (isInteractive)
33+
{
34+
var buttonId = "trigger-not-found";
35+
Browser.WaitForElementToBeVisible(By.Id(buttonId));
36+
Browser.Exists(By.Id(buttonId)).Click();
37+
}
38+
39+
var bodyText = Browser.FindElement(By.TagName("body")).Text;
40+
Assert.Contains("There's nothing here", bodyText);
41+
}
42+
43+
[Fact]
44+
public async Task CanSetNotFoundStatus()
45+
{
46+
var statusCode = await GetStatusCodeAsync($"/subdir/render-not-found-ssr");
47+
Assert.Equal(HttpStatusCode.NotFound, statusCode);
48+
}
49+
50+
private async Task<HttpStatusCode> GetStatusCodeAsync(string relativeUrl)
51+
{
52+
using var client = new HttpClient() { BaseAddress = _serverFixture.RootUri };
53+
var response = await client.GetAsync(relativeUrl, HttpCompletionOption.ResponseHeadersRead);
54+
return response.StatusCode;
55+
}
56+
2057
[Fact]
2158
public void CanFindStaticallyRenderedPageAfterClickingBrowserBackButtonOnDynamicallyRenderedPage()
2259
{

src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,13 @@
2323
<li>
2424
<a href="routing/complex-segment(value)">Complex segments</a>
2525
</li>
26+
<li>
27+
<a href="routing/not-found-ssr">Not found page for Static Server Rendering</a>
28+
</li>
29+
<li>
30+
<a href="routing/not-found-webassembly">Not found page for Interactive WebAssembly rendering</a>
31+
</li>
32+
<li>
33+
<a href="routing/not-found-server">Not found page for Interactive Server rendering</a>
34+
</li>
2635
</ul>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@page "/render-not-found-server"
2+
@using Microsoft.AspNetCore.Components;
3+
@using Microsoft.AspNetCore.Components.Web;
4+
@inject NavigationManager NavigationManager
5+
6+
<p id="test-info">Any content</p>
7+
@if(RendererInfo.IsInteractive)
8+
{
9+
<button id="trigger-not-found" @onclick="@TriggerNotFound">Trigger not found button</button>
10+
}
11+
12+
@code{
13+
private void TriggerNotFound()
14+
{
15+
NavigationManager.NotFound();
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@page "/render-not-found-webassembly"
2+
@using Microsoft.AspNetCore.Components;
3+
@using Microsoft.AspNetCore.Components.Web;
4+
@inject NavigationManager NavigationManager
5+
6+
<p id="test-info">Any content</p>
7+
@if(RendererInfo.IsInteractive)
8+
{
9+
<button id="trigger-not-found" @onclick="@TriggerNotFound">Trigger not found button</button>
10+
}
11+
12+
@code{
13+
private void TriggerNotFound()
14+
{
15+
NavigationManager.NotFound();
16+
}
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@page "/render-not-found-ssr"
2+
@inject NavigationManager NavigationManager
3+
4+
<p id="test-info">Any content</p>
5+
6+
@code{
7+
protected override void OnInitialized()
8+
{
9+
NavigationManager.NotFound();
10+
}
11+
}

src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,6 @@ private static TagHelperOutput GetTagHelperOutput()
7777

7878
private ViewContext GetViewContext()
7979
{
80-
var navManager = new Mock<NavigationManager>();
81-
navManager.As<IHostEnvironmentNavigationManager>();
82-
8380
var httpContext = new DefaultHttpContext
8481
{
8582
RequestServices = new ServiceCollection()
@@ -89,7 +86,7 @@ private ViewContext GetViewContext()
8986
x => x.CreateProtector(It.IsAny<string>()) == Mock.Of<IDataProtector>()))
9087
.AddLogging()
9188
.AddScoped<ComponentStatePersistenceManager>()
92-
.AddScoped(_ => navManager.Object)
89+
.AddScoped<NavigationManager, MockNavigationManager>()
9390
.AddScoped<HttpContextFormDataProvider>()
9491
.BuildServiceProvider(),
9592
};
@@ -104,4 +101,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
104101
builder.AddContent(0, "Hello from the component");
105102
}
106103
}
104+
105+
class MockNavigationManager : NavigationManager, IHostEnvironmentNavigationManager
106+
{
107+
public MockNavigationManager()
108+
{
109+
Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
110+
}
111+
112+
void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri)
113+
{
114+
}
115+
}
107116
}

0 commit comments

Comments
 (0)