Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 3 additions & 2 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,10 @@ public async ValueTask<string> 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;
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/Components/Server/test/Circuits/ComponentHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>()), Times.Once());
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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...';
Expand All @@ -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...';
Expand All @@ -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.<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';
}
}
Expand All @@ -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();
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
}

Expand All @@ -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 });
}
}

Expand Down
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;
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<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
Expand Up @@ -51,6 +51,14 @@ public void ConfigureServices(IServiceCollection services)
options.DisconnectedCircuitMaxRetained = 0;
options.DetailedErrors = true;
}
if (Configuration.GetValue<bool>("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;
}
options.RootComponents.RegisterForJavaScript<TestContentPackage.PersistentComponents.ComponentWithPersistentState>("dynamic-js-root-counter");
})
.AddAuthenticationStateSerialization(options =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async function resume() {
location.reload();
}
} catch {
location.reload();
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}

Expand Down
Loading