Skip to content
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

Per-page opt out from interactive routing #55157

Merged
Merged
Show file tree
Hide file tree
Changes from 17 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
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.ExcludeFromInteractiveRoutingAttribute
Microsoft.AspNetCore.Components.ExcludeFromInteractiveRoutingAttribute.ExcludeFromInteractiveRoutingAttribute() -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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.Routing;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// When applied to a page component, indicates that the interactive <see cref="Router"/> component should
/// ignore that page. This means that navigations to the page will not be resolved by interactive routing,
/// but instead will cause a full page reload.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class ExcludeFromInteractiveRoutingAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ static void GetRouteableComponents(List<Type> routeableComponents, Assembly asse
{
foreach (var type in assembly.ExportedTypes)
{
if (typeof(IComponent).IsAssignableFrom(type) && type.IsDefined(typeof(RouteAttribute)))
if (typeof(IComponent).IsAssignableFrom(type)
&& type.IsDefined(typeof(RouteAttribute))
&& !type.IsDefined(typeof(ExcludeFromInteractiveRoutingAttribute)))
{
routeableComponents.Add(type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using System.Reflection;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.Routing.Tree;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -74,6 +75,20 @@ public void IgnoresIdenticalTypes()
Assert.Equal(routes.GroupBy(x => x.Handler).Count(), routes.Count);
}

[Fact]
public void RespectsExcludeFromInteractiveRoutingAttribute()
{
// Arrange & Act
var routeTableFactory = new RouteTableFactory();
var routeTable = routeTableFactory.Create(new RouteKey(GetType().Assembly, Array.Empty<Assembly>()), _serviceProvider);

var routes = GetRoutes(routeTable);

// Assert
Assert.Contains(routes, r => r.Handler == typeof(ComponentWithoutExcludeFromInteractiveRoutingAttribute));
Assert.DoesNotContain(routes, r => r.Handler == typeof(ComponentWithExcludeFromInteractiveRoutingAttribute));
}

[Fact]
public void CanDiscoverRoute()
{
Expand Down Expand Up @@ -1120,4 +1135,11 @@ public RouteTable Build()

class TestHandler1 { }
class TestHandler2 { }

[Route("/ComponentWithoutExcludeFromInteractiveRoutingAttribute")]
public class ComponentWithoutExcludeFromInteractiveRoutingAttribute : ComponentBase { }

[Route("/ComponentWithExcludeFromInteractiveRoutingAttribute")]
[ExcludeFromInteractiveRouting]
public class ComponentWithExcludeFromInteractiveRoutingAttribute : ComponentBase { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Reflection;
using System.Reflection.Metadata;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;

[assembly: MetadataUpdateHandler(typeof(RazorComponentsEndpointHttpContextExtensions.MetadataUpdateHandler))]

namespace Microsoft.AspNetCore.Components.Routing;

/// <summary>
/// Extensions to <see cref="HttpContext"/> for Razor component applications.
/// </summary>
public static class RazorComponentsEndpointHttpContextExtensions
{
private static readonly ConcurrentDictionary<Type, bool> AcceptsInteractiveRoutingCache = new();

/// <summary>
/// Determines whether the current endpoint is a Razor component that can be reached through
/// interactive routing. This is true for all page components except if they declare the
/// attribute <see cref="ExcludeFromInteractiveRoutingAttribute"/>.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/>.</param>
/// <returns>True if the current endpoint is a Razor component that does not declare <see cref="ExcludeFromInteractiveRoutingAttribute"/>.</returns>
public static bool AcceptsInteractiveRouting(this HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);

var pageType = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>()?.Type;

return pageType is not null
&& AcceptsInteractiveRoutingCache.GetOrAdd(
pageType,
static pageType => !pageType.IsDefined(typeof(ExcludeFromInteractiveRoutingAttribute)));
}

internal static class MetadataUpdateHandler
{
/// <summary>
/// Invoked as part of <see cref="MetadataUpdateHandlerAttribute" /> contract for hot reload.
/// </summary>
public static void ClearCache(Type[]? _)
=> AcceptsInteractiveRoutingCache.Clear();
}
}
2 changes: 2 additions & 0 deletions src/Components/Endpoints/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions
static Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions.AcceptsInteractiveRouting(this Microsoft.AspNetCore.Http.HttpContext! context) -> bool
27 changes: 2 additions & 25 deletions src/Components/Samples/BlazorUnitedApp/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,10 @@
<link rel="stylesheet" href="css/site.css" />
<link href="BlazorUnitedApp.styles.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet></HeadOutlet>
<HeadOutlet />
</head>
<body>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

<div id="blazor-error-ui">
<environment include="Staging,Production">
An error has occurred. This application may no longer respond until reloaded.
</environment>
<environment include="Development">
An unhandled exception has occurred. See browser dev tools for details.
</environment>
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>

<Routes />
<script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>

<h1>Counter</h1>
Expand Down
6 changes: 4 additions & 2 deletions src/Components/Samples/BlazorUnitedApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();

builder.Services.AddSingleton<WeatherForecastService>();

Expand All @@ -26,6 +27,7 @@
app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

app.Run();
12 changes: 12 additions & 0 deletions src/Components/Samples/BlazorUnitedApp/Routes.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@
</article>
</main>
</div>

<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
1 change: 1 addition & 0 deletions src/Components/Samples/BlazorUnitedApp/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
@using Microsoft.JSInterop
@using BlazorUnitedApp
@using BlazorUnitedApp.Shared
@using static Microsoft.AspNetCore.Components.Web.RenderMode
73 changes: 69 additions & 4 deletions src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,77 @@ public class GlobalInteractivityTest(
[Fact]
public void CanFindStaticallyRenderedPageAfterClickingBrowserBackButtonOnDynamicallyRenderedPage()
{
Navigate("/subdir/static");
// Start on a static page
Navigate("/subdir/globally-interactive/static-via-url");
Browser.Equal("Global interactivity page: Static via URL", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);

Browser.Click(By.CssSelector("a[href=dynamic]"));
// Navigate to an interactive page and observe it really is interactive
Browser.Click(By.LinkText("Globally-interactive by default"));
Browser.Equal("Global interactivity page: Default", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("interactive webassembly", () => Browser.Exists(By.Id("execution-mode")).Text);

// Show that, after "back", we revert to the previous page
Browser.Navigate().Back();
Browser.Equal("Global interactivity page: Static via URL", () => Browser.Exists(By.TagName("h1")).Text);
}

[Fact]
public void CanNavigateFromStaticToInteractiveAndBack()
{
// Start on a static page
Navigate("/subdir/globally-interactive/static-via-attribute");
Browser.Equal("Global interactivity page: Static via attribute", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);

// Navigate to an interactive page and observe it really is interactive
Browser.Click(By.LinkText("Globally-interactive by default"));
Browser.Equal("Global interactivity page: Default", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("interactive webassembly", () => Browser.Exists(By.Id("execution-mode")).Text);

var heading = Browser.Exists(By.TagName("h1"));
Browser.Equal("Statically Rendered", () => heading.Text);
// Show that, after "back", we revert to static rendering on the previous page
Browser.Navigate().Back();
Browser.Equal("Global interactivity page: Static via attribute", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);
}

[Fact]
public void CanNavigateFromInteractiveToStaticAndBack()
{
// Start on an interactive page
Navigate("/subdir/globally-interactive");
Browser.Equal("Global interactivity page: Default", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("interactive webassembly", () => Browser.Exists(By.Id("execution-mode")).Text);

// Navigate to a static page
Browser.Click(By.LinkText("Static via attribute"));
Browser.Equal("Global interactivity page: Static via attribute", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);

// Show that, after "back", we revert to interactive rendering on the previous page
Browser.Navigate().Back();
Browser.Equal("Global interactivity page: Default", () => Browser.Exists(By.TagName("h1")).Text);
Browser.Equal("interactive webassembly", () => Browser.Exists(By.Id("execution-mode")).Text);
}

[Fact]
public void CanNavigateBetweenStaticPagesViaEnhancedNav()
{
// Start on a static page
Navigate("/subdir/globally-interactive/static-via-attribute");
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);
var h1 = Browser.Exists(By.TagName("h1"));
Assert.Equal("Global interactivity page: Static via attribute", h1.Text);

// Navigate to another static page
// We check it's the same h1 element, because this is enhanced nav
Browser.Click(By.LinkText("Static via URL"));
Browser.Equal("Global interactivity page: Static via URL", () => h1.Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);

// Back also works
Browser.Navigate().Back();
Browser.Equal("Global interactivity page: Static via attribute", () => h1.Text);
Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text);
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<base href="/subdir/" />

<HeadOutlet />
<HeadOutlet @rendermode="@PageRenderMode" />
</head>

<body>
<Components.WasmMinimal.Routes @rendermode="@RenderModeForPage" />
<Components.WasmMinimal.Routes @rendermode="@PageRenderMode" />
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
Expand All @@ -19,16 +16,16 @@
});
</script>
</body>

</html>


@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

// Statically render pages in the "/Account" subdirectory like we do in the Blazor Web template with Individaul auth.
private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/static")
? null
: RenderMode.InteractiveAuto;
// Show we can use arbitrary logic to determine the rendermode. Here it's global by default,
// but that can be suppressed via URL or attribute.
private IComponentRenderMode? PageRenderMode
=> HttpContext.AcceptsInteractiveRouting() && !HttpContext.Request.Path.StartsWithSegments("/globally-interactive/static-via-url")
? RenderMode.InteractiveWebAssembly
: null;
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@page "/globally-interactive"

<h1>Global interactivity page: Default</h1>

<p>
This page should be rendered interactively by GlobalInteractivityApp because
that's the default rendermode for the application.
</p>

<ShowExecutionMode />

<GloballyInteractive_Links />
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@using Microsoft.AspNetCore.Components.Routing
<ul>
<li><NavLink href="globally-interactive">Globally-interactive by default</NavLink></li>
<li><NavLink href="globally-interactive/static-via-url">Static via URL</NavLink></li>
<li><NavLink href="globally-interactive/static-via-attribute">Static via attribute</NavLink></li>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@page "/globally-interactive/static-via-attribute"
@using Microsoft.AspNetCore.Components.Routing
@attribute [ExcludeFromInteractiveRouting]

<h1>Global interactivity page: Static via attribute</h1>

<p>
This page should be rendered statically by GlobalInteractivityApp because
it has [ExcludeFromInteractiveRoutingAttribute], and the root component is
configured to render pages with that attribute statically.
</p>

<ShowExecutionMode />

<GloballyInteractive_Links />
Loading
Loading