Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add reconnect UI component to the Blazor template #60376

Merged
merged 13 commits into from
Feb 18, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ReconnectStateChangedEvent {
state: "show" | "hide" | "retrying" | "failed" | "rejected";
currentAttempt?: number;
secondsToNextAttempt?: number;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<BasicTestAppServerSiteFixture<ServerStartup>>
{
public ServerReconnectionCustomUITest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<ServerStartup> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

protected override void InitializeAsyncCore()
{
/// Setting this query parameter causes <see cref="ReconnectionComponent"/> to include the custom reconnect dialog.
Navigate($"{ServerPathBase}?useCustomReconnectModal=true");
Browser.MountTestComponent<ReconnectionComponent>();
Browser.Exists(By.Id("count"));
}

/// <summary>
/// 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').
/// </summary>
[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);
}

/// <summary>
/// Tests that when the custom reconnect UI is used, there are no style-related CSP errors.
/// </summary>
[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);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@using System.Timers
@using System.Web
@inject NavigationManager Navigation
@implements IDisposable
<h3>Reconnection Component</h3>

@@ -20,16 +22,49 @@

<button id="cause-error" @onclick="CauseError">Cause error</button>
</div>

@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.)
<HeadContent>
<meta http-equiv="Content-Security-Policy" content="style-src 'self';">
</HeadContent>

<dialog id="components-reconnect-modal">
Rejoining the server...
</dialog>

<script>
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);

function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
}
}
</script>
}

@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()
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
<!-- Used by ExternalContentPackage -->
<link href="_content/TestContentPackage/styles.css" rel="stylesheet" />
<link href="Components.TestServer.styles.css" rel="stylesheet" />
<component type="typeof(Microsoft.AspNetCore.Components.Web.HeadOutlet)" render-mode="Server" />
</head>
<body>
<root><component type="typeof(BasicTestApp.Index)" render-mode="Server" /></root>
Original file line number Diff line number Diff line change
@@ -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": [
Original file line number Diff line number Diff line change
@@ -28,3 +28,6 @@
<span class="dismiss">🗙</span>
</div>
##endif*@
@*#if (UseServer) -->
<ReconnectModal />
##endif*@
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>

<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
</div>
</dialog>
Loading
Loading