Skip to content

Unify the UX of template projects on navigation to non-existing page #62067

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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions src/Components/Components/src/LayoutAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ public LayoutAttribute([DynamicallyAccessedMembers(Component)] Type layoutType)
/// The type of the layout. The type must implement <see cref="IComponent"/>
/// and must accept a parameter with the name 'Body'.
/// </summary>
[DynamicallyAccessedMembers(Component)]
public Type LayoutType { get; private set; }
}
50 changes: 38 additions & 12 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary

private bool _onNavigateCalled;

[DynamicallyAccessedMembers(LinkerFlags.Component)]
private Type? _notFoundLayoutType;

[Inject] private NavigationManager NavigationManager { get; set; }

[Inject] private INavigationInterception NavigationInterception { get; set; }
Expand Down Expand Up @@ -156,6 +159,9 @@ public async Task SetParametersAsync(ParameterView parameters)
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
}

var layoutAttr = NotFoundPage.GetTypeInfo().GetCustomAttribute<LayoutAttribute>();
_notFoundLayoutType = layoutAttr?.LayoutType;
}

if (!_onNavigateCalled)
Expand Down Expand Up @@ -223,7 +229,7 @@ internal virtual void Refresh(bool isNavigationIntercepted)
var relativePath = NavigationManager.ToBaseRelativePath(_locationAbsolute.AsSpan());
var locationPathSpan = TrimQueryOrHash(relativePath);
var locationPath = $"/{locationPathSpan}";
Activity? activity = null;
Activity? activity;

// In order to avoid routing twice we check for RouteData
if (RoutingStateProvider?.RouteData is { } endpointRouteData)
Expand Down Expand Up @@ -286,7 +292,7 @@ internal virtual void Refresh(bool isNavigationIntercepted)
// We did not find a Component that matches the route.
// Only show the NotFound content if the application developer programatically got us here i.e we did not
// intercept the navigation. In all other cases, force a browser navigation since this could be non-Blazor content.
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
RenderNotFound();
}
else
{
Expand Down Expand Up @@ -382,23 +388,43 @@ private void OnNotFound(object sender, EventArgs args)
if (_renderHandle.IsInitialized)
{
Log.DisplayingNotFound(_logger);
_renderHandle.Render(builder =>
RenderNotFound();
}
}

private void RenderNotFound()
{
_renderHandle.Render(builder =>
{
if (NotFoundPage != null)
{
if (NotFoundPage != null)
if (_notFoundLayoutType is Type layoutType)
{
builder.OpenComponent(0, NotFoundPage);
// Directly instantiate the layout type, supplying the NotFoundPage as the Body
builder.OpenComponent(0, layoutType);
builder.AddAttribute(1, LayoutComponentBase.BodyPropertyName,
(RenderFragment)(childBuilder =>
{
childBuilder.OpenComponent(2, NotFoundPage);
childBuilder.CloseComponent();
}));
builder.CloseComponent();
}
else if (NotFound != null)
{
NotFound(builder);
}
else
{
DefaultNotFoundContent(builder);
builder.OpenComponent(0, NotFoundPage);
builder.CloseComponent();
}
});
}
}
else if (NotFound != null)
{
NotFound(builder);
}
else
{
DefaultNotFoundContent(builder);
}
});
}

async Task IHandleAfterRender.OnAfterRenderAsync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1411,4 +1411,41 @@ public void NavigatesWithInteractivityByRequestRedirection(bool controlFlowByExc
Browser.Click(By.Id("redirectButton"));
Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text);
}

[Theory]
// prerendering (SSR) is tested in NoInteractivityTest
[InlineData("ServerNonPrerendered")]
[InlineData("WebAssemblyNonPrerendered")]
public void ProgrammaticNavigationToNotExistingPathReExecutesTo404(string renderMode)
{
Navigate($"{ServerPathBase}/reexecution/redirection-not-found?renderMode={renderMode}&navigate-programmatically=true");
Assert404ReExecuted();
}

[Theory]
// prerendering (SSR) is tested in NoInteractivityTest
[InlineData("ServerNonPrerendered")]
[InlineData("WebAssemblyNonPrerendered")]
public void LinkNavigationToNotExistingPathReExecutesTo404(string renderMode)
{
Navigate($"{ServerPathBase}/reexecution/redirection-not-found?renderMode={renderMode}");
Browser.Click(By.Id("link-to-not-existing-page"));
Assert404ReExecuted();
}

[Theory]
// prerendering (SSR) is tested in NoInteractivityTest
[InlineData("ServerNonPrerendered")]
[InlineData("WebAssemblyNonPrerendered")]
public void BrowserNavigationToNotExistingPathReExecutesTo404(string renderMode)
{
// non-existing path has to have re-execution middleware set up
// so it has to have "reexecution" prefix. Otherwise middleware mapping
// will not be activated, see configuration in Startup
Navigate($"{ServerPathBase}/reexecution/not-existing-page?renderMode={renderMode}");
Assert404ReExecuted();
}

private void Assert404ReExecuted() =>
Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => Browser.Exists(By.Id("test-info")).Text);
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@ public void CanRenderNotFoundPageAfterStreamingStarted()
Browser.Equal("Default Not Found Page", () => Browser.Title);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void ProgrammaticNavigationToNotExistingPathReExecutesTo404(bool streaming)
{
string streamingPath = streaming ? "-streaming" : "";
Navigate($"{ServerPathBase}/reexecution/redirection-not-found-ssr{streamingPath}?navigate-programmatically=true");
Assert404ReExecuted();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void LinkNavigationToNotExistingPathReExecutesTo404(bool streaming)
{
string streamingPath = streaming ? "-streaming" : "";
Navigate($"{ServerPathBase}/reexecution/redirection-not-found-ssr{streamingPath}");
Browser.Click(By.Id("link-to-not-existing-page"));
Assert404ReExecuted();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void BrowserNavigationToNotExistingPathReExecutesTo404(bool streaming)
{
// non-existing path has to have re-execution middleware set up
// so it has to have "reexecution" prefix. Otherwise middleware mapping
// will not be activated, see configuration in Startup
string streamingPath = streaming ? "-streaming" : "";
Navigate($"{ServerPathBase}/reexecution/not-existing-page-ssr{streamingPath}");
Assert404ReExecuted();
}

private void Assert404ReExecuted() =>
Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => Browser.Exists(By.Id("test-info")).Text);

[Theory]
[InlineData(true)]
[InlineData(false)]
Expand All @@ -99,6 +136,9 @@ public void CanRenderNotFoundPageNoStreaming(bool useCustomNotFoundPage)
{
var infoText = Browser.FindElement(By.Id("test-info")).Text;
Assert.Contains("Welcome On Custom Not Found Page", infoText);
// custom page should have a custom layout
var aboutLink = Browser.FindElement(By.Id("about-link")).Text;
Assert.Contains("About", aboutLink);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public void CanRenderNotFoundInteractive(string renderingMode, bool useCustomNot
{
var infoText = Browser.FindElement(By.Id("test-info")).Text;
Assert.Contains("Welcome On Custom Not Found Page", infoText);
// custom page should have a custom layout
var aboutLink = Browser.FindElement(By.Id("about-link")).Text;
Assert.Contains("About", aboutLink);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,37 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

app.Map("/subdir", app =>
{
if (!env.IsDevelopment())
app.Map("/reexecution", reexecutionApp =>
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseStaticFiles();
app.UseRouting();
RazorComponentEndpointsStartup<TRootComponent>.UseFakeAuthState(app);
app.UseAntiforgery();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorComponents<TRootComponent>();
reexecutionApp.UseStaticFiles();
reexecutionApp.UseStatusCodePagesWithReExecute("/not-found-reexecute", createScopeForErrors: true);
reexecutionApp.UseRouting();
RazorComponentEndpointsStartup<TRootComponent>.UseFakeAuthState(reexecutionApp);
reexecutionApp.UseAntiforgery();
reexecutionApp.UseEndpoints(endpoints =>
{
endpoints.MapRazorComponents<TRootComponent>();
});
});

ConfigureSubdirPipeline(app, env);
});
}

private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironment env)
{
if (!env.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseStaticFiles();
app.UseRouting();
RazorComponentEndpointsStartup<TRootComponent>.UseFakeAuthState(app);
app.UseAntiforgery();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorComponents<TRootComponent>();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,17 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.Map("/reexecution", reexecutionApp =>
{
reexecutionApp.UseStatusCodePagesWithReExecute("/not-found-reexecute", createScopeForErrors: true);

reexecutionApp.UseRouting();

reexecutionApp.UseAntiforgery();
reexecutionApp.UseEndpoints(endpoints =>
{
endpoints.MapRazorComponents<TRootComponent>();
});
ConfigureEndpoints(reexecutionApp, env);
});

ConfigureSubdirPipeline(app, env);
});
}

protected virtual void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironment env)
private void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironment env)
{
WebAssemblyTestHelper.ServeCoopHeadersIfWebAssemblyThreadingEnabled(app);

Expand All @@ -106,11 +103,15 @@ protected virtual void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHost
{
if (ctx.Request.Query.ContainsKey("add-csp"))
{
ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'");
ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'");
}
return nxt();
});
ConfigureEndpoints(app, env);
}

private void ConfigureEndpoints(IApplicationBuilder app, IWebHostEnvironment env)
{
_ = app.UseEndpoints(endpoints =>
{
var contentRootStaticAssetsPath = Path.Combine(env.ContentRootPath, "Components.TestServer.staticwebassets.endpoints.json");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@using Components.TestServer.RazorComponents.Pages.Forms
@using Components.WasmMinimal.Pages
@using Components.WasmMinimal.Pages.NotFound

@code {
[Parameter]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@page "/redirection-not-found-ssr-streaming"
@page "/reexecution/redirection-not-found-ssr-streaming"
@attribute [StreamRendering(true)]

<Components.WasmMinimal.Pages.NotFound.RedirectionNotFoundComponent StartStreaming="true" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@page "/redirection-not-found-ssr"
@page "/reexecution/redirection-not-found-ssr"
@attribute [StreamRendering(false)]

<Components.WasmMinimal.Pages.NotFound.RedirectionNotFoundComponent />

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@page "/render-custom-not-found-page"
@layout NotFoundLayout

<h3 id="test-info">Welcome On Custom Not Found Page</h3>
<p>Sorry, the page you are looking for does not exist.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@inherits LayoutComponentBase

<div class="page">
<header class="top-bar">
<a id="about-link" href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</header>
<main>
<article class="content px-4">
@Body
</article>
</main>
</div>

<style>
.top-bar {
background-color: #0078d4;
color: white;
padding: 10px;
text-align: center;
width: 100%;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@page "/redirection-not-found"
@page "/reexecution/redirection-not-found"

<RedirectionNotFoundComponent @rendermode="@RenderModeHelper.GetRenderMode(_renderMode)" WaitForInteractivity="true"/>

@code{
[Parameter, SupplyParameterFromQuery(Name = "renderMode")]
public string? RenderModeStr { get; set; }

private RenderModeId _renderMode;

protected override void OnInitialized()
{
if (!string.IsNullOrEmpty(RenderModeStr))
{
_renderMode = RenderModeHelper.ParseRenderMode(RenderModeStr);
}
else
{
throw new ArgumentException("RenderModeStr cannot be null or empty. Did you mean to redirect to /redirection-not-found-ssr?", nameof(RenderModeStr));
}
}
}
Loading
Loading