Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -50,6 +50,15 @@ 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