diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 7455ef8c6f85..c8a698071c7a 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -316,9 +316,10 @@ public async ValueTask ResumeCircuit( persistedCircuitState = await _circuitPersistenceManager.ResumeCircuitAsync(circuitId, Context.ConnectionAborted); if (persistedCircuitState == null) { + // The circuit state cannot be retrieved. It might have been deleted or expired. + // We do not send an error to the client as this is a valid scenario + // that will be handled by the client reconnection logic. Log.InvalidInputData(_logger); - await NotifyClientError(Clients.Caller, "The circuit state could not be retrieved. It may have been deleted or expired."); - Context.Abort(); return null; } } diff --git a/src/Components/Server/test/Circuits/ComponentHubTest.cs b/src/Components/Server/test/Circuits/ComponentHubTest.cs index 6582f7d40a7d..e970cdd2f4d2 100644 --- a/src/Components/Server/test/Circuits/ComponentHubTest.cs +++ b/src/Components/Server/test/Circuits/ComponentHubTest.cs @@ -246,8 +246,6 @@ public async Task CannotResumeAppWhenPersistedComponentStateIsNotAvailable() var circuitSecret = await hub.StartCircuit("https://localhost:5000", "https://localhost:5000/subdir", "{}", null); var result = await hub.ResumeCircuit(circuitSecret, "https://localhost:5000", "https://localhost:5000/subdir", "[]", ""); Assert.Null(result); - var errorMessage = "The circuit state could not be retrieved. It may have been deleted or expired."; - mockClientProxy.Verify(m => m.SendCoreAsync("JS.Error", new[] { errorMessage }, It.IsAny()), Times.Once()); } [Fact] diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts index a14aa03363c7..4d424447ce90 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts @@ -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...'; @@ -129,12 +132,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.
Please retry or reload the page.'; this.document.addEventListener('visibilitychange', this.retryWhenDocumentBecomesVisible); } else { - this.status.innerHTML = 'Failed to resume the session.
Please reload the page.'; - this.resumeButton.style.display = 'none'; + this.status.innerHTML = 'Failed to resume the session.
Please retry or reload the page.'; + this.resumeButton.style.display = 'block'; this.reloadButton.style.display = 'none'; } } @@ -157,7 +161,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(); @@ -178,7 +181,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay { // - exception to mean we didn't reach the server (this can be sync or async) const successful = await Blazor.resumeCircuit!(); if (!successful) { - this.failed(); + this.rejected(); } } catch (err: unknown) { // We got an exception, server is currently unavailable diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts index 31528e850a41..a1db061fcd62 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts @@ -126,13 +126,12 @@ class ReconnectionProcess { if (!result) { // Try to resume the circuit if the reconnect failed // If the server responded and refused to reconnect, stop auto-retrying. - this.reconnectDisplay.update({ type: 'pause', remote: true }); const resumeResult = await this.resumeCallback(); if (resumeResult) { return; } - this.reconnectDisplay.failed(); + this.reconnectDisplay.rejected(); return; } return; diff --git a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts index 01106b9a6665..6438168fac6e 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts @@ -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 }); } } @@ -90,7 +92,7 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { this.dispatchReconnectStateChangedEvent({ state: 'failed' }); } else { this.dialog.classList.add(UserSpecifiedDisplay.ResumeFailedClassName); - this.dispatchReconnectStateChangedEvent({ state: 'resume-failed' }); + this.dispatchReconnectStateChangedEvent({ state: 'resume-failed', remote: this.remote }); } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionWithoutStateTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionWithoutStateTest.cs new file mode 100644 index 000000000000..277676870469 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerReconnectionWithoutStateTest.cs @@ -0,0 +1,181 @@ +// 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; +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; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests; + +public class ServerReconnectionWithoutStateTest : ServerTestBase>> +{ + public ServerReconnectionWithoutStateTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> 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(() => IsElementStale(initialElement)); + // 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(); + } + + private static bool IsElementStale(IWebElement element) + { + try + { + _ = element.Enabled; + return false; + } + catch (StaleElementReferenceException) + { + return true; + } + } +} + +public class ServerReconnectionWithoutStateCustomUITest : ServerReconnectionWithoutStateTest +{ + public ServerReconnectionWithoutStateCustomUITest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> 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]")); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index 861e0fbdf288..4751d429cf15 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -50,6 +50,14 @@ public void ConfigureServices(IServiceCollection services) options.DisconnectedCircuitMaxRetained = 0; options.DetailedErrors = true; } + if (Configuration.GetValue("DisableCircuitPersistence")) + { + // This disables the circuit persistence. + // In combination with DisableReconnectionCache this means that a disconnected client will always + // be rejected on reconnection/resume attempts. + options.PersistedCircuitInMemoryMaxRetained = 0; + options.DetailedErrors = true; + } }) .AddAuthenticationStateSerialization(options => { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor index a85217a1132b..44f63561f43a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor @@ -25,11 +25,11 @@

The session has been paused by the server.

-

- Failed to resume the session.
Please reload the page. + Failed to resume the session.
Please retry or reload the page.

+ diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor.js b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor.js index e52a190bacbb..a44de78d836d 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor.js +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Layout/ReconnectModal.razor.js @@ -52,7 +52,7 @@ async function resume() { location.reload(); } } catch { - location.reload(); + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); } }