Skip to content

Commit 0fe271c

Browse files
authored
Add reconnect UI component to the Blazor template (#60376)
* Add prototype to sample app * Add e2e test for the new reconnect UI * Add E2E test for CSP violation * Add HTML attributes to ReconnectModal * Add ReconnectModal to the BlazorWeb-CSharp project template * Add comments, simplify test component, clean up sample app * Add new project template files to template-baselines.json * Add missing cases to template-baselines.json * Add HeadOutlet to TestServer app, use meta tag for setting CSP in reconnect test * Dispatch reconnection state change events in UserSpecifiedDisplay * Fix code style * Add missing Add missing cases to template-baselines.json * Add missing cases to template-baselines.json
1 parent 39f1443 commit 0fe271c

File tree

11 files changed

+418
-2
lines changed

11 files changed

+418
-2
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface ReconnectStateChangedEvent {
2+
state: "show" | "hide" | "retrying" | "failed" | "rejected";
3+
currentAttempt?: number;
4+
secondsToNextAttempt?: number;
5+
}

src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
import { ReconnectDisplay } from './ReconnectDisplay';
5+
import { ReconnectStateChangedEvent } from './ReconnectStateChangedEvent';
6+
57
export class UserSpecifiedDisplay implements ReconnectDisplay {
68
static readonly ShowClassName = 'components-reconnect-show';
79

810
static readonly HideClassName = 'components-reconnect-hide';
911

12+
static readonly RetryingClassName = 'components-reconnect-retrying';
13+
1014
static readonly FailedClassName = 'components-reconnect-failed';
1115

1216
static readonly RejectedClassName = 'components-reconnect-rejected';
@@ -17,6 +21,8 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
1721

1822
static readonly SecondsToNextAttemptId = 'components-seconds-to-next-attempt';
1923

24+
static readonly ReconnectStateChangedEventName = 'components-reconnect-state-changed';
25+
2026
constructor(private dialog: HTMLElement, private readonly document: Document, maxRetries?: number) {
2127
this.document = document;
2228

@@ -32,6 +38,7 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
3238
show(): void {
3339
this.removeClasses();
3440
this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName);
41+
this.dispatchReconnectStateChangedEvent({ state: 'show' });
3542
}
3643

3744
update(currentAttempt: number, secondsToNextAttempt: number): void {
@@ -46,24 +53,43 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
4653
if (secondsToNextAttemptElement) {
4754
secondsToNextAttemptElement.innerText = secondsToNextAttempt.toString();
4855
}
56+
57+
if (currentAttempt > 1 && secondsToNextAttempt > 0) {
58+
this.dialog.classList.add(UserSpecifiedDisplay.RetryingClassName);
59+
}
60+
61+
this.dispatchReconnectStateChangedEvent({ state: 'retrying', currentAttempt, secondsToNextAttempt });
4962
}
5063

5164
hide(): void {
5265
this.removeClasses();
5366
this.dialog.classList.add(UserSpecifiedDisplay.HideClassName);
67+
this.dispatchReconnectStateChangedEvent({ state: 'hide' });
5468
}
5569

5670
failed(): void {
5771
this.removeClasses();
5872
this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName);
73+
this.dispatchReconnectStateChangedEvent({ state: 'failed' });
5974
}
6075

6176
rejected(): void {
6277
this.removeClasses();
6378
this.dialog.classList.add(UserSpecifiedDisplay.RejectedClassName);
79+
this.dispatchReconnectStateChangedEvent({ state: 'rejected' });
6480
}
6581

6682
private removeClasses() {
67-
this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.HideClassName, UserSpecifiedDisplay.FailedClassName, UserSpecifiedDisplay.RejectedClassName);
83+
this.dialog.classList.remove(
84+
UserSpecifiedDisplay.ShowClassName,
85+
UserSpecifiedDisplay.HideClassName,
86+
UserSpecifiedDisplay.RetryingClassName,
87+
UserSpecifiedDisplay.FailedClassName,
88+
UserSpecifiedDisplay.RejectedClassName);
89+
}
90+
91+
private dispatchReconnectStateChangedEvent(eventData: ReconnectStateChangedEvent) {
92+
const event = new CustomEvent(UserSpecifiedDisplay.ReconnectStateChangedEventName, { detail: eventData });
93+
this.dialog.dispatchEvent(event);
6894
}
6995
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using BasicTestApp.Reconnection;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
7+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
8+
using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
9+
using Microsoft.AspNetCore.E2ETesting;
10+
using Microsoft.AspNetCore.Hosting;
11+
using OpenQA.Selenium;
12+
using TestServer;
13+
using Xunit.Abstractions;
14+
15+
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
16+
17+
public class ServerReconnectionCustomUITest : ServerTestBase<BasicTestAppServerSiteFixture<ServerStartup>>
18+
{
19+
public ServerReconnectionCustomUITest(
20+
BrowserFixture browserFixture,
21+
BasicTestAppServerSiteFixture<ServerStartup> serverFixture,
22+
ITestOutputHelper output)
23+
: base(browserFixture, serverFixture, output)
24+
{
25+
}
26+
27+
protected override void InitializeAsyncCore()
28+
{
29+
/// Setting this query parameter causes <see cref="ReconnectionComponent"/> to include the custom reconnect dialog.
30+
Navigate($"{ServerPathBase}?useCustomReconnectModal=true");
31+
Browser.MountTestComponent<ReconnectionComponent>();
32+
Browser.Exists(By.Id("count"));
33+
}
34+
35+
/// <summary>
36+
/// Tests that the custom reconnect is displayed when the server circuit is disconnected.
37+
/// This UI is provided statically by a Razor component instead being generated by the default
38+
/// JS fallback code (see 'DefaultReconnectDisplay.ts').
39+
/// </summary>
40+
[Fact]
41+
public void ReconnectionUI_CustomDialog_IsDisplayed()
42+
{
43+
Browser.Exists(By.Id("increment")).Click();
44+
45+
var js = (IJavaScriptExecutor)Browser;
46+
js.ExecuteScript("Blazor._internal.forceCloseConnection()");
47+
48+
// We should see the 'reconnecting' UI appear
49+
Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display"));
50+
Browser.NotEqual(null, () => Browser.Exists(By.Id("components-reconnect-modal")).GetAttribute("open"));
51+
52+
// The reconnect modal should not be a 'div' element created by the fallback JS code
53+
Browser.Equal("dialog", () => Browser.Exists(By.Id("components-reconnect-modal")).TagName);
54+
55+
// Then it should disappear
56+
Browser.Equal("none", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display"));
57+
Browser.Equal(null, () => Browser.Exists(By.Id("components-reconnect-modal")).GetAttribute("open"));
58+
59+
Browser.Exists(By.Id("increment")).Click();
60+
61+
// Can dispatch events after reconnect
62+
Browser.Equal("2", () => Browser.Exists(By.Id("count")).Text);
63+
}
64+
65+
/// <summary>
66+
/// Tests that when the custom reconnect UI is used, there are no style-related CSP errors.
67+
/// </summary>
68+
[Fact]
69+
public void ReconnectionUI_WorksWith_StrictStyleCspPolicy()
70+
{
71+
var js = (IJavaScriptExecutor)Browser;
72+
js.ExecuteScript("Blazor._internal.forceCloseConnection()");
73+
74+
// We should see the 'reconnecting' UI appear
75+
Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display"));
76+
77+
// Check that there is no CSP-related error in the browser console
78+
var cspErrorMessage = "violates the following Content Security Policy directive: \"style-src";
79+
var logs = Browser.Manage().Logs.GetLog(LogType.Browser);
80+
var styleErrors = logs.Where(log => log.Message.Contains(cspErrorMessage));
81+
82+
Assert.Empty(styleErrors);
83+
}
84+
}

src/Components/test/testassets/BasicTestApp/Reconnection/ReconnectionComponent.razor

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
@using System.Timers
2+
@using System.Web
3+
@inject NavigationManager Navigation
24
@implements IDisposable
35
<h3>Reconnection Component</h3>
46

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

2123
<button id="cause-error" @onclick="CauseError">Cause error</button>
2224
</div>
25+
26+
@if (useCustomReconnectModal)
27+
{
28+
// We set stricter CSP for styles as we want to check that the application is CSP compliant
29+
// when using a custom reconnect modal.
30+
// (We know that it is not compliant with the JS-created default reconnect UI.)
31+
<HeadContent>
32+
<meta http-equiv="Content-Security-Policy" content="style-src 'self';">
33+
</HeadContent>
34+
35+
<dialog id="components-reconnect-modal">
36+
Rejoining the server...
37+
</dialog>
38+
39+
<script>
40+
const reconnectModal = document.getElementById("components-reconnect-modal");
41+
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
42+
43+
function handleReconnectStateChanged(event) {
44+
if (event.detail.state === "show") {
45+
reconnectModal.showModal();
46+
} else if (event.detail.state === "hide") {
47+
reconnectModal.close();
48+
}
49+
}
50+
</script>
51+
}
52+
2353
@code {
2454
int count;
2555
void Increment() => count++;
2656
int tickCount = 0;
2757
Timer timer;
2858
bool causeError = false;
59+
bool useCustomReconnectModal = false;
2960

30-
protected override void OnInitialized()
61+
protected override void OnInitialized()
3162
{
3263
timer = StartTimer();
64+
65+
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
66+
var query = HttpUtility.ParseQueryString(uri.Query);
67+
useCustomReconnectModal = query["useCustomReconnectModal"] == "true";
3368
}
3469

3570
private Timer StartTimer()

src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<!-- Used by ExternalContentPackage -->
1313
<link href="_content/TestContentPackage/styles.css" rel="stylesheet" />
1414
<link href="Components.TestServer.styles.css" rel="stylesheet" />
15+
<component type="typeof(Microsoft.AspNetCore.Components.Web.HeadOutlet)" render-mode="Server" />
1516
</head>
1617
<body>
1718
<root><component type="typeof(BasicTestApp.Index)" render-mode="Server" /></root>

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/.template.config/template.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@
9393
"BlazorWeb-CSharp/Components/Pages/Counter.razor"
9494
]
9595
},
96+
{
97+
"condition": "(!UseServer)",
98+
"exclude": [
99+
"BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor",
100+
"BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.css",
101+
"BlazorWeb-CSharp/Components/Layout/ReconnectModal.razor.js"
102+
]
103+
},
96104
{
97105
"condition": "(ExcludeLaunchSettings)",
98106
"exclude": [

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/Layout/MainLayout.razor

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@
2828
<span class="dismiss">🗙</span>
2929
</div>
3030
##endif*@
31+
@*#if (UseServer) -->
32+
<ReconnectModal />
33+
##endif*@
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
2+
3+
<dialog id="components-reconnect-modal" data-nosnippet>
4+
<div class="components-reconnect-container">
5+
<div class="components-rejoining-animation" aria-hidden="true">
6+
<div></div>
7+
<div></div>
8+
</div>
9+
<p class="components-reconnect-first-attempt-visible">
10+
Rejoining the server...
11+
</p>
12+
<p class="components-reconnect-repeated-attempt-visible">
13+
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
14+
</p>
15+
<p class="components-reconnect-failed-visible">
16+
Failed to rejoin.<br />Please retry or reload the page.
17+
</p>
18+
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
19+
Retry
20+
</button>
21+
</div>
22+
</dialog>

0 commit comments

Comments
 (0)