diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index d721133dd02d..25d6da65b94e 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -54,6 +54,8 @@ public event EventHandler OnNotFound private EventHandler? _notFound; + private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs(); + // For the baseUri it's worth storing as a System.Uri so we can do operations // on that type. System.Uri gives us access to the original string anyway. private Uri? _baseUri; @@ -203,7 +205,15 @@ public virtual void Refresh(bool forceReload = false) private void NotFoundCore() { - _notFound?.Invoke(this, new NotFoundEventArgs()); + if (_notFound == null) + { + // global router doesn't exist, no events were registered + return; + } + else + { + _notFound.Invoke(this, _notFoundEventArgs); + } } /// diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 21c0226e2ef5..b99e9fd7f216 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +1,7 @@ #nullable enable + +Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type! +Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler! Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func! onNavigateTo) -> void diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 7eabfa828aa4..d562b94bb639 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -3,11 +3,13 @@ #nullable disable warnings +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Reflection.Metadata; using System.Runtime.ExceptionServices; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; @@ -70,6 +72,13 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary [Parameter] public RenderFragment NotFound { get; set; } + /// + /// Gets or sets the page content to display when no match is found for the requested route. + /// + [Parameter] + [DynamicallyAccessedMembers(LinkerFlags.Component)] + public Type NotFoundPage { get; set; } = default!; + /// /// Gets or sets the content to display when a match is found for the requested route. /// @@ -132,6 +141,22 @@ public async Task SetParametersAsync(ParameterView parameters) throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(Found)}."); } + if (NotFoundPage != null) + { + if (!typeof(IComponent).IsAssignableFrom(NotFoundPage)) + { + throw new InvalidOperationException($"The type {NotFoundPage.FullName} " + + $"does not implement {typeof(IComponent).FullName}."); + } + + var routeAttributes = NotFoundPage.GetCustomAttributes(typeof(RouteAttribute), inherit: true); + if (routeAttributes.Length == 0) + { + throw new InvalidOperationException($"The type {NotFoundPage.FullName} " + + $"does not have a {typeof(RouteAttribute).FullName} applied to it."); + } + } + if (!_onNavigateCalled) { _onNavigateCalled = true; @@ -327,7 +352,22 @@ private void OnNotFound(object sender, EventArgs args) if (_renderHandle.IsInitialized) { Log.DisplayingNotFound(_logger); - _renderHandle.Render(NotFound ?? DefaultNotFoundContent); + _renderHandle.Render(builder => + { + if (NotFoundPage != null) + { + builder.OpenComponent(0, NotFoundPage); + builder.CloseComponent(); + } + else if (NotFound != null) + { + NotFound(builder); + } + else + { + DefaultNotFoundContent(builder); + } + }); } } diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj index 5362350bb1d7..4fa9814ea77e 100644 --- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj +++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 7638eda6163b..245f811d7f76 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -39,12 +39,18 @@ private async Task RenderComponentCore(HttpContext context) { context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; var isErrorHandler = context.Features.Get() is not null; + var hasStatusCodePage = context.Features.Get() is not null; + var isReExecuted = context.Features.Get() is not null; if (isErrorHandler) { Log.InteractivityDisabledForErrorHandling(_logger); } - _renderer.InitializeStreamingRenderingFraming(context, isErrorHandler); - EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context); + _renderer.InitializeStreamingRenderingFraming(context, isErrorHandler, isReExecuted); + if (!isReExecuted) + { + // re-executed pages have Headers already set up + EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context); + } var endpoint = context.GetEndpoint() ?? throw new InvalidOperationException($"An endpoint must be set on the '{nameof(HttpContext)}'."); @@ -85,6 +91,8 @@ await _renderer.InitializeStandardComponentServicesAsync( await using var writer = new HttpResponseStreamWriter(context.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); using var bufferWriter = new BufferedTextWriter(writer); + bool isErrorHandlerOrReExecuted = isErrorHandler || isReExecuted; + // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host // component takes care of switching into your desired render mode when it produces its own output. @@ -92,7 +100,14 @@ await _renderer.InitializeStandardComponentServicesAsync( context, rootComponent, ParameterView.Empty, - waitForQuiescence: result.IsPost || isErrorHandler); + waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted); + + bool avoidStartingResponse = hasStatusCodePage && !isReExecuted && context.Response.StatusCode == StatusCodes.Status404NotFound; + if (avoidStartingResponse) + { + // the request is going to be re-executed, we should avoid writing to the response + return; + } Task quiesceTask; if (!result.IsPost) @@ -145,7 +160,7 @@ await _renderer.InitializeStandardComponentServicesAsync( } // Emit comment containing state. - if (!isErrorHandler) + if (!isErrorHandlerOrReExecuted) { var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context); componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default); @@ -160,10 +175,11 @@ await _renderer.InitializeStandardComponentServicesAsync( private async Task ValidateRequestAsync(HttpContext context, IAntiforgery? antiforgery) { var processPost = HttpMethods.IsPost(context.Request.Method) && - // Disable POST functionality during exception handling. + // Disable POST functionality during exception handling and reexecution. // The exception handler middleware will not update the request method, and we don't // want to run the form handling logic against the error page. - context.Features.Get() == null; + context.Features.Get() == null && + context.Features.Get() == null; if (processPost) { diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index e30852703324..8a1062a58d76 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -77,14 +77,27 @@ private Task ReturnErrorResponse(string detailedMessage) : Task.CompletedTask; } - private void SetNotFoundResponse(object? sender, EventArgs args) + private async Task SetNotFoundResponseAsync(string baseUri) { if (_httpContext.Response.HasStarted) { - throw new InvalidOperationException("Cannot set a NotFound response after the response has already started."); + var defaultBufferSize = 16 * 1024; + await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + using var bufferWriter = new BufferedTextWriter(writer); + var notFoundUri = $"{baseUri}not-found"; + HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, notFoundUri); + await bufferWriter.FlushAsync(); } - _httpContext.Response.StatusCode = StatusCodes.Status404NotFound; - SignalRendererToFinishRendering(); + else + { + _httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + _httpContext.Response.ContentType = null; + } + + // When the application triggers a NotFound event, we continue rendering the current batch. + // However, after completing this batch, we do not want to process any further UI updates, + // as we are going to return a 404 status and discard the UI updates generated so far. + SignalRendererToFinishRenderingAfterCurrentBatch(); } private async Task OnNavigateTo(string uri) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 66ff3dfd9587..a7bdea9a75d2 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Components.Web.HtmlRendering; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -18,7 +19,7 @@ internal partial class EndpointHtmlRenderer protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) { - if (_isHandlingErrors) + if (_isHandlingErrors || _isReExecuted) { // Ignore the render mode boundary in error scenarios. return componentActivator.CreateInstance(componentType); @@ -166,7 +167,50 @@ private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedCompone } else if (_nonStreamingPendingTasks.Count > 0) { - await WaitForNonStreamingPendingTasks(); + if (_isReExecuted) + { + HandleNonStreamingTasks(); + } + else + { + await WaitForNonStreamingPendingTasks(); + } + } + } + + public void HandleNonStreamingTasks() + { + if (NonStreamingPendingTasksCompletion == null) + { + foreach (var task in _nonStreamingPendingTasks) + { + _ = GetErrorHandledTask(task); + } + + // Clear the pending tasks since we are handling them + _nonStreamingPendingTasks.Clear(); + + NonStreamingPendingTasksCompletion = Task.CompletedTask; + } + } + + private async Task GetErrorHandledTask(Task taskToHandle) + { + try + { + await taskToHandle; + } + catch (Exception ex) + { + // Ignore errors due to task cancellations. + if (!taskToHandle.IsCanceled) + { + _logger.LogError( + ex, + "An exception occurred during non-streaming rendering. " + + "This exception will be ignored because the response " + + "is being discarded and the request is being re-executed."); + } } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index 79239c6d6db2..c17f7cd53555 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -23,11 +23,13 @@ internal partial class EndpointHtmlRenderer private HashSet? _visitedComponentIdsInCurrentStreamingBatch; private string? _ssrFramingCommentMarkup; private bool _isHandlingErrors; + private bool _isReExecuted; - public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler) + public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler, bool isReExecuted) { _isHandlingErrors = isErrorHandler; - if (IsProgressivelyEnhancedNavigation(httpContext.Request)) + _isReExecuted = isReExecuted; + if (!isReExecuted && IsProgressivelyEnhancedNavigation(httpContext.Request)) { var id = Guid.NewGuid().ToString(); httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id); diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 1bc4c40ce0a4..e99574aa881e 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -44,6 +44,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer private HttpContext _httpContext = default!; // Always set at the start of an inbound call private ResourceAssetCollection? _resourceCollection; private bool _rendererIsStopped; + private readonly ILogger _logger; // 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 @@ -56,6 +57,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log { _services = serviceProvider; _options = serviceProvider.GetRequiredService>().Value; + _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); } internal HttpContext? HttpContext => _httpContext; @@ -83,7 +85,7 @@ internal async Task InitializeStandardComponentServicesAsync( if (navigationManager != null) { - navigationManager.OnNotFound += SetNotFoundResponse; + navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri); } var authenticationStateProvider = httpContext.RequestServices.GetService(); @@ -163,6 +165,11 @@ protected override ComponentState CreateComponentState(int componentId, ICompone protected override void AddPendingTask(ComponentState? componentState, Task task) { + if (_isReExecuted) + { + return; + } + var streamRendering = componentState is null ? false : ((EndpointComponentState)componentState).StreamRendering; @@ -176,12 +183,28 @@ protected override void AddPendingTask(ComponentState? componentState, Task task base.AddPendingTask(componentState, task); } - protected override void SignalRendererToFinishRendering() + private void SignalRendererToFinishRenderingAfterCurrentBatch() { + // sets a deferred stop on the renderer, which will have an effect after the current batch is completed _rendererIsStopped = true; + } + + protected override void SignalRendererToFinishRendering() + { + SignalRendererToFinishRenderingAfterCurrentBatch(); + // sets a hard stop on the renderer, which will have an effect immediately base.SignalRendererToFinishRendering(); } + protected override void ProcessPendingRender() + { + if (_rendererIsStopped) + { + return; + } + base.ProcessPendingRender(); + } + // For tests only internal Task? NonStreamingPendingTasksCompletion; diff --git a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs index 8f22818c7a27..3f092c15626a 100644 --- a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs +++ b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs @@ -48,7 +48,8 @@ private static Task RenderComponentToResponse( return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => { var isErrorHandler = httpContext.Features.Get() is not null; - endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler); + var isReExecuted = httpContext.Features.Get() is not null; + endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler, isReExecuted); EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext); // We could pool these dictionary instances if we wanted, and possibly even the ParameterView diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 5ad9be45f057..139f3db4726e 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -3,6 +3,7 @@ using System.Net.Http; using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.E2ETesting; @@ -78,4 +79,44 @@ public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowBy Browser.Click(By.Id("redirectButton")); Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); } + + [Fact] + public void CanRenderNotFoundPageAfterStreamingStarted() + { + Navigate($"{ServerPathBase}/streaming-set-not-found"); + Browser.Equal("Default Not Found Page", () => Browser.Title); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanRenderNotFoundPageNoStreaming(bool useCustomNotFoundPage) + { + string query = useCustomNotFoundPage ? "&useCustomNotFoundPage=true" : ""; + Navigate($"{ServerPathBase}/set-not-found?shouldSet=true{query}"); + + if (useCustomNotFoundPage) + { + var infoText = Browser.FindElement(By.Id("test-info")).Text; + Assert.Contains("Welcome On Custom Not Found Page", infoText); + } + else + { + var bodyText = Browser.FindElement(By.TagName("body")).Text; + Assert.Contains("There's nothing here", bodyText); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanRenderNotFoundPageWithStreaming(bool useCustomNotFoundPage) + { + // when streaming started, we always render page under "not-found" path + string query = useCustomNotFoundPage ? "?useCustomNotFoundPage=true" : ""; + Navigate($"{ServerPathBase}/streaming-set-not-found{query}"); + + string expectedTitle = "Default Not Found Page"; + Browser.Equal(expectedTitle, () => Browser.Title); + } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs new file mode 100644 index 000000000000..58ac90b39bbe --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/StatusCodePagesTest.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Components.TestServer.RazorComponents; +using Components.TestServer.RazorComponents.Pages.StreamingRendering; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; + +public class StatusCodePagesTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture> serverFixture, ITestOutputHelper output) + : ServerTestBase>>(browserFixture, serverFixture, output) +{ + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void StatusCodePagesWithReExecution(bool streaming, bool responseStarted) + { + string streamingPath = streaming ? "streaming-" : ""; + Navigate($"{ServerPathBase}/reexecution/{streamingPath}set-not-found?responseStarted={responseStarted}"); + + // streaming when response started does not re-execute + string expectedTitle = responseStarted + ? "Default Not Found Page" + : "Re-executed page"; + Browser.Equal(expectedTitle, () => Browser.Title); + } +} diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index 371bab218061..5f91c4b684f7 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -22,18 +22,29 @@ public class GlobalInteractivityTest( { [Theory] - [InlineData("server")] - [InlineData("webassembly")] - public void CanRenderNotFoundInteractive(string renderingMode) + [InlineData("server", false)] + [InlineData("webassembly", false)] + [InlineData("server", true)] + [InlineData("webassembly", true)] + public void CanRenderNotFoundInteractive(string renderingMode, bool useCustomNotFoundPage) { - Navigate($"/subdir/render-not-found-{renderingMode}"); + string query = useCustomNotFoundPage ? "?useCustomNotFoundPage=true" : ""; + Navigate($"{ServerPathBase}/render-not-found-{renderingMode}{query}"); var buttonId = "trigger-not-found"; Browser.WaitForElementToBeVisible(By.Id(buttonId)); Browser.Exists(By.Id(buttonId)).Click(); - var bodyText = Browser.FindElement(By.TagName("body")).Text; - Assert.Contains("There's nothing here", bodyText); + if (useCustomNotFoundPage) + { + var infoText = Browser.FindElement(By.Id("test-info")).Text; + Assert.Contains("Welcome On Custom Not Found Page", infoText); + } + else + { + var bodyText = Browser.FindElement(By.TagName("body")).Text; + Assert.Contains("There's nothing here", bodyText); + } } [Fact] diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 9615dcf58df0..f91db9aa4ee3 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -49,10 +49,6 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); - - - - services.AddHttpContextAccessor(); services.AddSingleton(); services.AddCascadingAuthenticationState(); @@ -77,57 +73,74 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.Map("/subdir", app => { - WebAssemblyTestHelper.ServeCoopHeadersIfWebAssemblyThreadingEnabled(app); - - if (!env.IsDevelopment()) + app.Map("/reexecution", reexecutionApp => { - app.UseExceptionHandler("/Error", createScopeForErrors: true); - } - - app.UseRouting(); - UseFakeAuthState(app); - app.UseAntiforgery(); + reexecutionApp.UseStatusCodePagesWithReExecute("/not-found-reexecute", createScopeForErrors: true); - app.Use((ctx, nxt) => - { - if (ctx.Request.Query.ContainsKey("add-csp")) + reexecutionApp.UseRouting(); + reexecutionApp.UseAntiforgery(); + reexecutionApp.UseEndpoints(endpoints => { - ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'"); - } - return nxt(); + endpoints.MapRazorComponents(); + }); }); - _ = app.UseEndpoints(endpoints => + ConfigureSubdirPipeline(app, env); + }); + } + + protected virtual void ConfigureSubdirPipeline(IApplicationBuilder app, IWebHostEnvironment env) + { + WebAssemblyTestHelper.ServeCoopHeadersIfWebAssemblyThreadingEnabled(app); + + if (!env.IsDevelopment()) + { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + } + + app.UseRouting(); + UseFakeAuthState(app); + app.UseAntiforgery(); + + app.Use((ctx, nxt) => + { + if (ctx.Request.Query.ContainsKey("add-csp")) { - var contentRootStaticAssetsPath = Path.Combine(env.ContentRootPath, "Components.TestServer.staticwebassets.endpoints.json"); - if (File.Exists(contentRootStaticAssetsPath)) - { - endpoints.MapStaticAssets(contentRootStaticAssetsPath); - } - else - { - endpoints.MapStaticAssets(); - } + ctx.Response.Headers.Add("Content-Security-Policy", "script-src 'self' 'unsafe-inline'"); + } + return nxt(); + }); - _ = endpoints.MapRazorComponents() - .AddAdditionalAssemblies(Assembly.Load("Components.WasmMinimal")) - .AddInteractiveServerRenderMode(options => - { - var config = app.ApplicationServices.GetRequiredService(); - options.DisableWebSocketCompression = config.IsCompressionDisabled; + _ = app.UseEndpoints(endpoints => + { + var contentRootStaticAssetsPath = Path.Combine(env.ContentRootPath, "Components.TestServer.staticwebassets.endpoints.json"); + if (File.Exists(contentRootStaticAssetsPath)) + { + endpoints.MapStaticAssets(contentRootStaticAssetsPath); + } + else + { + endpoints.MapStaticAssets(); + } - options.ContentSecurityFrameAncestorsPolicy = config.CspPolicy; + _ = endpoints.MapRazorComponents() + .AddAdditionalAssemblies(Assembly.Load("Components.WasmMinimal")) + .AddInteractiveServerRenderMode(options => + { + var config = app.ApplicationServices.GetRequiredService(); + options.DisableWebSocketCompression = config.IsCompressionDisabled; - options.ConfigureWebSocketAcceptContext = config.ConfigureWebSocketAcceptContext; - }) - .AddInteractiveWebAssemblyRenderMode(options => options.PathPrefix = "/WasmMinimal"); + options.ContentSecurityFrameAncestorsPolicy = config.CspPolicy; - NotEnabledStreamingRenderingComponent.MapEndpoints(endpoints); - StreamingRenderingForm.MapEndpoints(endpoints); - InteractiveStreamingRenderingComponent.MapEndpoints(endpoints); + options.ConfigureWebSocketAcceptContext = config.ConfigureWebSocketAcceptContext; + }) + .AddInteractiveWebAssemblyRenderMode(options => options.PathPrefix = "/WasmMinimal"); - MapEnhancedNavigationEndpoints(endpoints); - }); + NotEnabledStreamingRenderingComponent.MapEndpoints(endpoints); + StreamingRenderingForm.MapEndpoints(endpoints); + InteractiveStreamingRenderingComponent.MapEndpoints(endpoints); + + MapEnhancedNavigationEndpoints(endpoints); }); } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index 8c4a8f258b51..4418fd392d9b 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -1,4 +1,25 @@ @using Components.TestServer.RazorComponents.Pages.Forms +@using Components.WasmMinimal.Pages + +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "useCustomNotFoundPage")] + public string? UseCustomNotFoundPage { get; set; } + + private Type? NotFoundPageType { get; set; } + + protected override void OnParametersSet() + { + if (UseCustomNotFoundPage == "true") + { + NotFoundPageType = typeof(CustomNotFoundPage); + } + else + { + NotFoundPageType = null; + } + } +} @@ -8,7 +29,7 @@ - + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFoundPage.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFoundPage.razor new file mode 100644 index 000000000000..26641748f282 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/NotFoundPage.razor @@ -0,0 +1,6 @@ +@page "/not-found" + +Default Not Found Page + +

This page is used for a workaround of NavigationManager.NotFound() method, used in SSR when the response already started and changing it to 404 is not possible. + This workaround triggers navigation to a constant "not-found" relative path.

\ No newline at end of file diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PageThatSetsNotFound.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PageThatSetsNotFound.razor new file mode 100644 index 000000000000..e397f81672a3 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PageThatSetsNotFound.razor @@ -0,0 +1,23 @@ +@page "/reexecution/set-not-found" +@page "/set-not-found" +@attribute [StreamRendering(false)] +@inject NavigationManager NavigationManager + +Original page + +

Any content

+ +@code{ + [Parameter] + [SupplyParameterFromQuery(Name = "shouldSet")] + public bool? ShouldSet { get; set; } + + protected override void OnInitialized() + { + bool shouldSet = ShouldSet ?? true; + if (shouldSet) + { + NavigationManager.NotFound(); + } + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/ReexecutedPage.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/ReexecutedPage.razor new file mode 100644 index 000000000000..6cf5ec51481a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/ReexecutedPage.razor @@ -0,0 +1,6 @@ +@page "/not-found-reexecute" + +Re-executed page + +

Welcome On Page Re-executed After Not Found Event

+

This page is shown when UseStatusCodePagesWithReExecute is set and another page sets 404

diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor new file mode 100644 index 000000000000..92b5f95d7c4b --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering/StreamingSetNotFound.razor @@ -0,0 +1,30 @@ +@page "/reexecution/streaming-set-not-found" +@page "/streaming-set-not-found" +@attribute [StreamRendering] +@inject NavigationManager NavigationManager + +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "shouldSet")] + public bool? ShouldSet { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "responseStarted")] + public bool? ResponseStarted { get; set; } + + protected override async Task OnInitializedAsync() + { + bool shouldSet = ShouldSet ?? true; + bool responseStarted = ResponseStarted ?? true; + if (responseStarted) + { + // Simulate some delay before triggering NotFound to start streaming response + await Task.Yield(); + } + + if (shouldSet) + { + NavigationManager.NotFound(); + } + } +} diff --git a/src/Components/test/testassets/Components.WasmMinimal/Pages/CustomNotFoundPage.razor b/src/Components/test/testassets/Components.WasmMinimal/Pages/CustomNotFoundPage.razor new file mode 100644 index 000000000000..fc48947d6ec2 --- /dev/null +++ b/src/Components/test/testassets/Components.WasmMinimal/Pages/CustomNotFoundPage.razor @@ -0,0 +1,4 @@ +@page "/render-custom-not-found-page" + +

Welcome On Custom Not Found Page

+

Sorry, the page you are looking for does not exist.

\ No newline at end of file diff --git a/src/Components/test/testassets/Components.WasmMinimal/Routes.razor b/src/Components/test/testassets/Components.WasmMinimal/Routes.razor index ec39646aa5a5..a0807b09bf14 100644 --- a/src/Components/test/testassets/Components.WasmMinimal/Routes.razor +++ b/src/Components/test/testassets/Components.WasmMinimal/Routes.razor @@ -1,6 +1,28 @@ @using Microsoft.AspNetCore.Components.Routing +@using Components.WasmMinimal.Pages +@inject NavigationManager NavigationManager - +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "useCustomNotFoundPage")] + public string? UseCustomNotFoundPage { get; set; } + + private Type? NotFoundPageType { get; set; } + + protected override void OnParametersSet() + { + if (UseCustomNotFoundPage == "true") + { + NotFoundPageType = typeof(CustomNotFoundPage); + } + else + { + NotFoundPageType = null; + } + } +} + + diff --git a/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt b/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..13c61eb5eab2 100644 --- a/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt +++ b/src/Middleware/Diagnostics/src/PublicAPI.Unshipped.txt @@ -1 +1,4 @@ #nullable enable +Microsoft.AspNetCore.Builder.StatusCodePagesOptions.CreateScopeForErrors.get -> bool +Microsoft.AspNetCore.Builder.StatusCodePagesOptions.CreateScopeForErrors.set -> void +static Microsoft.AspNetCore.Builder.StatusCodePagesExtensions.UseStatusCodePagesWithReExecute(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app, string! pathFormat, bool createScopeForErrors, string? queryFormat = null) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! \ No newline at end of file diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs index a431f35582f8..6aa6843995b8 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesExtensions.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Globalization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Builder; @@ -160,6 +162,47 @@ public static IApplicationBuilder UseStatusCodePagesWithReExecute( return app.UseStatusCodePages(CreateHandler(pathFormat, queryFormat)); } + /// + /// Adds a StatusCodePages middleware to the pipeline. Specifies that the response body should be generated by + /// re-executing the request pipeline using an alternate path. This path may contain a '{0}' placeholder of the status code. + /// + /// + /// + /// Whether or not to create a new scope. + /// + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple overloads with optional parameters", Justification = "Required to maintain compatibility")] + public static IApplicationBuilder UseStatusCodePagesWithReExecute( + this IApplicationBuilder app, + string pathFormat, + bool createScopeForErrors, + string? queryFormat = null) + { + ArgumentNullException.ThrowIfNull(app); + + // Only use this path if there's a global router (in the 'WebApplication' case). + if (app.Properties.TryGetValue(RerouteHelper.GlobalRouteBuilderKey, out var routeBuilder) && routeBuilder is not null) + { + return app.Use(next => + { + var newNext = RerouteHelper.Reroute(app, routeBuilder, next); + return new StatusCodePagesMiddleware(next, + Options.Create(new StatusCodePagesOptions() { + HandleAsync = CreateHandler(pathFormat, queryFormat, newNext), + CreateScopeForErrors = createScopeForErrors + })).Invoke; + }); + } + + var options = new StatusCodePagesOptions + { + HandleAsync = CreateHandler(pathFormat, queryFormat), + CreateScopeForErrors = createScopeForErrors + }; + var wrappedOptions = new OptionsWrapper(options); + return app.UseMiddleware(wrappedOptions); + } + private static Func CreateHandler(string pathFormat, string? queryFormat, RequestDelegate? next = null) { var handler = async (StatusCodeContext context) => @@ -176,6 +219,10 @@ private static Func CreateHandler(string pathFormat, st var originalQueryString = context.HttpContext.Request.QueryString; var routeValuesFeature = context.HttpContext.Features.Get(); + var oldScope = context.Options.CreateScopeForErrors ? context.HttpContext.RequestServices : null; + await using AsyncServiceScope? scope = context.Options.CreateScopeForErrors + ? context.HttpContext.RequestServices.GetRequiredService().CreateAsyncScope() + : null; // Store the original paths so the app can check it. context.HttpContext.Features.Set(new StatusCodeReExecuteFeature() @@ -188,6 +235,11 @@ private static Func CreateHandler(string pathFormat, st RouteValues = routeValuesFeature?.RouteValues }); + if (scope.HasValue) + { + context.HttpContext.RequestServices = scope.Value.ServiceProvider; + } + // An endpoint may have already been set. Since we're going to re-invoke the middleware pipeline we need to reset // the endpoint and route values to ensure things are re-calculated. HttpExtensions.ClearEndpoint(context.HttpContext); @@ -210,6 +262,10 @@ private static Func CreateHandler(string pathFormat, st context.HttpContext.Request.QueryString = originalQueryString; context.HttpContext.Request.Path = originalPath; context.HttpContext.Features.Set(null); + if (oldScope != null) + { + context.HttpContext.RequestServices = oldScope; + } } }; diff --git a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs index d3d946ab1763..fba436b01442 100644 --- a/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs +++ b/src/Middleware/Diagnostics/src/StatusCodePage/StatusCodePagesOptions.cs @@ -55,4 +55,11 @@ private static string BuildResponseBody(int httpStatusCode) /// The handler that generates the response body for the given . By default this produces a plain text response that includes the status code. ///
public Func HandleAsync { get; set; } + + /// + /// Gets or sets whether the handler needs to create a separate scope and + /// replace it on when re-executing the request. + /// + /// The default value is . + public bool CreateScopeForErrors { get; set; } } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json index dc632c166f1e..09f28a39ffee 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json @@ -66,6 +66,7 @@ "rename": { "BlazorWeb-CSharp/Components/Layout/": "./BlazorWeb-CSharp.Client/Layout/", "BlazorWeb-CSharp/Components/Pages/Home.razor": "./BlazorWeb-CSharp.Client/Pages/Home.razor", + "BlazorWeb-CSharp/Components/Pages/NotFound.razor": "./BlazorWeb-CSharp.Client/Pages/NotFound.razor", "BlazorWeb-CSharp/Components/Pages/Weather.razor": "./BlazorWeb-CSharp.Client/Pages/Weather.razor", "BlazorWeb-CSharp/Components/Routes.razor": "./BlazorWeb-CSharp.Client/Routes.razor" } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Pages/NotFound.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Pages/NotFound.razor new file mode 100644 index 000000000000..52784945efc4 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Pages/NotFound.razor @@ -0,0 +1,4 @@ +@page "/not-found" + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Routes.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Routes.razor index fdf2fe8b427f..654ae7a121c9 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Routes.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Routes.razor @@ -2,9 +2,9 @@ @using BlazorWeb_CSharp.Components.Account.Shared ##endif*@ @*#if (UseWebAssembly && !InteractiveAtRoot) - + ##else - + ##endif*@ @*#if (IndividualLocalAuth) diff --git a/src/ProjectTemplates/scripts/startvs.cmd b/src/ProjectTemplates/scripts/startvs.cmd new file mode 100644 index 000000000000..5229f91fdd6f --- /dev/null +++ b/src/ProjectTemplates/scripts/startvs.cmd @@ -0,0 +1,34 @@ +@ECHO OFF +SETLOCAL + +:: This command launches a Visual Studio solution with environment variables required to use a local version of the .NET Core SDK. + +:: This tells .NET Core to use the same dotnet.exe that build scripts use +SET DOTNET_ROOT=%~dp0\.dotnet +SET DOTNET_ROOT(x86)=%~dp0\.dotnet\x86 + +:: This tells .NET Core not to go looking for .NET Core in other places +SET DOTNET_MULTILEVEL_LOOKUP=0 + +:: Put our local dotnet.exe on PATH first so Visual Studio knows which one to use +SET PATH=%DOTNET_ROOT%;%PATH% + +SET sln=%~1 + +IF "%sln%"=="" ( + echo Error^: Expected argument ^ + echo Usage^: startvs.cmd ^ + + exit /b 1 +) + +IF NOT EXIST "%DOTNET_ROOT%\dotnet.exe" ( + echo .NET Core has not yet been installed. Run `%~dp0restore.cmd` to install tools + exit /b 1 +) + +IF "%VSINSTALLDIR%" == "" ( + start "" "%sln%" +) else ( + "%VSINSTALLDIR%\Common7\IDE\devenv.com" "%sln%" +) diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index dd5755ff3bbe..5142ac24928e 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -535,6 +535,7 @@ "Components/Layout/NavMenu.razor.css", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -645,6 +646,7 @@ "Components/Pages/Auth.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -727,6 +729,7 @@ "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Properties/launchSettings.json", "wwwroot/app.css", @@ -838,6 +841,7 @@ "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -959,6 +963,7 @@ "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -1037,6 +1042,7 @@ "{ProjectName}/Components/Layout/NavMenu.razor.css", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Pages/Weather.razor", "{ProjectName}/Properties/launchSettings.json", "{ProjectName}/wwwroot/app.css", @@ -1156,6 +1162,7 @@ "{ProjectName}/Components/Layout/NavMenu.razor.css", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Pages/Weather.razor", "{ProjectName}/Components/Routes.razor", "{ProjectName}/Components/_Imports.razor", @@ -1238,6 +1245,7 @@ "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Pages/Weather.razor", "{ProjectName}/Properties/launchSettings.json", "{ProjectName}/wwwroot/app.css", @@ -1361,6 +1369,7 @@ "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Pages/Weather.razor", "{ProjectName}/Components/Routes.razor", "{ProjectName}/Components/_Imports.razor", @@ -1439,6 +1448,7 @@ "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -1504,6 +1514,7 @@ "{ProjectName}.Client/Layout/NavMenu.razor.css", "{ProjectName}.Client/Pages/Counter.razor", "{ProjectName}.Client/Pages/Home.razor", + "{ProjectName}.Client/Pages/NotFound.razor", "{ProjectName}.Client/Pages/Weather.razor", "{ProjectName}.Client/Program.cs", "{ProjectName}.Client/Routes.razor", @@ -1582,6 +1593,7 @@ "{ProjectName}.Client/Layout/ReconnectModal.razor.js", "{ProjectName}.Client/Pages/Counter.razor", "{ProjectName}.Client/Pages/Home.razor", + "{ProjectName}.Client/Pages/NotFound.razor", "{ProjectName}.Client/Pages/Weather.razor", "{ProjectName}.Client/Program.cs", "{ProjectName}.Client/Routes.razor", @@ -1662,6 +1674,7 @@ "Components/Layout/MainLayout.razor.css", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Properties/launchSettings.json", "wwwroot/app.css" ], @@ -1685,6 +1698,7 @@ "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Properties/launchSettings.json", "wwwroot/app.css" ], @@ -1701,6 +1715,7 @@ "{ProjectName}/Program.cs", "{ProjectName}/Components/App.razor", "{ProjectName}/Components/Pages/Error.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Routes.razor", "{ProjectName}/Components/_Imports.razor", "{ProjectName}/Components/Layout/MainLayout.razor", @@ -1733,6 +1748,7 @@ "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Properties/launchSettings.json", "{ProjectName}/wwwroot/app.css", "{ProjectName}.Client/{ProjectName}.Client.csproj", @@ -1801,6 +1817,7 @@ "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", + "{ProjectName}/Components/Pages/NotFound.razor", "{ProjectName}/Components/Routes.razor", "{ProjectName}/Components/_Imports.razor", "{ProjectName}/Data/app.db", @@ -1876,6 +1893,7 @@ "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", + "Components/Pages/NotFound.razor", "Components/Pages/Weather.razor", "Components/Routes.razor", "Components/_Imports.razor", @@ -1949,6 +1967,7 @@ "{ProjectName}.Client/Pages/Auth.razor", "{ProjectName}.Client/Pages/Counter.razor", "{ProjectName}.Client/Pages/Home.razor", + "{ProjectName}.Client/Pages/NotFound.razor", "{ProjectName}.Client/Pages/Weather.razor", "{ProjectName}.Client/Program.cs", "{ProjectName}.Client/RedirectToLogin.razor", @@ -2076,6 +2095,7 @@ "{ProjectName}.Client/Pages/Auth.razor", "{ProjectName}.Client/Pages/Counter.razor", "{ProjectName}.Client/Pages/Home.razor", + "{ProjectName}.Client/Pages/NotFound.razor", "{ProjectName}.Client/Pages/Weather.razor", "{ProjectName}.Client/Program.cs", "{ProjectName}.Client/RedirectToLogin.razor",