Skip to content
Open
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.
Comment on lines +319 to +321
Copy link

Copilot AI Jan 14, 2026

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."

Copilot uses AI. Check for mistakes.
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';
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resumeButton.style.display = 'none' is set in the show() method (line 99), but this may not be necessary since the button visibility should be controlled by the state transitions in update() and failed(). Adding this line creates redundancy and could make the code harder to maintain if button visibility logic needs to be changed in the future.

Copilot uses AI. Check for mistakes.
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 @@ -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';
}
Expand All @@ -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';
}
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ 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;
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;
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reconnect property was changed from private reconnect = false to public reconnect = true. This is a breaking change to the internal API. The default value change from false to true could affect existing behavior. Please ensure this change is intentional and that all code paths that rely on this property have been updated accordingly.

Copilot uses AI. Check for mistakes.

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

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;
Comment on lines +4 to +6
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file includes unused using directives. System.Collections.Generic and System.Text don't appear to be used in the code. These should be removed to keep the code clean.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These using directives for OpenQA.Selenium.BiDi.Communication and OpenQA.Selenium.DevTools don't appear to be used in the test file. Consider removing them to reduce clutter.

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects exactly 2 buttons to be present in the reconnect modal. This hard-coded expectation is fragile - if the UI is modified to add or remove buttons in the future, this test will break. Consider using more specific selectors to find the expected buttons (e.g., by ID) rather than relying on button count.

Copilot uses AI. Check for mistakes.
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">
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Resume button now has both components-pause-visible and components-resume-failed-visible classes, which means it will be displayed when either state is active. However, the CSS rules in ReconnectModal.razor.css show that these classes are mutually exclusive states (lines 14 and 15 in the CSS file control when each is shown). This could lead to the button being visible when it shouldn't be if both CSS classes are active simultaneously. Consider whether this multi-class approach aligns with the intended behavior or if separate buttons for pause and resume-failed states would be clearer.

Copilot uses AI. Check for mistakes.
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