diff --git a/src/Components/Web.JS/src/Platform/Circuits/ReconnectStateChangedEvent.ts b/src/Components/Web.JS/src/Platform/Circuits/ReconnectStateChangedEvent.ts new file mode 100644 index 000000000000..89bc593057e4 --- /dev/null +++ b/src/Components/Web.JS/src/Platform/Circuits/ReconnectStateChangedEvent.ts @@ -0,0 +1,5 @@ +export interface ReconnectStateChangedEvent { + state: "show" | "hide" | "retrying" | "failed" | "rejected"; + currentAttempt?: number; + secondsToNextAttempt?: number; +} diff --git a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts index e179d921cf3c..db91f49dcc90 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts @@ -2,11 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. import { ReconnectDisplay } from './ReconnectDisplay'; +import { ReconnectStateChangedEvent } from './ReconnectStateChangedEvent'; + export class UserSpecifiedDisplay implements ReconnectDisplay { static readonly ShowClassName = 'components-reconnect-show'; static readonly HideClassName = 'components-reconnect-hide'; + static readonly RetryingClassName = 'components-reconnect-retrying'; + static readonly FailedClassName = 'components-reconnect-failed'; static readonly RejectedClassName = 'components-reconnect-rejected'; @@ -17,6 +21,8 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { static readonly SecondsToNextAttemptId = 'components-seconds-to-next-attempt'; + static readonly ReconnectStateChangedEventName = 'components-reconnect-state-changed'; + constructor(private dialog: HTMLElement, private readonly document: Document, maxRetries?: number) { this.document = document; @@ -32,6 +38,7 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { show(): void { this.removeClasses(); this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName); + this.dispatchReconnectStateChangedEvent({ state: 'show' }); } update(currentAttempt: number, secondsToNextAttempt: number): void { @@ -46,24 +53,43 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { if (secondsToNextAttemptElement) { secondsToNextAttemptElement.innerText = secondsToNextAttempt.toString(); } + + if (currentAttempt > 1 && secondsToNextAttempt > 0) { + this.dialog.classList.add(UserSpecifiedDisplay.RetryingClassName); + } + + this.dispatchReconnectStateChangedEvent({ state: 'retrying', currentAttempt, secondsToNextAttempt }); } hide(): void { this.removeClasses(); this.dialog.classList.add(UserSpecifiedDisplay.HideClassName); + this.dispatchReconnectStateChangedEvent({ state: 'hide' }); } failed(): void { this.removeClasses(); this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName); + this.dispatchReconnectStateChangedEvent({ state: 'failed' }); } rejected(): void { this.removeClasses(); this.dialog.classList.add(UserSpecifiedDisplay.RejectedClassName); + this.dispatchReconnectStateChangedEvent({ state: 'rejected' }); } private removeClasses() { - this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.HideClassName, UserSpecifiedDisplay.FailedClassName, UserSpecifiedDisplay.RejectedClassName); + this.dialog.classList.remove( + UserSpecifiedDisplay.ShowClassName, + UserSpecifiedDisplay.HideClassName, + UserSpecifiedDisplay.RetryingClassName, + UserSpecifiedDisplay.FailedClassName, + UserSpecifiedDisplay.RejectedClassName); + } + + private dispatchReconnectStateChangedEvent(eventData: ReconnectStateChangedEvent) { + const event = new CustomEvent(UserSpecifiedDisplay.ReconnectStateChangedEventName, { detail: eventData }); + this.dialog.dispatchEvent(event); } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionCustomUITest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionCustomUITest.cs new file mode 100644 index 000000000000..b7e127ebd83a --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionCustomUITest.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicTestApp.Reconnection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; +using Microsoft.AspNetCore.E2ETesting; +using Microsoft.AspNetCore.Hosting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; + +public class ServerReconnectionCustomUITest : ServerTestBase> +{ + public ServerReconnectionCustomUITest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + /// Setting this query parameter causes to include the custom reconnect dialog. + Navigate($"{ServerPathBase}?useCustomReconnectModal=true"); + Browser.MountTestComponent(); + Browser.Exists(By.Id("count")); + } + + /// + /// Tests that the custom reconnect is displayed when the server circuit is disconnected. + /// This UI is provided statically by a Razor component instead being generated by the default + /// JS fallback code (see 'DefaultReconnectDisplay.ts'). + /// + [Fact] + public void ReconnectionUI_CustomDialog_IsDisplayed() + { + Browser.Exists(By.Id("increment")).Click(); + + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript("Blazor._internal.forceCloseConnection()"); + + // We should see the 'reconnecting' UI appear + Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); + Browser.NotEqual(null, () => Browser.Exists(By.Id("components-reconnect-modal")).GetAttribute("open")); + + // The reconnect modal should not be a 'div' element created by the fallback JS code + Browser.Equal("dialog", () => Browser.Exists(By.Id("components-reconnect-modal")).TagName); + + // Then it should disappear + Browser.Equal("none", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); + Browser.Equal(null, () => Browser.Exists(By.Id("components-reconnect-modal")).GetAttribute("open")); + + Browser.Exists(By.Id("increment")).Click(); + + // Can dispatch events after reconnect + Browser.Equal("2", () => Browser.Exists(By.Id("count")).Text); + } + + /// + /// Tests that when the custom reconnect UI is used, there are no style-related CSP errors. + /// + [Fact] + public void ReconnectionUI_WorksWith_StrictStyleCspPolicy() + { + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript("Blazor._internal.forceCloseConnection()"); + + // We should see the 'reconnecting' UI appear + Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); + + // Check that there is no CSP-related error in the browser console + var cspErrorMessage = "violates the following Content Security Policy directive: \"style-src"; + var logs = Browser.Manage().Logs.GetLog(LogType.Browser); + var styleErrors = logs.Where(log => log.Message.Contains(cspErrorMessage)); + + Assert.Empty(styleErrors); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Reconnection/ReconnectionComponent.razor b/src/Components/test/testassets/BasicTestApp/Reconnection/ReconnectionComponent.razor index ec5f659fa20b..2a113db37bb4 100644 --- a/src/Components/test/testassets/BasicTestApp/Reconnection/ReconnectionComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/Reconnection/ReconnectionComponent.razor @@ -1,4 +1,6 @@ @using System.Timers +@using System.Web +@inject NavigationManager Navigation @implements IDisposable

Reconnection Component

@@ -20,16 +22,49 @@ + +@if (useCustomReconnectModal) +{ + // We set stricter CSP for styles as we want to check that the application is CSP compliant + // when using a custom reconnect modal. + // (We know that it is not compliant with the JS-created default reconnect UI.) + + + + + + Rejoining the server... + + + +} + @code { int count; void Increment() => count++; int tickCount = 0; Timer timer; bool causeError = false; + bool useCustomReconnectModal = false; - protected override void OnInitialized() + protected override void OnInitialized() { timer = StartTimer(); + + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = HttpUtility.ParseQueryString(uri.Query); + useCustomReconnectModal = query["useCustomReconnectModal"] == "true"; } private Timer StartTimer() diff --git a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml index 13c09111b590..9de8ea472840 100644 --- a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml +++ b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml @@ -12,6 +12,7 @@ + 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 717a2d42f7b7..8ef0b63f24d7 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 @@ -93,6 +93,14 @@ "BlazorWeb-CSharp/Components/Pages/Counter.razor" ] }, + { + "condition": "(!UseServer)", + "exclude": [ + "BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor", + "BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.css", + "BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.js" + ] + }, { "condition": "(ExcludeLaunchSettings)", "exclude": [ diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/MainLayout.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/MainLayout.razor index 998fa68bafd4..dff2f7ff2e66 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/MainLayout.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/MainLayout.razor @@ -28,3 +28,6 @@ 🗙 ##endif*@ +@*#if (UseServer) --> + +##endif*@ diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor new file mode 100644 index 000000000000..1965d2c88991 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor @@ -0,0 +1,22 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +
+
diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.css b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 000000000000..cb5426abe0b2 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,154 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 000000000000..c7a148cb6f26 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,42 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn"t reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We"ll reload the page so the user can continue using the app as quickly as possible. + location.reload(); + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index c70e18b926d7..dd5755ff3bbe 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -721,6 +721,9 @@ "Components/Layout/MainLayout.razor.css", "Components/Layout/NavMenu.razor", "Components/Layout/NavMenu.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", @@ -828,6 +831,9 @@ "Components/Layout/MainLayout.razor.css", "Components/Layout/NavMenu.razor", "Components/Layout/NavMenu.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Auth.razor", "Components/Pages/Counter.razor", "Components/Pages/Error.razor", @@ -946,6 +952,9 @@ "Components/Layout/MainLayout.razor.css", "Components/Layout/NavMenu.razor", "Components/Layout/NavMenu.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Auth.razor", "Components/Pages/Counter.razor", "Components/Pages/Error.razor", @@ -1224,6 +1233,9 @@ "{ProjectName}/Components/Layout/MainLayout.razor.css", "{ProjectName}/Components/Layout/NavMenu.razor", "{ProjectName}/Components/Layout/NavMenu.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor", + "{ProjectName}/Components/Layout/ReconnectModal.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", "{ProjectName}/Components/Pages/Weather.razor", @@ -1344,6 +1356,9 @@ "{ProjectName}/Components/Layout/MainLayout.razor.css", "{ProjectName}/Components/Layout/NavMenu.razor", "{ProjectName}/Components/Layout/NavMenu.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor", + "{ProjectName}/Components/Layout/ReconnectModal.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", "{ProjectName}/Components/Pages/Weather.razor", @@ -1418,6 +1433,9 @@ "Components/Layout/MainLayout.razor.css", "Components/Layout/NavMenu.razor", "Components/Layout/NavMenu.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Counter.razor", "Components/Pages/Error.razor", "Components/Pages/Home.razor", @@ -1559,6 +1577,9 @@ "{ProjectName}.Client/Layout/MainLayout.razor.css", "{ProjectName}.Client/Layout/NavMenu.razor", "{ProjectName}.Client/Layout/NavMenu.razor.css", + "{ProjectName}.Client/Layout/ReconnectModal.razor", + "{ProjectName}.Client/Layout/ReconnectModal.razor.css", + "{ProjectName}.Client/Layout/ReconnectModal.razor.js", "{ProjectName}.Client/Pages/Counter.razor", "{ProjectName}.Client/Pages/Home.razor", "{ProjectName}.Client/Pages/Weather.razor", @@ -1659,6 +1680,9 @@ "Components/_Imports.razor", "Components/Layout/MainLayout.razor", "Components/Layout/MainLayout.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Error.razor", "Components/Pages/Home.razor", "Properties/launchSettings.json", @@ -1704,6 +1728,9 @@ "{ProjectName}/Components/_Imports.razor", "{ProjectName}/Components/Layout/MainLayout.razor", "{ProjectName}/Components/Layout/MainLayout.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor", + "{ProjectName}/Components/Layout/ReconnectModal.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", "{ProjectName}/Properties/launchSettings.json", @@ -1769,6 +1796,9 @@ "{ProjectName}/Components/App.razor", "{ProjectName}/Components/Layout/MainLayout.razor", "{ProjectName}/Components/Layout/MainLayout.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor", + "{ProjectName}/Components/Layout/ReconnectModal.razor.css", + "{ProjectName}/Components/Layout/ReconnectModal.razor.js", "{ProjectName}/Components/Pages/Error.razor", "{ProjectName}/Components/Pages/Home.razor", "{ProjectName}/Components/Routes.razor", @@ -1839,6 +1869,9 @@ "Components/Layout/MainLayout.razor.css", "Components/Layout/NavMenu.razor", "Components/Layout/NavMenu.razor.css", + "Components/Layout/ReconnectModal.razor", + "Components/Layout/ReconnectModal.razor.css", + "Components/Layout/ReconnectModal.razor.js", "Components/Pages/Auth.razor", "Components/Pages/Counter.razor", "Components/Pages/Error.razor", @@ -2036,6 +2069,9 @@ "{ProjectName}.Client/Layout/MainLayout.razor.css", "{ProjectName}.Client/Layout/NavMenu.razor", "{ProjectName}.Client/Layout/NavMenu.razor.css", + "{ProjectName}.Client/Layout/ReconnectModal.razor", + "{ProjectName}.Client/Layout/ReconnectModal.razor.css", + "{ProjectName}.Client/Layout/ReconnectModal.razor.js", "{ProjectName}.Client/{ProjectName}.Client.csproj", "{ProjectName}.Client/Pages/Auth.razor", "{ProjectName}.Client/Pages/Counter.razor",