diff --git a/src/Components/Components/src/ComponentFactory.cs b/src/Components/Components/src/ComponentFactory.cs index 12ad8ba03dea..f30625e59f65 100644 --- a/src/Components/Components/src/ComponentFactory.cs +++ b/src/Components/Components/src/ComponentFactory.cs @@ -49,23 +49,16 @@ private static ComponentTypeInfoCacheEntry GetComponentTypeInfo([DynamicallyAcce public IComponent InstantiateComponent(IServiceProvider serviceProvider, [DynamicallyAccessedMembers(Component)] Type componentType, IComponentRenderMode? callerSpecifiedRenderMode, int? parentComponentId) { var (componentTypeRenderMode, propertyInjector) = GetComponentTypeInfo(componentType); - IComponent component; - if (componentTypeRenderMode is null && callerSpecifiedRenderMode is null) - { - // Typical case where no rendermode is specified in either location. We don't call ResolveComponentForRenderMode in this case. - component = _componentActivator.CreateInstance(componentType); - } - else - { - // At least one rendermode is specified. We require that it's exactly one, and use ResolveComponentForRenderMode with it. - var effectiveRenderMode = callerSpecifiedRenderMode is null - ? componentTypeRenderMode! - : componentTypeRenderMode is null - ? callerSpecifiedRenderMode - : throw new InvalidOperationException($"The component type '{componentType}' has a fixed rendermode of '{componentTypeRenderMode}', so it is not valid to specify any rendermode when using this component."); - component = _renderer.ResolveComponentForRenderMode(componentType, parentComponentId, _componentActivator, effectiveRenderMode); - } + // In the typical case where no rendermode is specified in either location, we don't even call + // ComputeEffectiveRenderMode and hence don't call ResolveComponentForRenderMode either. + var effectiveRenderMode = componentTypeRenderMode is null && callerSpecifiedRenderMode is null + ? null + : _renderer.ResolveEffectiveRenderMode(componentType, parentComponentId, componentTypeRenderMode, callerSpecifiedRenderMode); + + var component = effectiveRenderMode is null + ? _componentActivator.CreateInstance(componentType) + : _renderer.ResolveComponentForRenderMode(componentType, parentComponentId, _componentActivator, effectiveRenderMode); if (component is null) { diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..93eb502a3e25 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Components.RouteAttribute.Static.get -> bool +Microsoft.AspNetCore.Components.RouteAttribute.Static.set -> void +virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ResolveEffectiveRenderMode(System.Type! componentType, int? parentComponentId, Microsoft.AspNetCore.Components.IComponentRenderMode? componentTypeRenderMode, Microsoft.AspNetCore.Components.IComponentRenderMode? callerSpecifiedRenderMode) -> Microsoft.AspNetCore.Components.IComponentRenderMode? diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 29c6d7ef00c8..bb699572215e 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -1228,6 +1228,27 @@ void NotifyExceptions(List exceptions) } } + /// + /// Determines the rendermode to use for a component when one is specified either at the call site or on the component type. + /// Renderer subclasses may override this to impose additional rules about rendermode selection. + /// + /// The type of component that was requested. + /// The parent component ID, or null if it is a root component. + /// The declared on . + /// The declared at the call site (for example, by the parent component). + /// + /// + protected internal virtual IComponentRenderMode? ResolveEffectiveRenderMode(Type componentType, int? parentComponentId, IComponentRenderMode? componentTypeRenderMode, IComponentRenderMode? callerSpecifiedRenderMode) + { + // To avoid confusion, we require that you don't specify the rendermode in two places. This means that + // the resolution is trivial - just use the nonnull one (if any). + return callerSpecifiedRenderMode is null + ? componentTypeRenderMode! + : componentTypeRenderMode is null + ? callerSpecifiedRenderMode + : throw new InvalidOperationException($"The component type '{componentType}' has a fixed rendermode of '{componentTypeRenderMode}', so it is not valid to specify any rendermode when using this component."); + } + /// /// Determines how to handle an when obtaining a component instance. /// This is only called when a render mode is specified either at the call site or on the component type. diff --git a/src/Components/Components/src/RouteAttribute.cs b/src/Components/Components/src/RouteAttribute.cs index 58d3c2500dc9..a8f02aec2a9d 100644 --- a/src/Components/Components/src/RouteAttribute.cs +++ b/src/Components/Components/src/RouteAttribute.cs @@ -24,4 +24,10 @@ public RouteAttribute(string template) /// Gets the route template. /// public string Template { get; } + + /// + /// Gets or sets a flag to indicate whether the page should be rendered statically. + /// The effect of this flag is to suppress any @rendermode directives in the root component. + /// + public bool Static { get; set; } } diff --git a/src/Components/Components/src/Routing/RouteTable.cs b/src/Components/Components/src/Routing/RouteTable.cs index 2d4c9335cc87..fe2fa57436f9 100644 --- a/src/Components/Components/src/Routing/RouteTable.cs +++ b/src/Components/Components/src/Routing/RouteTable.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components.Routing; internal sealed class RouteTable(TreeRouter treeRouter) { private readonly TreeRouter _router = treeRouter; - private static readonly ConcurrentDictionary<(Type, string), InboundRouteEntry> _routeEntryCache = new(); + private static readonly ConcurrentDictionary<(Type, string), InboundRouteEntry> _parameterProcessingRouteEntryCache = new(); public TreeRouter? TreeRouter => _router; @@ -23,9 +23,11 @@ internal static RouteData ProcessParameters(RouteData endpointRouteData) { if (endpointRouteData.Template != null) { - var entry = _routeEntryCache.GetOrAdd( + // When building this cache, we include static routes because even though the interactive router doesn't use them for + // matching, we still need to process the parameters for them after they are matched during endpoint routing. + var entry = _parameterProcessingRouteEntryCache.GetOrAdd( (endpointRouteData.PageType, endpointRouteData.Template), - ((Type page, string template) key) => RouteTableFactory.CreateEntry(key.page, key.template)); + ((Type page, string template) key) => RouteTableFactory.CreateEntry(key.page, key.template, includeStaticRoutes: true)); var routeValueDictionary = new RouteValueDictionary(endpointRouteData.RouteValues); foreach (var kvp in endpointRouteData.RouteValues) diff --git a/src/Components/Components/src/Routing/RouteTableFactory.cs b/src/Components/Components/src/Routing/RouteTableFactory.cs index 481b24a68951..73ef1c13022c 100644 --- a/src/Components/Components/src/Routing/RouteTableFactory.cs +++ b/src/Components/Components/src/Routing/RouteTableFactory.cs @@ -83,35 +83,40 @@ static void GetRouteableComponents(List routeableComponents, Assembly asse internal static RouteTable Create(List componentTypes, IServiceProvider serviceProvider) { - var templatesByHandler = new Dictionary(); + var templatesByHandler = new Dictionary>(); foreach (var componentType in componentTypes) { // We're deliberately using inherit = false here. // // RouteAttribute is defined as non-inherited, because inheriting a route attribute always causes an // ambiguity. You end up with two components (base class and derived class) with the same route. - var templates = GetTemplates(componentType); - - templatesByHandler.Add(componentType, templates); + // We exclude static routes because this is the interactive router. + var templates = GetTemplates(componentType, includeStaticRoutes: false); + if (templates.Count > 0) + { + templatesByHandler.Add(componentType, templates); + } } return Create(templatesByHandler, serviceProvider); } - private static string[] GetTemplates(Type componentType) + private static IReadOnlyList GetTemplates(Type componentType, bool includeStaticRoutes) { var routeAttributes = componentType.GetCustomAttributes(typeof(RouteAttribute), inherit: false); - var templates = new string[routeAttributes.Length]; - for (var i = 0; i < routeAttributes.Length; i++) + var templates = new List(routeAttributes.Length); + foreach (RouteAttribute routeAttribute in routeAttributes) { - var attribute = (RouteAttribute)routeAttributes[i]; - templates[i] = attribute.Template; + if (includeStaticRoutes || !routeAttribute.Static) + { + templates.Add(routeAttribute.Template); + } } return templates; } [UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Application code does not get trimmed, and the framework does not define routable components.")] - internal static RouteTable Create(Dictionary templatesByHandler, IServiceProvider serviceProvider) + internal static RouteTable Create(Dictionary> templatesByHandler, IServiceProvider serviceProvider) { var routeOptions = Options.Create(new RouteOptions()); if (!OperatingSystem.IsBrowser() || RegexConstraintSupport.IsEnabled) @@ -141,10 +146,10 @@ internal static RouteTable Create(Dictionary templatesByHandler, return new RouteTable(builder.Build()); } - private static TemplateGroupInfo ComputeTemplateGroupInfo(string[] templates) + private static TemplateGroupInfo ComputeTemplateGroupInfo(IReadOnlyList templates) { var result = new TemplateGroupInfo(templates); - for (var i = 0; i < templates.Length; i++) + for (var i = 0; i < templates.Count; i++) { var parsedTemplate = RoutePatternParser.Parse(templates[i]); var parameterNames = GetParameterNames(parsedTemplate); @@ -159,15 +164,15 @@ private static TemplateGroupInfo ComputeTemplateGroupInfo(string[] templates) return result; } - private struct TemplateGroupInfo(string[] templates) + private struct TemplateGroupInfo(IReadOnlyCollection templates) { public HashSet AllRouteParameterNames { get; set; } = new(StringComparer.OrdinalIgnoreCase); - public (RoutePattern, HashSet)[] ParsedTemplates { get; set; } = new (RoutePattern, HashSet)[templates.Length]; + public (RoutePattern, HashSet)[] ParsedTemplates { get; set; } = new (RoutePattern, HashSet)[templates.Count]; } - internal static InboundRouteEntry CreateEntry([DynamicallyAccessedMembers(Component)] Type pageType, string template) + internal static InboundRouteEntry CreateEntry([DynamicallyAccessedMembers(Component)] Type pageType, string template, bool includeStaticRoutes) { - var templates = GetTemplates(pageType); + var templates = GetTemplates(pageType, includeStaticRoutes); var result = ComputeTemplateGroupInfo(templates); RoutePattern? parsedTemplate = null; diff --git a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs index e20aa7a19f4b..cd811a5debe2 100644 --- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs +++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs @@ -1107,7 +1107,7 @@ public RouteTable Build() { var templatesByHandler = _routeTemplates .GroupBy(rt => rt.Handler) - .ToDictionary(group => group.Key, group => group.Select(g => g.Template).ToArray()); + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(g => g.Template).ToArray()); return RouteTableFactory.Create(templatesByHandler, _serviceProvider); } catch (InvalidOperationException ex) when (ex.InnerException is InvalidOperationException) diff --git a/src/Components/Endpoints/src/Builder/ComponentTypeMetadata.cs b/src/Components/Endpoints/src/Builder/ComponentTypeMetadata.cs index 91136680625c..0bd659db2ae9 100644 --- a/src/Components/Endpoints/src/Builder/ComponentTypeMetadata.cs +++ b/src/Components/Endpoints/src/Builder/ComponentTypeMetadata.cs @@ -20,9 +20,25 @@ public ComponentTypeMetadata([DynamicallyAccessedMembers(Component)] Type compon Type = componentType; } + /// + /// Initializes a new instance of . + /// + /// The component type. + /// A flag indicating whether the page's route is declared as static. + public ComponentTypeMetadata([DynamicallyAccessedMembers(Component)] Type componentType, bool isStaticPage) + : this(componentType) + { + IsStaticRoute = isStaticPage; + } + /// /// Gets the component type. /// [DynamicallyAccessedMembers(Component)] public Type Type { get; } + + /// + /// Gets a flag indicating whether the page's route is declared as static. + /// + public bool IsStaticRoute { get; } } diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs index 8f79f6c191b8..c81d00f00725 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs @@ -32,7 +32,7 @@ internal void AddEndpoints( // Name is only relevant for Link generation, which we don't support either. var builder = new RouteEndpointBuilder( null, - RoutePatternFactory.Parse(pageDefinition.Route), + RoutePatternFactory.Parse(pageDefinition.Route.Template), order: 0); // Require antiforgery by default, let the page override it. @@ -47,7 +47,7 @@ internal void AddEndpoints( // We do not support link generation, so explicitly opt-out. builder.Metadata.Add(new SuppressLinkGenerationMetadata()); builder.Metadata.Add(HttpMethodsMetadata); - builder.Metadata.Add(new ComponentTypeMetadata(pageDefinition.Type)); + builder.Metadata.Add(new ComponentTypeMetadata(pageDefinition.Type, pageDefinition.Route.Static)); builder.Metadata.Add(new RootComponentMetadata(rootComponent)); builder.Metadata.Add(configuredRenderModesMetadata); diff --git a/src/Components/Endpoints/src/Discovery/IRazorComponentApplication.cs b/src/Components/Endpoints/src/Discovery/IRazorComponentApplication.cs index 16d2b0a1fa03..b1b99feb8e9d 100644 --- a/src/Components/Endpoints/src/Discovery/IRazorComponentApplication.cs +++ b/src/Components/Endpoints/src/Discovery/IRazorComponentApplication.cs @@ -33,7 +33,7 @@ static ComponentApplicationBuilder GetBuilderForAssembly(ComponentApplicationBui { pages.Add(new PageComponentBuilder() { - RouteTemplates = routes.Select(r => r.Template).ToList(), + RouteTemplates = routes.ToList(), AssemblyName = name, PageType = candidate }); diff --git a/src/Components/Endpoints/src/Discovery/PageComponentBuilder.cs b/src/Components/Endpoints/src/Discovery/PageComponentBuilder.cs index e803d6b332d4..a0886cec463b 100644 --- a/src/Components/Endpoints/src/Discovery/PageComponentBuilder.cs +++ b/src/Components/Endpoints/src/Discovery/PageComponentBuilder.cs @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.Components.Discovery; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] internal class PageComponentBuilder : IEquatable { - private IReadOnlyList _routeTemplates = Array.Empty(); + private IReadOnlyList _routeTemplates = Array.Empty(); /// /// Gets or sets the assembly name where this component comes from. @@ -24,7 +24,7 @@ internal class PageComponentBuilder : IEquatable /// /// Gets or sets the route templates for this page component. /// - public required IReadOnlyList RouteTemplates + public required IReadOnlyList RouteTemplates { get => _routeTemplates; set @@ -61,7 +61,7 @@ public bool Equals(PageComponentBuilder? other) { return other is not null && AssemblyName == other.AssemblyName && - RouteTemplates.SequenceEqual(other.RouteTemplates!, StringComparer.OrdinalIgnoreCase) && + RouteTemplates.SequenceEqual(other.RouteTemplates!, RouteAttributeComparer.Instance) && EqualityComparer.Default.Equals(PageType, other.PageType); } @@ -81,13 +81,33 @@ public override int GetHashCode() return hash.ToHashCode(); } - internal PageComponentInfo Build(string route, object[] pageMetadata) + internal PageComponentInfo Build(RouteAttribute route, object[] pageMetadata) { - return new PageComponentInfo(route, PageType, route, pageMetadata); + return new PageComponentInfo(route.Template, PageType, route, pageMetadata); } private string GetDebuggerDisplay() { - return $"Type = {PageType.FullName}, RouteTemplates = {string.Join(", ", RouteTemplates ?? Enumerable.Empty())}"; + return $"Type = {PageType.FullName}, RouteTemplates = {string.Join(", ", RouteTemplates?.Select(r => r.Template) ?? Enumerable.Empty())}"; + } + + private class RouteAttributeComparer : IEqualityComparer + { + public static RouteAttributeComparer Instance { get; } = new(); + + public bool Equals(RouteAttribute? x, RouteAttribute? y) + { + if (x is null) + { + return y is null; + } + + return y is not null + && string.Equals(x.Template, y.Template, StringComparison.OrdinalIgnoreCase) + && x.Static == y.Static; + } + + public int GetHashCode([DisallowNull] RouteAttribute value) + => HashCode.Combine(value.Template, value.Static); } } diff --git a/src/Components/Endpoints/src/Discovery/PageComponentInfo.cs b/src/Components/Endpoints/src/Discovery/PageComponentInfo.cs index 9a9467260ad8..eefb33d848e1 100644 --- a/src/Components/Endpoints/src/Discovery/PageComponentInfo.cs +++ b/src/Components/Endpoints/src/Discovery/PageComponentInfo.cs @@ -23,7 +23,7 @@ internal class PageComponentInfo internal PageComponentInfo( string displayName, [DynamicallyAccessedMembers(Component)] Type type, - string route, + RouteAttribute route, IReadOnlyList metadata) { DisplayName = displayName; @@ -46,7 +46,7 @@ internal PageComponentInfo( /// /// Gets the routes for the page. /// - public string Route { get; } + public RouteAttribute Route { get; } /// /// Gets the metadata for the page. diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..950f7ebb239b 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata.ComponentTypeMetadata(System.Type! componentType, bool isStaticPage) -> void +Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata.IsStaticRoute.get -> bool diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 02bf4e3a3467..e10ae89bdf98 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -46,8 +46,14 @@ private async Task RenderComponentCore(HttpContext context) var endpoint = context.GetEndpoint() ?? throw new InvalidOperationException($"An endpoint must be set on the '{nameof(HttpContext)}'."); + var componentTypeMetadata = endpoint.Metadata.GetRequiredMetadata(); var rootComponent = endpoint.Metadata.GetRequiredMetadata().Type; - var pageComponent = endpoint.Metadata.GetRequiredMetadata().Type; + var pageComponent = componentTypeMetadata.Type; + + if (componentTypeMetadata.IsStaticRoute) + { + _renderer.SuppressRootComponentRenderModes(); + } Log.BeginRenderRootComponent(_logger, rootComponent.Name, pageComponent.Name); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 326a1ed249e8..b37456503c6d 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -15,30 +15,38 @@ internal partial class EndpointHtmlRenderer { private static readonly object ComponentSequenceKey = new object(); - protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) + protected override IComponentRenderMode? ResolveEffectiveRenderMode(Type componentType, int? parentComponentId, IComponentRenderMode? componentTypeRenderMode, IComponentRenderMode? callerSpecifiedRenderMode) { if (_isHandlingErrors) { // Ignore the render mode boundary in error scenarios. - return componentActivator.CreateInstance(componentType); + return null; } - var closestRenderModeBoundary = parentComponentId.HasValue - ? GetClosestRenderModeBoundary(parentComponentId.Value) - : null; - if (closestRenderModeBoundary is not null) + // If we're inside a subtree with a rendermode, we don't need to emit another rendermode boundary. + // Once the subtree becomes interactive, the entire DOM subtree will get replaced anyway. + if (parentComponentId.HasValue && GetClosestRenderModeBoundary(parentComponentId.Value) is not null) { - // We're already inside a subtree with a rendermode. Once it becomes interactive, the entire DOM subtree - // will get replaced anyway. So there is no point emitting further rendermode boundaries. - return componentActivator.CreateInstance(componentType); + return null; } - else + + if (_suppressRootComponentRenderModes && IsRootComponent(parentComponentId)) { - // This component is the start of a subtree with a rendermode, so introduce a new rendermode boundary here - return new SSRRenderModeBoundary(_httpContext, componentType, renderMode); + // Disregard the callsite rendermode because this is in the root component (or is the root component itelf) + // and we've been configured to suppress render modes for the root component. + callerSpecifiedRenderMode = null; } + + // This component is the start of a subtree with a rendermode, so introduce a new rendermode boundary here + return base.ResolveEffectiveRenderMode(componentType, parentComponentId, componentTypeRenderMode, callerSpecifiedRenderMode); + + bool IsRootComponent(int? componentId) + => !componentId.HasValue || GetComponentState(componentId.Value).ParentComponentState is null; } + protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) + => new SSRRenderModeBoundary(_httpContext, componentType, renderMode); + protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) { var componentState = GetComponentState(component); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index c32c602cfa2e..1cf210909d89 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -41,6 +41,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer private readonly RazorComponentsServiceOptions _options; private Task? _servicesInitializedTask; private HttpContext _httpContext = default!; // Always set at the start of an inbound call + private bool _suppressRootComponentRenderModes; // The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e., // when everything (regardless of streaming SSR) is fully complete. In this subclass we also track @@ -69,6 +70,11 @@ private void SetHttpContext(HttpContext httpContext) } } + public void SuppressRootComponentRenderModes() + { + _suppressRootComponentRenderModes = true; + } + internal static async Task InitializeStandardComponentServicesAsync( HttpContext httpContext, [DynamicallyAccessedMembers(Component)] Type? componentType = null, diff --git a/src/Components/Endpoints/test/Discovery/ComponentApplicationBuilderTests.cs b/src/Components/Endpoints/test/Discovery/ComponentApplicationBuilderTests.cs index 4017736911e1..aea5465e9a5e 100644 --- a/src/Components/Endpoints/test/Discovery/ComponentApplicationBuilderTests.cs +++ b/src/Components/Endpoints/test/Discovery/ComponentApplicationBuilderTests.cs @@ -24,10 +24,11 @@ public void ComponentApplicationBuilder_CanAddLibrary() p => Assert.Equal(typeof(App1Test2), p.Type), p => Assert.Equal(typeof(App1Test3), p.Type)); - Assert.Collection(app.Pages.Select(p => p.Route), - r => Assert.Equal("/App1/Test1", r), - r => Assert.Equal("/App1/Test2", r), - r => Assert.Equal("/App1/Test3", r)); + Assert.Equal(app.Pages.Select(p => p.Route.Template), + ["/App1/Test1", "/App1/Test2", "/App1/Test3"]); + + Assert.Equal(app.Pages.Select(p => p.Route.Static), + [false, true, false]); Assert.Collection(app.Components, c => Assert.Equal(typeof(App1Test1), c.ComponentType), @@ -295,19 +296,19 @@ private IReadOnlyList CreateApp1Pages(string assemblyName) { AssemblyName = assemblyName, PageType = typeof(App1Test1), - RouteTemplates = new List { "/App1/Test1" } + RouteTemplates = [new ("/App1/Test1")] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(App1Test2), - RouteTemplates = new List { "/App1/Test2" } + RouteTemplates = [new ("/App1/Test2") { Static = true }] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(App1Test3), - RouteTemplates = new List { "/App1/Test3" } + RouteTemplates = [new ("/App1/Test3")] } }; } @@ -347,19 +348,19 @@ private IReadOnlyList CreateApp2Pages(string assemblyName) { AssemblyName = assemblyName, PageType = typeof(App2Test1), - RouteTemplates = new List { "/App2/Test1" } + RouteTemplates = [new ("/App2/Test1")] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(App2Test2), - RouteTemplates = new List { "/App2/Test2" } + RouteTemplates = [new ("/App2/Test2")] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(App2Test3), - RouteTemplates = new List { "/App2/Test3" } + RouteTemplates = [new ("/App2/Test3")] } }; } @@ -399,19 +400,19 @@ private IReadOnlyList CreateSharedPages(string assemblyNam { AssemblyName = assemblyName, PageType = typeof(SharedTest1), - RouteTemplates = new List { "/Shared/Test1" } + RouteTemplates = [new ("/Shared/Test1")] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(SharedTest2), - RouteTemplates = new List { "/Shared/Test2" } + RouteTemplates = [new ("/Shared/Test2")] }, new PageComponentBuilder { AssemblyName = assemblyName, PageType = typeof(SharedTest3), - RouteTemplates = new List { "/Shared/Test3" } + RouteTemplates = [new ("/Shared/Test3")] }, }; } diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index 4370d5bdedd2..96d39d2f76e8 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -1,12 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Net.Http; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Components.Endpoints.Forms; using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents; +using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents.StaticPages; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Forms.Mapping; using Microsoft.AspNetCore.Components.Infrastructure; @@ -33,7 +33,8 @@ namespace Microsoft.AspNetCore.Components.Endpoints; public class EndpointHtmlRendererTest { private const string MarkerPrefix = "(?.+?)$"; + private const string PrerenderedComponentPattern = "(?.+?)"; + private const string SingleLinePrerenderedComponentPattern = $"^{PrerenderedComponentPattern}$"; private const string ComponentPattern = "^$"; private static readonly IDataProtectionProvider _dataprotectorProvider = new EphemeralDataProtectionProvider(); @@ -80,7 +81,7 @@ public async Task CanPrerender_ParameterlessComponent_ClientMode() var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), RenderMode.InteractiveWebAssembly, ParameterView.Empty); await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default)); var content = writer.ToString(); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -195,7 +196,7 @@ public async Task CanPrerender_ComponentWithParameters_ClientMode() })); await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default)); var content = writer.ToString(); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -244,7 +245,7 @@ public async Task CanPrerender_ComponentWithNullParameters_ClientMode() })); await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default)); var content = writer.ToString(); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -335,7 +336,7 @@ public async Task CanPrerender_ParameterlessComponent_ServerMode() // Act var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), RenderMode.InteractiveServer, ParameterView.Empty); var content = await renderer.Dispatcher.InvokeAsync(() => HtmlContentToString(result)); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -396,7 +397,7 @@ public async Task CanRenderMultipleServerComponents() // Act var firstResult = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveServerRenderMode(true), ParameterView.Empty); var firstComponent = await renderer.Dispatcher.InvokeAsync(() => HtmlContentToString(firstResult)); - var firstMatch = Regex.Match(firstComponent, PrerenderedComponentPattern, RegexOptions.Multiline); + var firstMatch = Regex.Match(firstComponent, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); var secondResult = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveServerRenderMode(false), ParameterView.Empty); var secondComponent = await renderer.Dispatcher.InvokeAsync(() => HtmlContentToString(secondResult)); @@ -532,7 +533,7 @@ public async Task CanPrerender_ComponentWithParameters_ServerPrerenderedMode() var parameters = ParameterView.FromDictionary(new Dictionary { { "Name", "SomeName" } }); var result = await renderer.PrerenderComponentAsync(httpContext, typeof(GreetingComponent), RenderMode.InteractiveServer, parameters); var content = await renderer.Dispatcher.InvokeAsync(() => HtmlContentToString(result)); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -583,7 +584,7 @@ public async Task CanPrerender_ComponentWithNullParameters_ServerPrerenderedMode var parameters = ParameterView.FromDictionary(new Dictionary { { "Name", null } }); var result = await renderer.PrerenderComponentAsync(httpContext, typeof(GreetingComponent), RenderMode.InteractiveServer, parameters); var content = await renderer.Dispatcher.InvokeAsync(() => HtmlContentToString(result)); - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Multiline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Multiline); // Assert Assert.True(match.Success); @@ -1061,9 +1062,9 @@ public async Task RenderMode_CanRenderInteractiveComponents() // Assert var lines = content.Replace("\r\n", "\n").Split('\n'); - var serverMarkerMatch = Regex.Match(lines[0], PrerenderedComponentPattern); + var serverMarkerMatch = Regex.Match(lines[0], SingleLinePrerenderedComponentPattern); var serverNonPrerenderedMarkerMatch = Regex.Match(lines[1], ComponentPattern); - var webAssemblyMarkerMatch = Regex.Match(lines[2], PrerenderedComponentPattern); + var webAssemblyMarkerMatch = Regex.Match(lines[2], SingleLinePrerenderedComponentPattern); var webAssemblyNonPrerenderedMarkerMatch = Regex.Match(lines[3], ComponentPattern); // Server @@ -1167,7 +1168,7 @@ public async Task DoesNotEmitNestedRenderModeBoundaries() var numMarkers = Regex.Matches(content, MarkerPrefix).Count; Assert.Equal(2, numMarkers); // A start and an end marker - var match = Regex.Match(content, PrerenderedComponentPattern, RegexOptions.Singleline); + var match = Regex.Match(content, SingleLinePrerenderedComponentPattern, RegexOptions.Singleline); Assert.True(match.Success); var preamble = match.Groups["preamble"].Value; var preambleMarker = JsonSerializer.Deserialize(preamble, ServerComponentSerializationSettings.JsonSerializationOptions); @@ -1178,6 +1179,59 @@ public async Task DoesNotEmitNestedRenderModeBoundaries() Assert.Equal("

This is InteractiveWithInteractiveChild

\n\n

Hello from InteractiveGreetingServer!

", prerenderedContent.Replace("\r\n", "\n")); } + [Fact] + public async Task CanSuppressCallSiteRenderModesInRootComponent() + { + // Arrange + var httpContext = GetHttpContext(); + var writer = new StringWriter(); + renderer.SuppressRootComponentRenderModes(); + + // Act + var result = await renderer.PrerenderComponentAsync(httpContext, typeof(StaticPagesRoot), + null, + ParameterView.Empty); + await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default)); + var content = writer.ToString(); + + // Assert + var numMarkers = Regex.Matches(content, MarkerPrefix).Count; + Assert.Equal(4, numMarkers); // A start and an end marker for each of two interactive components + + var matches = Regex.Matches(content, PrerenderedComponentPattern, RegexOptions.Singleline); + Assert.Collection(matches, + match => + { + // This match will be the grandchild + Assert.True(match.Success); + var preamble = match.Groups["preamble"].Value; + var preambleMarker = JsonSerializer.Deserialize(preamble, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.NotNull(preambleMarker.PrerenderId); + Assert.Equal("server", preambleMarker.Type); + + // The key thing we're observing here is that *only* the grandchild has interactivity markers. + // The root component and the child component are both static, even though the root component sets + // @rendermode=InteractiveServer on the child. This is suppressed by SuppressRootComponentRenderModes. + var prerenderedContent = match.Groups["content"].Value; + Assert.Equal("[Grandchild: Hello!]", prerenderedContent.ReplaceLineEndings(string.Empty)); + }, + match => + { + // This match will be the top-level InteractiveGreetingWebAssembly + // The fact that interactivity is *not* suppressed here shows that SuppressRootComponentRenderModes + // only suppresses *call-site* rendermodes in the root. It does not stop a child from being interactive + // if it has a @rendermode on the child component type itself. + Assert.True(match.Success); + var preamble = match.Groups["preamble"].Value; + var preambleMarker = JsonSerializer.Deserialize(preamble, ServerComponentSerializationSettings.JsonSerializationOptions); + Assert.NotNull(preambleMarker.PrerenderId); + Assert.Equal("webassembly", preambleMarker.Type); + + var prerenderedContent = match.Groups["content"].Value; + Assert.Equal("

Hello InteractiveGreetingWebAssembly!

", prerenderedContent.ReplaceLineEndings(string.Empty)); + }); + } + [Fact] public async Task PrerenderedState_EmptyWhenNoDeclaredRenderModes() { diff --git a/src/Components/Endpoints/test/HotReloadServiceTests.cs b/src/Components/Endpoints/test/HotReloadServiceTests.cs index a492c36e39b0..65562f4e346d 100644 --- a/src/Components/Endpoints/test/HotReloadServiceTests.cs +++ b/src/Components/Endpoints/test/HotReloadServiceTests.cs @@ -53,7 +53,7 @@ public void AddNewEndpointWhenDataSourceChanges() { AssemblyName = "TestAssembly2", PageType = typeof(StaticComponent), - RouteTemplates = new List { "/app/test" } + RouteTemplates = [new ("/app/test")] } }); HotReloadService.UpdateApplication(null); diff --git a/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs b/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs index a7dbd751bf25..9d82c0a8f969 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs +++ b/src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs @@ -11,8 +11,10 @@ namespace Microsoft.AspNetCore.Components.Endpoints; public class RazorComponentEndpointFactoryTest { - [Fact] - public void AddEndpoints_CreatesEndpointWithExpectedMetadata() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddEndpoints_CreatesEndpointWithExpectedMetadata(bool staticRoute) { var endpoints = new List(); var factory = new RazorComponentEndpointFactory(); @@ -23,7 +25,7 @@ public void AddEndpoints_CreatesEndpointWithExpectedMetadata() factory.AddEndpoints(endpoints, typeof(App), new PageComponentInfo( "App", typeof(App), - "/", + new ("/") { Static = staticRoute }, new object[] { new AuthorizeAttribute() }), conventions, finallyConventions, @@ -35,7 +37,7 @@ public void AddEndpoints_CreatesEndpointWithExpectedMetadata() Assert.Equal(0, routeEndpoint.Order); Assert.Equal("/", routeEndpoint.RoutePattern.RawText); Assert.Contains(endpoint.Metadata, m => m is RootComponentMetadata); - Assert.Contains(endpoint.Metadata, m => m is ComponentTypeMetadata); + Assert.Contains(endpoint.Metadata, m => m is ComponentTypeMetadata c && c.Type == typeof(App) && c.IsStaticRoute == staticRoute); 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 @@ -65,7 +67,7 @@ public void AddEndpoints_RunsConventions() new PageComponentInfo( "App", typeof(App), - "/", + new ("/"), Array.Empty()), conventions, finallyConventions, @@ -93,7 +95,7 @@ public void AddEndpoints_RunsFinallyConventions() new PageComponentInfo( "App", typeof(App), - "/", + new ("/"), Array.Empty()), conventions, finallyConventions, @@ -121,7 +123,7 @@ public void AddEndpoints_RouteOrderCanNotBeChanged() new PageComponentInfo( "App", typeof(App), - "/", + new ("/"), Array.Empty()), conventions, finallyConventions, @@ -153,7 +155,7 @@ public void AddEndpoints_RunsFinallyConventionsAfterRegularConventions() new PageComponentInfo( "App", typeof(App), - "/", + new ("/"), Array.Empty()), conventions, finallyConventions, diff --git a/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesChild.razor b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesChild.razor new file mode 100644 index 000000000000..4c6f2981c164 --- /dev/null +++ b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesChild.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Web +[Child: ] +@code { + [Parameter] public string Name { get; set; } +} diff --git a/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesGrandchild.razor b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesGrandchild.razor new file mode 100644 index 000000000000..a87c5d4c2798 --- /dev/null +++ b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesGrandchild.razor @@ -0,0 +1,5 @@ +[Grandchild: @Name] + +@code { + [Parameter] public string Name { get; set; } +} diff --git a/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesRoot.razor b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesRoot.razor new file mode 100644 index 000000000000..9950db837144 --- /dev/null +++ b/src/Components/Endpoints/test/TestComponents/StaticPages/StaticPagesRoot.razor @@ -0,0 +1,9 @@ +@using Microsoft.AspNetCore.Components.Web + +Despite declaring the following as interactive, it will render statically because root interactivity is suppressed. + +[Root: ] + +The following component will be interactive because it has @@rendermode declared on the component type itself. + + diff --git a/src/Components/Samples/BlazorUnitedApp/App.razor b/src/Components/Samples/BlazorUnitedApp/App.razor index 11fbf7c92ab7..e9327fb3cbc2 100644 --- a/src/Components/Samples/BlazorUnitedApp/App.razor +++ b/src/Components/Samples/BlazorUnitedApp/App.razor @@ -9,33 +9,10 @@ - + - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
- -
- - An error has occurred. This application may no longer respond until reloaded. - - - An unhandled exception has occurred. See browser dev tools for details. - - Reload - 🗙 -
- + diff --git a/src/Components/Samples/BlazorUnitedApp/Pages/Counter.razor b/src/Components/Samples/BlazorUnitedApp/Pages/Counter.razor index de653ae1773b..0d2acac011ab 100644 --- a/src/Components/Samples/BlazorUnitedApp/Pages/Counter.razor +++ b/src/Components/Samples/BlazorUnitedApp/Pages/Counter.razor @@ -1,4 +1,5 @@ @page "/counter" +@rendermode InteractiveServer Counter

Counter

diff --git a/src/Components/Samples/BlazorUnitedApp/Program.cs b/src/Components/Samples/BlazorUnitedApp/Program.cs index 3739cf4a31ac..3f3fc0bcbedb 100644 --- a/src/Components/Samples/BlazorUnitedApp/Program.cs +++ b/src/Components/Samples/BlazorUnitedApp/Program.cs @@ -7,7 +7,8 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddRazorComponents(); +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); builder.Services.AddSingleton(); @@ -26,6 +27,7 @@ app.UseStaticFiles(); app.UseAntiforgery(); -app.MapRazorComponents(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); app.Run(); diff --git a/src/Components/Samples/BlazorUnitedApp/Routes.razor b/src/Components/Samples/BlazorUnitedApp/Routes.razor new file mode 100644 index 000000000000..6fd3ed1b5a3b --- /dev/null +++ b/src/Components/Samples/BlazorUnitedApp/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor b/src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor index 9b5041643514..40cfc1bb234a 100644 --- a/src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor +++ b/src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor @@ -13,3 +13,9 @@ + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/src/Components/Samples/BlazorUnitedApp/_Imports.razor b/src/Components/Samples/BlazorUnitedApp/_Imports.razor index f4bc6414aac3..1be7a7e9a5bf 100644 --- a/src/Components/Samples/BlazorUnitedApp/_Imports.razor +++ b/src/Components/Samples/BlazorUnitedApp/_Imports.razor @@ -7,3 +7,4 @@ @using Microsoft.JSInterop @using BlazorUnitedApp @using BlazorUnitedApp.Shared +@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index edd2f0bc3656..b6b8f8cf0dd4 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -17,12 +17,85 @@ 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); + + // TODO: Debug this. It would fail because the page is still in interactive mode. + // This problem is specific to the "override rendermode via URL in App.razor" technique + // and doesn't occur with the new proper static page mechanism. Need to understand if this + // is a real problem for .NET 8 apps and that https://github.com/dotnet/aspnetcore/issues/54574 + // isn't really fixed. However I can't repro 54574 even when running a plain .NET 8 app created + // outside this repo, so it's unclear. + // Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text); + } + + [Fact] + public void CanNavigateFromStaticToInteractiveAndBack() + { + // Start on a static page + Navigate("/subdir/globally-interactive/static-via-route"); + Browser.Equal("Global interactivity page: Static via route", () => 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); + + // Show that, after "back", we revert to static rendering on the previous page + Browser.Navigate().Back(); + Browser.Equal("Global interactivity page: Static via route", () => 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 route")); + Browser.Equal("Global interactivity page: Static via route", () => 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); + } - var heading = Browser.Exists(By.TagName("h1")); - Browser.Equal("Statically Rendered", () => heading.Text); + [Fact] + public void CanNavigateBetweenStaticPagesViaEnhancedNav() + { + // Start on a static page + Navigate("/subdir/globally-interactive/static-via-route"); + Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text); + var h1 = Browser.Exists(By.TagName("h1")); + Assert.Equal("Global interactivity page: Static via route", 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 route", () => h1.Text); + Browser.Equal("static", () => Browser.Exists(By.Id("execution-mode")).Text); } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/GlobalInteractivityApp.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/GlobalInteractivityApp.razor index 0682bca04818..4b6049e22498 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/GlobalInteractivityApp.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/GlobalInteractivityApp.razor @@ -1,13 +1,10 @@  - - - + - @@ -19,16 +16,15 @@ }); - - @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") + // Statically render a specific page to show we can override the rendermode based on URL + // Elsewhere, use global interactivity. + private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/globally-interactive/static-via-url") ? null - : RenderMode.InteractiveAuto; + : RenderMode.InteractiveWebAssembly; } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Static.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Static.razor deleted file mode 100644 index dd3c7cc235a1..000000000000 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Static.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/static" - -@* This should be statically rendered by GlobalInteractivityApp. *@ -

Statically Rendered

- -
    -
  • Dynamic page
  • -
diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/Dynamic.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/Dynamic.razor deleted file mode 100644 index 8678edcb5b34..000000000000 --- a/src/Components/test/testassets/Components.WasmMinimal/Pages/Dynamic.razor +++ /dev/null @@ -1,13 +0,0 @@ -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web - -@rendermode RenderMode.InteractiveWebAssembly - -@page "/dynamic" - -

Dynamically Rendered

- -
    -
  • Static page
  • -
  • Another dynamic page
  • -
diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Default.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Default.razor new file mode 100644 index 000000000000..43dafd2cc6a3 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Default.razor @@ -0,0 +1,12 @@ +@page "/globally-interactive" + +

Global interactivity page: Default

+ +

+ This page should be rendered interactively by GlobalInteractivityApp because + that's the default rendermode for the application. +

+ + + + diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Links.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Links.razor new file mode 100644 index 000000000000..7897b6da4156 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_Links.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components.Routing +
    +
  • Globally-interactive by default
  • +
  • Static via URL
  • +
  • Static via route
  • +
diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaRoute.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaRoute.razor new file mode 100644 index 000000000000..5af0edc279ac --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaRoute.razor @@ -0,0 +1,12 @@ +@attribute [Route("/globally-interactive/static-via-route", Static = true)] + +

Global interactivity page: Static via route

+ +

+ This page should be rendered statically by GlobalInteractivityApp because its + RouteAttribute is declared with Static=true. +

+ + + + diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaUrl.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaUrl.razor new file mode 100644 index 000000000000..093479f98798 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/GloballyInteractive_StaticViaUrl.razor @@ -0,0 +1,19 @@ +@page "/globally-interactive/static-via-url" + +

Global interactivity page: Static via URL

+ +

+ This page should be rendered statically by GlobalInteractivityApp because the root component + overrides the rendermode for this URL. This is equivalent to how the .NET 8 auth-enabled + template overrides global interactivity for the "/account" subdir. +

+ +

+ Caution: if you navigate here via a link from an interactive page, it will actually render + interactively because the router doesn't know to exit from interactive mode. That's one of + the things fixed by marking a page static via its route. +

+ + + + diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/ShowExecutionMode.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/ShowExecutionMode.razor new file mode 100644 index 000000000000..5b44f30020c7 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/ShowExecutionMode.razor @@ -0,0 +1,18 @@ +

+ Actual execution mode: + @executionMode +

+ +@code { + string executionMode = "static"; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + executionMode = "interactive "; + executionMode += OperatingSystem.IsBrowser() ? "webassembly" : "server"; + StateHasChanged(); + } + } +}