From 2dc300d02e2251c45fd18b53a156f66e31e34f41 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 28 May 2024 09:43:44 -0700 Subject: [PATCH] [Blazor] Updated Blazor Server reconnect UI (#55723) # Updated Blazor Server reconnect UI Improves the default Blazor Server reconnect experience according to common customer feedback. ## Description Makes the following improvements to the Blazor Server reconnect UI: * Rather than waiting three seconds before attempting reconnection, then waiting an additional default of 20 seconds between successive attempts, the new default settings use a configurable exponential backoff strategy: * Retry as quickly as possible for the first 10 attempts * Retry every 5 seconds for the next 10 attempts * Retry every 30 seconds until reaching the user-configured max retry count * **Note**: Customers can configure their own retry interval calculation function to override the default behavior * When the user navigates back to the disconnected app from another app or browser tab, a reconnect attempt is immediately made * If the server can be reached, but reconnection fails because server disposed the circuit, a refresh occurs automatically * The default reconnect UI shows the number of seconds until the next reconnect attempt instead of the number of attempts remaining * The styling of the default reconnect UI has been modernized Fixes #55721 ## Customer Impact Customers of apps built using Blazor Server often complain about the reconnection experience, which has motivated Blazor developers to open issues like #32113 suggesting improvements to reduce the amount of time the customer spends looking at the reconnect UI. This PR addresses many of those common concerns by performing reconnection attempts more aggressively. Unless apps have overridden the default reconnection options, they will automatically get thew new reconnect behavior by upgrading to .NET 9. In addition, the default reconnect UI styling has been updated. Styling changes will not affect apps that have overridden the default reconnect UI. ## Regression? - [ ] Yes - [X] No ## Risk - [ ] High - [ ] Medium - [X] Low This change only affects the reconnection experience when using Blazor Server or Server interactivity in a Blazor Web App. We have existing automated tests verifying core reconnect functionality. It's possible that some customers may have been relying on the previous defaults, but they'll still be able to override the new defaults if desired. ## Verification - [X] Manual (required) - [X] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [X] N/A --- src/Components/Components.slnf | 1 + .../Platform/Circuits/CircuitStartOptions.ts | 27 +- .../Circuits/DefaultReconnectDisplay.ts | 385 +++++++++++------- .../Circuits/DefaultReconnectionHandler.ts | 85 +++- .../src/Platform/Circuits/ReconnectDisplay.ts | 2 +- .../Platform/Circuits/UserSpecifiedDisplay.ts | 20 +- .../Pages/_ServerHost.cshtml | 13 +- .../RazorComponents/App.razor | 7 + 8 files changed, 381 insertions(+), 159 deletions(-) diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 8988c4f77c6c..6da9c7c7e04d 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -75,6 +75,7 @@ "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj", "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj", "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj", + "src\\Hosting\\TestHost\\src\\Microsoft.AspNetCore.TestHost.csproj", "src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj", "src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj", "src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj", diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts index 935282a685f0..ac94b7a2d781 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitStartOptions.ts @@ -34,8 +34,8 @@ export function resolveOptions(userOptions?: Partial): Circ } export interface ReconnectionOptions { - maxRetries: number; - retryIntervalMilliseconds: number; + maxRetries?: number; + retryIntervalMilliseconds: number | ((previousAttempts: number, maxRetries?: number) => number | undefined | null); dialogId: string; } @@ -49,6 +49,25 @@ export interface ReconnectionHandler { onConnectionUp(): void; } +function computeDefaultRetryInterval(previousAttempts: number, maxRetries?: number): number | null { + if (maxRetries && previousAttempts >= maxRetries) { + return null; + } + + if (previousAttempts < 10) { + // Retry as quickly as possible for the first 10 tries + return 0; + } + + if (previousAttempts < 20) { + // Retry every 5 seconds for the next 10 tries + return 5000; + } + + // Then retry every 30 seconds indefinitely + return 30000; +} + const defaultOptions: CircuitStartOptions = { // eslint-disable-next-line @typescript-eslint/no-empty-function configureSignalR: (_) => { }, @@ -56,8 +75,8 @@ const defaultOptions: CircuitStartOptions = { initializers: undefined!, circuitHandlers: [], reconnectionOptions: { - maxRetries: 8, - retryIntervalMilliseconds: 20000, + maxRetries: 30, + retryIntervalMilliseconds: computeDefaultRetryInterval, dialogId: 'components-reconnect-modal', }, }; diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts index 00c3b1c04d7a..7a6b79027f66 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectDisplay.ts @@ -1,166 +1,277 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { ReconnectDisplay } from './ReconnectDisplay'; -import { Logger, LogLevel } from '../Logging/Logger'; import { Blazor } from '../../GlobalExports'; +import { LogLevel, Logger } from '../Logging/Logger'; +import { ReconnectDisplay } from './ReconnectDisplay'; export class DefaultReconnectDisplay implements ReconnectDisplay { - modal: HTMLDivElement; - - message: HTMLHeadingElement; - - button: HTMLButtonElement; - - reloadParagraph: HTMLParagraphElement; - - loader: HTMLDivElement; - - constructor(dialogId: string, private readonly maxRetries: number, private readonly document: Document, private readonly logger: Logger) { - this.modal = this.document.createElement('div'); - this.modal.id = dialogId; - this.maxRetries = maxRetries; - - const modalStyles = [ - 'position: fixed', - 'top: 0', - 'right: 0', - 'bottom: 0', - 'left: 0', - 'z-index: 1050', - 'display: none', - 'overflow: hidden', - 'background-color: #fff', - 'opacity: 0.8', - 'text-align: center', - 'font-weight: bold', - 'transition: visibility 0s linear 500ms', - ]; - - this.modal.style.cssText = modalStyles.join(';'); - - this.message = this.document.createElement('h5') as HTMLHeadingElement; - this.message.style.cssText = 'margin-top: 20px'; - - this.button = this.document.createElement('button') as HTMLButtonElement; - this.button.style.cssText = 'margin:5px auto 5px'; - this.button.textContent = 'Retry'; - - const link = this.document.createElement('a'); - link.addEventListener('click', () => location.reload()); - link.textContent = 'reload'; - - this.reloadParagraph = this.document.createElement('p') as HTMLParagraphElement; - this.reloadParagraph.textContent = 'Alternatively, '; - this.reloadParagraph.appendChild(link); - - this.modal.appendChild(this.message); - this.modal.appendChild(this.button); - this.modal.appendChild(this.reloadParagraph); - - this.loader = this.getLoader(); - - this.message.after(this.loader); - - this.button.addEventListener('click', async () => { - this.show(); - - try { - // reconnect will asynchronously return: - // - true to mean success - // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) - // - exception to mean we didn't reach the server (this can be sync or async) - const successful = await Blazor.reconnect!(); - if (!successful) { - this.rejected(); - } - } catch (err: unknown) { - // We got an exception, server is currently unavailable - this.logger.log(LogLevel.Error, err as Error); - this.failed(); + static readonly ReconnectOverlayClassName = 'components-reconnect-overlay'; + + static readonly ReconnectDialogClassName = 'components-reconnect-dialog'; + + static readonly ReconnectVisibleClassName = 'components-reconnect-visible'; + + static readonly RejoiningAnimationClassName = 'components-rejoining-animation'; + + static readonly AnimationRippleCount = 2; + + style: HTMLStyleElement; + + overlay: HTMLDivElement; + + dialog: HTMLDivElement; + + rejoiningAnimation: HTMLDivElement; + + reloadButton: HTMLButtonElement; + + status: HTMLParagraphElement; + + retryWhenDocumentBecomesVisible: () => void; + + constructor(dialogId: string, private readonly document: Document, private readonly logger: Logger) { + this.style = this.document.createElement('style'); + this.style.innerHTML = DefaultReconnectDisplay.Css; + + this.overlay = this.document.createElement('div'); + this.overlay.className = DefaultReconnectDisplay.ReconnectOverlayClassName; + this.overlay.id = dialogId; + + this.dialog = this.document.createElement('div'); + this.dialog.className = DefaultReconnectDisplay.ReconnectDialogClassName; + + this.rejoiningAnimation = document.createElement('div'); + this.rejoiningAnimation.className = DefaultReconnectDisplay.RejoiningAnimationClassName; + + for (let i = 0; i < DefaultReconnectDisplay.AnimationRippleCount; i++) { + const ripple = document.createElement('div'); + this.rejoiningAnimation.appendChild(ripple); + } + + this.status = document.createElement('p'); + this.status.innerHTML = ''; + + this.reloadButton = document.createElement('button'); + this.reloadButton.style.display = 'none'; + this.reloadButton.innerHTML = 'Retry'; + this.reloadButton.addEventListener('click', this.retry.bind(this)); + + this.dialog.appendChild(this.rejoiningAnimation); + this.dialog.appendChild(this.status); + this.dialog.appendChild(this.reloadButton); + + this.overlay.appendChild(this.dialog); + + this.retryWhenDocumentBecomesVisible = () => { + if (this.document.visibilityState === 'visible') { + this.retry(); } - }); + }; } show(): void { - if (!this.document.contains(this.modal)) { - this.document.body.appendChild(this.modal); - } - this.modal.style.display = 'block'; - this.loader.style.display = 'inline-block'; - this.button.style.display = 'none'; - this.reloadParagraph.style.display = 'none'; - this.message.textContent = 'Attempting to reconnect to the server...'; - - // The visibility property has a transition so it takes effect after a delay. - // This is to prevent it appearing momentarily when navigating away. For the - // transition to take effect, we have to apply the visibility asynchronously. - this.modal.style.visibility = 'hidden'; - setTimeout(() => { - this.modal.style.visibility = 'visible'; - }, 0); + if (!this.document.contains(this.overlay)) { + this.document.body.appendChild(this.overlay); + } + + if (!this.document.contains(this.style)) { + this.document.body.appendChild(this.style); + } + + this.reloadButton.style.display = 'none'; + this.rejoiningAnimation.style.display = 'block'; + this.status.innerHTML = 'Rejoining the server...'; + this.overlay.style.display = 'block'; + this.overlay.classList.add(DefaultReconnectDisplay.ReconnectVisibleClassName); } - update(currentAttempt: number): void { - this.message.textContent = `Attempting to reconnect to the server: ${currentAttempt} of ${this.maxRetries}`; + update(currentAttempt: number, secondsToNextAttempt: number): void { + if (currentAttempt === 1 || secondsToNextAttempt === 0) { + this.status.innerHTML = 'Rejoining the server...'; + } else { + const unitText = secondsToNextAttempt === 1 ? 'second' : 'seconds'; + this.status.innerHTML = `Rejoin failed... trying again in ${secondsToNextAttempt} ${unitText}`; + } } hide(): void { - this.modal.style.display = 'none'; + this.overlay.style.display = 'none'; + this.overlay.classList.remove(DefaultReconnectDisplay.ReconnectVisibleClassName); } failed(): void { - this.button.style.display = 'block'; - this.reloadParagraph.style.display = 'none'; - this.loader.style.display = 'none'; + this.reloadButton.style.display = 'block'; + this.rejoiningAnimation.style.display = 'none'; + this.status.innerHTML = 'Failed to rejoin.
Please retry or reload the page.'; + this.document.addEventListener('visibilitychange', this.retryWhenDocumentBecomesVisible); + } + + rejected(): void { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + location.reload(); + } + + private async retry() { + this.document.removeEventListener('visibilitychange', this.retryWhenDocumentBecomesVisible); + this.show(); + + try { + // reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect!(); + if (!successful) { + this.rejected(); + } + } catch (err: unknown) { + // We got an exception, server is currently unavailable + this.logger.log(LogLevel.Error, err as Error); + this.failed(); + } + } - const errorDescription = this.document.createTextNode('Reconnection failed. Try '); + static readonly Css = ` + .${this.ReconnectOverlayClassName} { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 10000; + display: none; + overflow: hidden; + animation: components-reconnect-fade-in; + } - const link = this.document.createElement('a'); - link.textContent = 'reloading'; - link.setAttribute('href', ''); - link.addEventListener('click', () => location.reload()); + .${this.ReconnectOverlayClassName}.${this.ReconnectVisibleClassName} { + display: block; + } - const errorInstructions = this.document.createTextNode(' the page if you\'re unable to reconnect.'); + .${this.ReconnectOverlayClassName}::before { + content: ''; + background-color: rgba(0, 0, 0, 0.4); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + animation: components-reconnect-fadeInOpacity 0.5s ease-in-out; + opacity: 1; + } - this.message.replaceChildren(errorDescription, link, errorInstructions); - } + .${this.ReconnectOverlayClassName} p { + margin: 0; + text-align: center; + } - rejected(): void { - this.button.style.display = 'none'; - this.reloadParagraph.style.display = 'none'; - this.loader.style.display = 'none'; + .${this.ReconnectOverlayClassName} button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; + } + + .${this.ReconnectOverlayClassName} button:hover { + background-color: #3b6ea2; + } - const errorDescription = this.document.createTextNode('Could not reconnect to the server. '); + .${this.ReconnectOverlayClassName} button:active { + background-color: #6b9ed2; + } - const link = this.document.createElement('a'); - link.textContent = 'Reload'; - link.setAttribute('href', ''); - link.addEventListener('click', () => location.reload()); + .${this.ReconnectDialogClassName} { + position: relative; + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + opacity: 0; + animation: components-reconnect-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-fadeInOpacity 0.5s ease-out 0.3s; + animation-fill-mode: forwards; + z-index: 10001; + } - const errorInstructions = this.document.createTextNode(' the page to restore functionality.'); + .${this.RejoiningAnimationClassName} { + display: block; + position: relative; + width: 80px; + height: 80px; + } - this.message.replaceChildren(errorDescription, link, errorInstructions); - } + .${this.RejoiningAnimationClassName} div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: ${this.RejoiningAnimationClassName} 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } - private getLoader(): HTMLDivElement { - const loader = this.document.createElement('div'); - - const loaderStyles = [ - 'border: 0.3em solid #f3f3f3', - 'border-top: 0.3em solid #3498db', - 'border-radius: 50%', - 'width: 2em', - 'height: 2em', - 'display: inline-block', - ]; - - loader.style.cssText = loaderStyles.join(';'); - loader.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { - duration: 2000, - iterations: Infinity, - }); - - return loader; - } + .${this.RejoiningAnimationClassName} div:nth-child(2) { + animation-delay: -0.5s; + } + + @keyframes ${this.RejoiningAnimationClassName} { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } + } + + @keyframes components-reconnect-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } + } + + @keyframes components-reconnect-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } + } + `; } diff --git a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts index 28e55adfbcf1..58750dce46e6 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/DefaultReconnectionHandler.ts @@ -27,8 +27,8 @@ export class DefaultReconnectionHandler implements ReconnectionHandler { if (!this._reconnectionDisplay) { const modal = document.getElementById(options.dialogId); this._reconnectionDisplay = modal - ? new UserSpecifiedDisplay(modal, options.maxRetries, document) - : new DefaultReconnectDisplay(options.dialogId, options.maxRetries, document, this._logger); + ? new UserSpecifiedDisplay(modal, document, options.maxRetries) + : new DefaultReconnectDisplay(options.dialogId, document, this._logger); } if (!this._currentReconnectionProcess) { @@ -63,13 +63,23 @@ class ReconnectionProcess { } async attemptPeriodicReconnection(options: ReconnectionOptions) { - for (let i = 0; i < options.maxRetries; i++) { - this.reconnectDisplay.update(i + 1); + for (let i = 0; options.maxRetries === undefined || i < options.maxRetries; i++) { + let retryInterval: number; + if (typeof(options.retryIntervalMilliseconds) === 'function') { + const computedRetryInterval = options.retryIntervalMilliseconds(i); + if (computedRetryInterval === null || computedRetryInterval === undefined) { + break; + } + retryInterval = computedRetryInterval; + } else { + retryInterval = i === 0 && options.retryIntervalMilliseconds > ReconnectionProcess.MaximumFirstRetryInterval + ? ReconnectionProcess.MaximumFirstRetryInterval + : options.retryIntervalMilliseconds; + } - const delayDuration = i === 0 && options.retryIntervalMilliseconds > ReconnectionProcess.MaximumFirstRetryInterval - ? ReconnectionProcess.MaximumFirstRetryInterval - : options.retryIntervalMilliseconds; - await this.delay(delayDuration); + await this.runTimer(retryInterval, /* intervalMs */ 1000, remainingMs => { + this.reconnectDisplay.update(i + 1, Math.round(remainingMs / 1000)); + }); if (this.isDisposed) { break; @@ -96,7 +106,62 @@ class ReconnectionProcess { this.reconnectDisplay.failed(); } - delay(durationMilliseconds: number): Promise { - return new Promise(resolve => setTimeout(resolve, durationMilliseconds)); + private async runTimer(totalTimeMs: number, intervalMs: number, callback: (remainingMs: number) => void): Promise { + if (totalTimeMs <= 0) { + callback(0); + return; + } + + let lastTime = Date.now(); + let timeoutId: unknown; + let resolveTimerPromise: () => void; + + callback(totalTimeMs); + + const step = () => { + if (this.isDisposed) { + // Stop invoking the callback after disposal. + resolveTimerPromise(); + return; + } + + const currentTime = Date.now(); + const deltaTime = currentTime - lastTime; + lastTime = currentTime; + + // Get the number of steps that should have passed have since the last + // call to "step". We expect this to be 1 in most cases, but it may + // be higher if something causes the timeout to get significantly + // delayed (e.g., the browser sleeps the tab). + const simulatedSteps = Math.max(1, Math.floor(deltaTime / intervalMs)); + const simulatedTime = intervalMs * simulatedSteps; + + totalTimeMs -= simulatedTime; + if (totalTimeMs < Number.EPSILON) { + callback(0); + resolveTimerPromise(); + return; + } + + const nextTimeout = Math.min(totalTimeMs, intervalMs - (deltaTime - simulatedTime)); + callback(totalTimeMs); + timeoutId = setTimeout(step, nextTimeout); + }; + + const stepIfDocumentIsVisible = () => { + // If the document becomes visible while the timeout is running, immediately + // invoke the callback. + if (document.visibilityState === 'visible') { + clearTimeout(timeoutId as number); + callback(0); + resolveTimerPromise(); + } + }; + + timeoutId = setTimeout(step, intervalMs); + + document.addEventListener('visibilitychange', stepIfDocumentIsVisible); + await new Promise(resolve => resolveTimerPromise = resolve); + document.removeEventListener('visibilitychange', stepIfDocumentIsVisible); } } diff --git a/src/Components/Web.JS/src/Platform/Circuits/ReconnectDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/ReconnectDisplay.ts index e319913044d2..d2d084169b5e 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/ReconnectDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/ReconnectDisplay.ts @@ -3,7 +3,7 @@ export interface ReconnectDisplay { show(): void; - update(currentAttempt: number): void; + update(currentAttempt: number, secondsToNextAttempt: number): void; hide(): void; failed(): void; rejected(): void; diff --git a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts index c4f05fb0cc73..e179d921cf3c 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/UserSpecifiedDisplay.ts @@ -15,13 +15,17 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { static readonly CurrentAttemptId = 'components-reconnect-current-attempt'; - constructor(private dialog: HTMLElement, private readonly maxRetries: number, private readonly document: Document) { + static readonly SecondsToNextAttemptId = 'components-seconds-to-next-attempt'; + + constructor(private dialog: HTMLElement, private readonly document: Document, maxRetries?: number) { this.document = document; - const maxRetriesElement = this.document.getElementById(UserSpecifiedDisplay.MaxRetriesId); + if (maxRetries !== undefined) { + const maxRetriesElement = this.document.getElementById(UserSpecifiedDisplay.MaxRetriesId); - if (maxRetriesElement) { - maxRetriesElement.innerText = this.maxRetries.toString(); + if (maxRetriesElement) { + maxRetriesElement.innerText = maxRetries.toString(); + } } } @@ -30,12 +34,18 @@ export class UserSpecifiedDisplay implements ReconnectDisplay { this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName); } - update(currentAttempt: number): void { + update(currentAttempt: number, secondsToNextAttempt: number): void { const currentAttemptElement = this.document.getElementById(UserSpecifiedDisplay.CurrentAttemptId); if (currentAttemptElement) { currentAttemptElement.innerText = currentAttempt.toString(); } + + const secondsToNextAttemptElement = this.document.getElementById(UserSpecifiedDisplay.SecondsToNextAttemptId); + + if (secondsToNextAttemptElement) { + secondsToNextAttemptElement.innerText = secondsToNextAttempt.toString(); + } } hide(): void { diff --git a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml index 08bc5c2a21a2..13c09111b590 100644 --- a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml +++ b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml @@ -49,8 +49,17 @@ document.body.append(element); } - - + + + diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index 717f269eb2fb..12be047b82c8 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -93,6 +93,13 @@ } }, }, + circuit: { + reconnectionOptions: { + // It's easier to test the reconnection logic if we wait a bit + // before attempting to reconnect + retryIntervalMilliseconds: 5000, + }, + }, }).then(() => appendHiddenParagraph('blazor-started')); }