Add enhancement to default behavior of client reconnection (#24992)

These changes are to improve the default reconnection behavior of the client

- [x] Match the reconnection time with server side
- [x] Add indicator to know at which reconnection attempt we currently are
- [x] [Additional] Add a loader symbol
- [x] Add client side test

Addresses #18745
This commit is contained in:
Juan Barahona 2020-08-20 17:12:04 -04:00 committed by GitHub
parent c181218a4e
commit 601fc20ece
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 121 additions and 20 deletions

File diff suppressed because one or more lines are too long

View File

@ -33,8 +33,8 @@ const defaultOptions: CircuitStartOptions = {
configureSignalR: (_) => { },
logLevel: LogLevel.Warning,
reconnectionOptions: {
maxRetries: 5,
retryIntervalMilliseconds: 3000,
maxRetries: 8,
retryIntervalMilliseconds: 20000,
dialogId: 'components-reconnect-modal',
},
};

View File

@ -12,9 +12,12 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
reloadParagraph: HTMLParagraphElement;
constructor(dialogId: string, private readonly document: Document, private readonly logger: Logger) {
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',
@ -37,6 +40,9 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
this.message = this.modal.querySelector('h5')!;
this.button = this.modal.querySelector('button')!;
this.reloadParagraph = this.modal.querySelector('p')!;
this.loader = this.getLoader();
this.message.after(this.loader);
this.button.addEventListener('click', async () => {
this.show();
@ -65,6 +71,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
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...';
@ -78,6 +85,10 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
}, 0);
}
update(currentAttempt: number): void {
this.message.textContent = `Attempting to reconnect to the server: ${currentAttempt} of ${this.maxRetries}`;
}
hide(): void {
this.modal.style.display = 'none';
}
@ -85,6 +96,7 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
failed(): void {
this.button.style.display = 'block';
this.reloadParagraph.style.display = 'none';
this.loader.style.display = 'none';
this.message.innerHTML = 'Reconnection failed. Try <a href>reloading</a> the page if you\'re unable to reconnect.';
this.message.querySelector('a')!.addEventListener('click', () => location.reload());
}
@ -92,7 +104,32 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
rejected(): void {
this.button.style.display = 'none';
this.reloadParagraph.style.display = 'none';
this.loader.style.display = 'none';
this.message.innerHTML = 'Could not reconnect to the server. <a href>Reload</a> the page to restore functionality.';
this.message.querySelector('a')!.addEventListener('click', () => location.reload());
}
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;
}
}

View File

@ -20,8 +20,8 @@ export class DefaultReconnectionHandler implements ReconnectionHandler {
if (!this._reconnectionDisplay) {
const modal = document.getElementById(options.dialogId);
this._reconnectionDisplay = modal
? new UserSpecifiedDisplay(modal)
: new DefaultReconnectDisplay(options.dialogId, document, this._logger);
? new UserSpecifiedDisplay(modal, options.maxRetries, document)
: new DefaultReconnectDisplay(options.dialogId, options.maxRetries, document, this._logger);
}
if (!this._currentReconnectionProcess) {
@ -38,6 +38,8 @@ export class DefaultReconnectionHandler implements ReconnectionHandler {
};
class ReconnectionProcess {
static readonly MaximumFirstRetryInterval = 3000;
readonly reconnectDisplay: ReconnectDisplay;
isDisposed = false;
@ -54,7 +56,13 @@ class ReconnectionProcess {
async attemptPeriodicReconnection(options: ReconnectionOptions) {
for (let i = 0; i < options.maxRetries; i++) {
await this.delay(options.retryIntervalMilliseconds);
this.reconnectDisplay.update(i + 1);
const delayDuration = i == 0 && options.retryIntervalMilliseconds > ReconnectionProcess.MaximumFirstRetryInterval
? ReconnectionProcess.MaximumFirstRetryInterval
: options.retryIntervalMilliseconds;
await this.delay(delayDuration);
if (this.isDisposed) {
break;
}

View File

@ -1,5 +1,6 @@
export interface ReconnectDisplay {
show(): void;
update(currentAttempt: number): void;
hide(): void;
failed(): void;
rejected(): void;

View File

@ -8,7 +8,18 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
static readonly RejectedClassName = 'components-reconnect-rejected';
constructor(private dialog: HTMLElement) {
static readonly MaxRetriesId = 'components-reconnect-max-retries';
static readonly CurrentAttemptId = 'components-reconnect-current-attempt';
constructor(private dialog: HTMLElement, private readonly maxRetries: number, private readonly document: Document) {
this.document = document;
const maxRetriesElement = this.document.getElementById(UserSpecifiedDisplay.MaxRetriesId);
if (maxRetriesElement) {
maxRetriesElement.innerText = this.maxRetries.toString();
}
}
show(): void {
@ -16,6 +27,14 @@ export class UserSpecifiedDisplay implements ReconnectDisplay {
this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName);
}
update(currentAttempt: number): void {
const currentAttemptElement = this.document.getElementById(UserSpecifiedDisplay.CurrentAttemptId);
if (currentAttemptElement) {
currentAttemptElement.innerText = currentAttempt.toString();
}
}
hide(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.HideClassName);

View File

@ -3,10 +3,18 @@ import { JSDOM } from 'jsdom';
import { NullLogger } from '../src/Platform/Logging/Loggers';
describe('DefaultReconnectDisplay', () => {
let testDocument: Document;
beforeEach(() => {
const window = new JSDOM().window;
//JSDOM does not support animate function so we need to mock it
window.HTMLDivElement.prototype.animate = jest.fn();
testDocument = window.document;
})
it ('adds element to the body on show', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument, NullLogger.instance);
const display = new DefaultReconnectDisplay('test-dialog-id', 6, testDocument, NullLogger.instance);
display.show();
@ -16,6 +24,7 @@ describe('DefaultReconnectDisplay', () => {
expect(element!.style.display).toBe('block');
expect(element!.style.visibility).toBe('hidden');
expect(display.loader.style.display).toBe('inline-block');
expect(display.message.textContent).toBe('Attempting to reconnect to the server...');
expect(display.button.style.display).toBe('none');
@ -27,8 +36,7 @@ describe('DefaultReconnectDisplay', () => {
});
it ('does not add element to the body multiple times', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument, NullLogger.instance);
const display = new DefaultReconnectDisplay('test-dialog-id', 6, testDocument, NullLogger.instance);
display.show();
display.show();
@ -37,8 +45,7 @@ describe('DefaultReconnectDisplay', () => {
});
it ('hides element', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument, NullLogger.instance);
const display = new DefaultReconnectDisplay('test-dialog-id', 6, testDocument, NullLogger.instance);
display.hide();
@ -46,8 +53,7 @@ describe('DefaultReconnectDisplay', () => {
});
it ('updates message on fail', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument, NullLogger.instance);
const display = new DefaultReconnectDisplay('test-dialog-id', 6, testDocument, NullLogger.instance);
display.show();
display.failed();
@ -55,11 +61,11 @@ describe('DefaultReconnectDisplay', () => {
expect(display.modal.style.display).toBe('block');
expect(display.message.innerHTML).toBe('Reconnection failed. Try <a href=\"\">reloading</a> the page if you\'re unable to reconnect.');
expect(display.button.style.display).toBe('block');
expect(display.loader.style.display).toBe('none');
});
it ('updates message on refused', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument, NullLogger.instance);
const display = new DefaultReconnectDisplay('test-dialog-id', 6, testDocument, NullLogger.instance);
display.show();
display.rejected();
@ -67,6 +73,18 @@ describe('DefaultReconnectDisplay', () => {
expect(display.modal.style.display).toBe('block');
expect(display.message.innerHTML).toBe('Could not reconnect to the server. <a href=\"\">Reload</a> the page to restore functionality.');
expect(display.button.style.display).toBe('none');
expect(display.loader.style.display).toBe('none');
});
it('update message with current attempt', () => {
const maxRetires = 6;
const display = new DefaultReconnectDisplay('test-dialog-id', maxRetires, testDocument, NullLogger.instance);
display.show();
for (let index = 0; index < maxRetires; index++) {
display.update(index);
expect(display.message.innerHTML).toBe(`Attempting to reconnect to the server: ${index++} of ${maxRetires}`);
}
})
});

View File

@ -75,6 +75,23 @@ describe('DefaultReconnectionHandler', () => {
expect(testDisplay.failed).toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(2);
});
it('invokes update on each attempt', async () => {
const testDisplay = createTestDisplay();
const reconnect = jest.fn().mockRejectedValue(null);
const handler = new DefaultReconnectionHandler(NullLogger.instance, testDisplay, reconnect);
const maxRetries = 6;
handler.onConnectionDown({
maxRetries: maxRetries,
retryIntervalMilliseconds: 5,
dialogId: 'ignored'
});
await delay(500);
expect(testDisplay.update).toHaveBeenCalledTimes(maxRetries);
})
});
function attachUserSpecifiedUI(options: ReconnectionOptions): Element {
@ -92,6 +109,7 @@ function delay(durationMilliseconds: number) {
function createTestDisplay(): ReconnectDisplay {
return {
show: jest.fn(),
update: jest.fn(),
hide: jest.fn(),
failed: jest.fn(),
rejected: jest.fn()