Skip to content

Commit e91b768

Browse files
authored
Improve Blazor reconnection experience after the server is restarted (#64732)
* Improve Blazor reconnection experience after server restart * Update CannotResumeAppWhenPersistedComponentStateIsNotAvailable to reflect change in ResumeCircuit * Revert a minor UI change * Add E2E tests to check reconnection behavior without server state * Fix typos * Add missing hiding of buttons in DefaultReconnectDisplay
1 parent c9d0750 commit e91b768

File tree

9 files changed

+198
-19
lines changed

9 files changed

+198
-19
lines changed

src/Components/Server/src/ComponentHub.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,10 @@ public async ValueTask<string> ResumeCircuit(
316316
persistedCircuitState = await _circuitPersistenceManager.ResumeCircuitAsync(circuitId, Context.ConnectionAborted);
317317
if (persistedCircuitState == null)
318318
{
319+
// The circuit state cannot be retrieved. It might have been deleted or expired.
320+
// We do not send an error to the client as this is a valid scenario
321+
// that will be handled by the client reconnection logic.
319322
Log.InvalidInputData(_logger);
320-
await NotifyClientError(Clients.Caller, "The circuit state could not be retrieved. It may have been deleted or expired.");
321-
Context.Abort();
322323
return null;
323324
}
324325
}

src/Components/Server/test/Circuits/ComponentHubTest.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,6 @@ public async Task CannotResumeAppWhenPersistedComponentStateIsNotAvailable()
246246
var circuitSecret = await hub.StartCircuit("https://localhost:5000", "https://localhost:5000/subdir", "{}", null);
247247
var result = await hub.ResumeCircuit(circuitSecret, "https://localhost:5000", "https://localhost:5000/subdir", "[]", "");
248248
Assert.Null(result);
249-
var errorMessage = "The circuit state could not be retrieved. It may have been deleted or expired.";
250-
mockClientProxy.Verify(m => m.SendCoreAsync("JS.Error", new[] { errorMessage }, It.IsAny<CancellationToken>()), Times.Once());
251249
}
252250

253251
[Fact]

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
9696

9797
this.reconnect = options?.type === 'reconnect';
9898

99+
this.resumeButton.style.display = 'none';
99100
this.reloadButton.style.display = 'none';
100101
this.rejoiningAnimation.style.display = 'block';
101102
this.status.innerHTML = 'Rejoining the server...';
@@ -106,6 +107,8 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
106107
update(options: ReconnectDisplayUpdateOptions): void {
107108
this.reconnect = options.type === 'reconnect';
108109
if (this.reconnect) {
110+
this.reloadButton.style.display = 'none';
111+
this.resumeButton.style.display = 'none';
109112
const { currentAttempt, secondsToNextAttempt } = options as ReconnectOptions;
110113
if (currentAttempt === 1 || secondsToNextAttempt === 0) {
111114
this.status.innerHTML = 'Rejoining the server...';
@@ -129,12 +132,13 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
129132
failed(): void {
130133
this.rejoiningAnimation.style.display = 'none';
131134
if (this.reconnect) {
135+
this.resumeButton.style.display = 'none';
132136
this.reloadButton.style.display = 'block';
133137
this.status.innerHTML = 'Failed to rejoin.<br />Please retry or reload the page.';
134138
this.document.addEventListener('visibilitychange', this.retryWhenDocumentBecomesVisible);
135139
} else {
136-
this.status.innerHTML = 'Failed to resume the session.<br />Please reload the page.';
137-
this.resumeButton.style.display = 'none';
140+
this.status.innerHTML = 'Failed to resume the session.<br />Please retry or reload the page.';
141+
this.resumeButton.style.display = 'block';
138142
this.reloadButton.style.display = 'none';
139143
}
140144
}
@@ -157,7 +161,6 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
157161
const successful = await Blazor.reconnect!();
158162
if (!successful) {
159163
// Try to resume the circuit if the reconnect failed
160-
this.update({ type: 'pause', remote: this.remote });
161164
const resumeSuccessful = await Blazor.resumeCircuit!();
162165
if (!resumeSuccessful) {
163166
this.rejected();
@@ -178,7 +181,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
178181
// - exception to mean we didn't reach the server (this can be sync or async)
179182
const successful = await Blazor.resumeCircuit!();
180183
if (!successful) {
181-
this.failed();
184+
this.rejected();
182185
}
183186
} catch (err: unknown) {
184187
// We got an exception, server is currently unavailable

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,12 @@ class ReconnectionProcess {
126126
if (!result) {
127127
// Try to resume the circuit if the reconnect failed
128128
// If the server responded and refused to reconnect, stop auto-retrying.
129-
this.reconnectDisplay.update({ type: 'pause', remote: true });
130129
const resumeResult = await this.resumeCallback();
131130
if (resumeResult) {
132131
return;
133132
}
134133

135-
this.reconnectDisplay.failed();
134+
this.reconnectDisplay.rejected();
136135
return;
137136
}
138137
return;

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
2727

2828
static readonly ReconnectStateChangedEventName = 'components-reconnect-state-changed';
2929

30-
private reconnect = false;
30+
reconnect = true;
31+
32+
remote = false;
3133

3234
constructor(private dialog: HTMLElement, private readonly document: Document, maxRetries?: number) {
3335
this.document = document;
@@ -70,10 +72,10 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
7072
this.dispatchReconnectStateChangedEvent({ state: 'retrying', currentAttempt, secondsToNextAttempt });
7173
}
7274
if (options.type === 'pause') {
73-
const remote = options.remote;
75+
this.remote = options.remote;
7476
this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.RetryingClassName);
7577
this.dialog.classList.add(UserSpecifiedDisplay.PausedClassName);
76-
this.dispatchReconnectStateChangedEvent({ state: 'paused', remote: remote });
78+
this.dispatchReconnectStateChangedEvent({ state: 'paused', remote: this.remote });
7779
}
7880
}
7981

@@ -90,7 +92,7 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
9092
this.dispatchReconnectStateChangedEvent({ state: 'failed' });
9193
} else {
9294
this.dialog.classList.add(UserSpecifiedDisplay.ResumeFailedClassName);
93-
this.dispatchReconnectStateChangedEvent({ state: 'resume-failed' });
95+
this.dispatchReconnectStateChangedEvent({ state: 'resume-failed', remote: this.remote });
9496
}
9597
}
9698

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 System;
5+
using System.Collections.Generic;
6+
using System.Text;
7+
using Components.TestServer.RazorComponents;
8+
using Microsoft.AspNetCore.Components.E2ETest;
9+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
10+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
11+
using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
12+
using Microsoft.AspNetCore.E2ETesting;
13+
using OpenQA.Selenium;
14+
using OpenQA.Selenium.BiDi.Communication;
15+
using OpenQA.Selenium.DevTools;
16+
using TestServer;
17+
using Xunit.Abstractions;
18+
19+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;
20+
21+
public class ServerReconnectionWithoutStateTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>>>
22+
{
23+
public ServerReconnectionWithoutStateTest(
24+
BrowserFixture browserFixture,
25+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>> serverFixture,
26+
ITestOutputHelper output)
27+
: base(browserFixture, serverFixture, output)
28+
{
29+
serverFixture.AdditionalArguments.AddRange("--DisableReconnectionCache", "true");
30+
serverFixture.AdditionalArguments.AddRange("--DisableCircuitPersistence", "true");
31+
}
32+
33+
protected override void InitializeAsyncCore()
34+
{
35+
Navigate(TestUrl);
36+
Browser.Exists(By.Id("render-mode-interactive"));
37+
}
38+
39+
public string TestUrl { get; set; } = "/subdir/persistent-state/disconnection";
40+
41+
public bool UseShadowRoot { get; set; } = true;
42+
43+
[Fact]
44+
public void ReloadsPage_AfterDisconnection_WithoutServerState()
45+
{
46+
// Check interactivity
47+
Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
48+
Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
49+
Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
50+
51+
// Store a reference to an element to detect page reload
52+
// When the page reloads, this element reference will become stale
53+
var initialElement = Browser.Exists(By.Id("non-persisted-counter"));
54+
var initialConnectedLogCount = GetConnectedLogCount();
55+
56+
// Force close the connection
57+
// The client should get rejected on both reconnection and circuit resume because the server has no state
58+
var javascript = (IJavaScriptExecutor)Browser;
59+
javascript.ExecuteScript("Blazor._internal.forceCloseConnection()");
60+
61+
// Check for page reload using multiple conditions:
62+
// 1. Previously captured element is stale
63+
Browser.True(initialElement.IsStale);
64+
// 2. Counter state is reset
65+
Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
66+
// 3. WebSocket connection has been re-established
67+
Browser.True(() => GetConnectedLogCount() == initialConnectedLogCount + 1);
68+
69+
int GetConnectedLogCount() => Browser.Manage().Logs.GetLog(LogType.Browser)
70+
.Where(l => l.Level == LogLevel.Info && l.Message.Contains("Information: WebSocket connected")).Count();
71+
}
72+
73+
[Fact]
74+
public void CanResume_AfterClientPause_WithoutServerState()
75+
{
76+
// Initial state: NonPersistedCounter should be 5
77+
Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
78+
79+
// Increment both counters
80+
Browser.Exists(By.Id("increment-persistent-counter-count")).Click();
81+
Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
82+
83+
Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
84+
Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
85+
86+
var javascript = (IJavaScriptExecutor)Browser;
87+
TriggerClientPauseAndInteract(javascript);
88+
89+
// After first reconnection:
90+
Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
91+
Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
92+
93+
// Increment non-persisted counter again
94+
Browser.Exists(By.Id("increment-non-persisted-counter")).Click();
95+
Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
96+
97+
TriggerClientPauseAndInteract(javascript);
98+
99+
// After second reconnection:
100+
Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text);
101+
Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text);
102+
}
103+
104+
private void TriggerClientPauseAndInteract(IJavaScriptExecutor javascript)
105+
{
106+
var previousText = Browser.Exists(By.Id("persistent-counter-render")).Text;
107+
javascript.ExecuteScript("Blazor.pauseCircuit()");
108+
Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display"));
109+
110+
// Retry button should be hidden
111+
Browser.Equal(
112+
(false, true),
113+
() => Browser.Exists(
114+
() =>
115+
{
116+
var buttons = UseShadowRoot ?
117+
Browser.Exists(By.Id("components-reconnect-modal"))
118+
.GetShadowRoot()
119+
.FindElements(By.CssSelector(".components-reconnect-dialog button")) :
120+
Browser.Exists(By.Id("components-reconnect-modal"))
121+
.FindElements(By.CssSelector(".components-reconnect-container button"));
122+
123+
Assert.Equal(2, buttons.Count);
124+
return (buttons[0].Displayed, buttons[1].Displayed);
125+
},
126+
TimeSpan.FromSeconds(1)));
127+
128+
Browser.Exists(
129+
() =>
130+
{
131+
var buttons = UseShadowRoot ?
132+
Browser.Exists(By.Id("components-reconnect-modal"))
133+
.GetShadowRoot()
134+
.FindElements(By.CssSelector(".components-reconnect-dialog button")) :
135+
Browser.Exists(By.Id("components-reconnect-modal"))
136+
.FindElements(By.CssSelector(".components-reconnect-container button"));
137+
return buttons[1];
138+
},
139+
TimeSpan.FromSeconds(1)).Click();
140+
141+
// Then it should disappear
142+
Browser.Equal("none", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display"));
143+
144+
var newText = Browser.Exists(By.Id("persistent-counter-render")).Text;
145+
Assert.NotEqual(previousText, newText);
146+
147+
Browser.Exists(By.Id("increment-persistent-counter-count")).Click();
148+
}
149+
}
150+
151+
public class ServerReconnectionWithoutStateCustomUITest : ServerReconnectionWithoutStateTest
152+
{
153+
public ServerReconnectionWithoutStateCustomUITest(
154+
BrowserFixture browserFixture,
155+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>> serverFixture,
156+
ITestOutputHelper output)
157+
: base(browserFixture, serverFixture, output)
158+
{
159+
TestUrl = "/subdir/persistent-state/disconnection?custom-reconnect-ui=true";
160+
UseShadowRoot = false; // Custom UI does not use shadow DOM
161+
}
162+
163+
protected override void InitializeAsyncCore()
164+
{
165+
base.InitializeAsyncCore();
166+
Browser.Exists(By.CssSelector("#components-reconnect-modal[data-nosnippet]"));
167+
}
168+
}

src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public void ConfigureServices(IServiceCollection services)
5151
options.DisconnectedCircuitMaxRetained = 0;
5252
options.DetailedErrors = true;
5353
}
54+
if (Configuration.GetValue<bool>("DisableCircuitPersistence"))
55+
{
56+
// This disables the circuit persistence.
57+
// In combination with DisableReconnectionCache this means that a disconnected client will always
58+
// be rejected on reconnection/resume attempts.
59+
options.PersistedCircuitInMemoryMaxRetained = 0;
60+
options.DetailedErrors = true;
61+
}
5462
options.RootComponents.RegisterForJavaScript<TestContentPackage.PersistentComponents.ComponentWithPersistentState>("dynamic-js-root-counter");
5563
})
5664
.AddAuthenticationStateSerialization(options =>

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
<p class="components-pause-visible">
2626
The session has been paused by the server.
2727
</p>
28-
<button id="components-resume-button" class="components-pause-visible">
29-
Resume
30-
</button>
3128
<p class="components-resume-failed-visible">
32-
Failed to resume the session.<br />Please reload the page.
29+
Failed to resume the session.<br />Please retry or reload the page.
3330
</p>
31+
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
32+
Resume
33+
</button>
3434
</div>
3535
</dialog>

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async function resume() {
5252
location.reload();
5353
}
5454
} catch {
55-
location.reload();
55+
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
5656
}
5757
}
5858

0 commit comments

Comments
 (0)