Skip to content

Commit 33839dc

Browse files
authored
Client reconnects when state's available on the server (#7395)
Reconnect if we have state on the server Fixes #7537
1 parent d09c6e8 commit 33839dc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+7169
-780
lines changed

src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
</Target>
3939

4040
<ItemGroup>
41-
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
41+
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
4242
<Reference Include="Microsoft.AspNetCore.Components" />
4343
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
4444
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />

src/Components/Browser.JS/src/Microsoft.AspNetCore.Components.Browser.JS.npmproj renamed to src/Components/Browser.JS/Microsoft.AspNetCore.Components.Browser.JS.npmproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" />
33

44
<PropertyGroup>
5-
<IsTestProject>false</IsTestProject>
5+
<IsTestProject>true</IsTestProject>
66
<IsPackable>false</IsPackable>
77
</PropertyGroup>
88

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
module.exports = {
5+
globals: {
6+
"ts-jest": {
7+
"tsConfig": "./tests/tsconfig.json",
8+
"babeConfig": true,
9+
"diagnostics": true
10+
}
11+
},
12+
preset: 'ts-jest',
13+
testEnvironment: 'jsdom'
14+
};

src/Components/Browser.JS/src/package.json renamed to src/Components/Browser.JS/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55
"description": "",
66
"main": "index.js",
77
"scripts": {
8-
"build:debug": "webpack --mode development",
9-
"build:production": "webpack --mode production",
10-
"test": "echo \"Error: no test specified\" && exit 1"
8+
"build:debug": "cd src && webpack --mode development --config ./webpack.config.js",
9+
"build:production": "cd src && webpack --mode production --config ./webpack.config.js",
10+
"test": "jest"
1111
},
1212
"devDependencies": {
1313
"@aspnet/signalr": "^1.0.0",
1414
"@aspnet/signalr-protocol-msgpack": "^1.0.0",
1515
"@dotnet/jsinterop": "^0.1.1",
16+
"@types/jsdom": "11.0.6",
17+
"@types/jest": "^24.0.6",
1618
"@types/emscripten": "0.0.31",
19+
"jest": "^24.1.0",
20+
"ts-jest": "^24.0.0",
1721
"ts-loader": "^4.4.1",
1822
"typescript": "^2.9.2",
1923
"webpack": "^4.12.0",

src/Components/Browser.JS/src/Boot.Server.ts

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,43 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
66
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
77
import { renderBatch } from './Rendering/Renderer';
88
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon';
9+
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
10+
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';
911

10-
let connection : signalR.HubConnection;
12+
async function boot() {
13+
const circuitHandlers: CircuitHandler[] = [ new AutoReconnectCircuitHandler() ];
14+
window['Blazor'].circuitHandlers = circuitHandlers;
1115

12-
function boot() {
1316
// In the background, start loading the boot config and any embedded resources
1417
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
1518
return loadEmbeddedResourcesAsync(bootConfig);
1619
});
1720

18-
connection = new signalR.HubConnectionBuilder()
21+
const initialConnection = await initializeConnection(circuitHandlers);
22+
23+
// Ensure any embedded resources have been loaded before starting the app
24+
await embeddedResourcesPromise;
25+
const circuitId = await initialConnection.invoke<string>(
26+
'StartCircuit',
27+
uriHelperFunctions.getLocationHref(),
28+
uriHelperFunctions.getBaseURI()
29+
);
30+
31+
window['Blazor'].reconnect = async () => {
32+
const reconnection = await initializeConnection(circuitHandlers);
33+
if (!(await reconnection.invoke<Boolean>('ConnectCircuit', circuitId))) {
34+
return false;
35+
}
36+
37+
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
38+
return true;
39+
};
40+
41+
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
42+
}
43+
44+
async function initializeConnection(circuitHandlers: CircuitHandler[]): Promise<signalR.HubConnection> {
45+
const connection = new signalR.HubConnectionBuilder()
1946
.withUrl('_blazor')
2047
.withHubProtocol(new MessagePackHubProtocol())
2148
.configureLogging(signalR.LogLevel.Information)
@@ -33,40 +60,31 @@ function boot() {
3360
}
3461
});
3562

36-
connection.on('JS.Error', unhandledError);
37-
38-
connection.start()
39-
.then(async () => {
40-
DotNet.attachDispatcher({
41-
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
42-
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
43-
}
44-
});
45-
46-
// Ensure any embedded resources have been loaded before starting the app
47-
await embeddedResourcesPromise;
48-
49-
connection.send(
50-
'StartCircuit',
51-
uriHelperFunctions.getLocationHref(),
52-
uriHelperFunctions.getBaseURI()
53-
);
54-
})
55-
.catch(unhandledError);
56-
57-
// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339
58-
// This will be replaced once we implement proper connection management (reconnects, etc.)
59-
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
63+
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
64+
connection.on('JS.Error', error => unhandledError(connection, error));
65+
66+
window['Blazor']._internal.forceCloseConnection = () => connection.stop();
67+
68+
try {
69+
await connection.start();
70+
} catch (ex) {
71+
unhandledError(connection, ex);
72+
}
73+
74+
DotNet.attachDispatcher({
75+
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
76+
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
77+
}
78+
});
79+
80+
return connection;
6081
}
6182

62-
function unhandledError(err) {
83+
function unhandledError(connection: signalR.HubConnection, err: Error) {
6384
console.error(err);
6485

6586
// Disconnect on errors.
6687
//
67-
// TODO: it would be nice to have some kind of experience for what happens when you're
68-
// trying to interact with an app that's disconnected.
69-
//
7088
// Trying to call methods on the connection after its been closed will throw.
7189
if (connection) {
7290
connection.stop();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { CircuitHandler } from './CircuitHandler';
2+
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';
3+
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
4+
import { ReconnectDisplay } from './ReconnectDisplay';
5+
export class AutoReconnectCircuitHandler implements CircuitHandler {
6+
static readonly MaxRetries = 5;
7+
static readonly RetryInterval = 3000;
8+
static readonly DialogId = 'components-reconnect-modal';
9+
reconnectDisplay: ReconnectDisplay;
10+
11+
constructor() {
12+
this.reconnectDisplay = new DefaultReconnectDisplay(document);
13+
document.addEventListener('DOMContentLoaded', () => {
14+
const modal = document.getElementById(AutoReconnectCircuitHandler.DialogId);
15+
if (modal) {
16+
this.reconnectDisplay = new UserSpecifiedDisplay(modal);
17+
}
18+
});
19+
}
20+
onConnectionUp() : void{
21+
this.reconnectDisplay.hide();
22+
}
23+
24+
delay() : Promise<void>{
25+
return new Promise((resolve) => setTimeout(resolve, AutoReconnectCircuitHandler.RetryInterval));
26+
}
27+
28+
async onConnectionDown() : Promise<void> {
29+
this.reconnectDisplay.show();
30+
31+
for (let i = 0; i < AutoReconnectCircuitHandler.MaxRetries; i++) {
32+
await this.delay();
33+
try {
34+
const result = await window['Blazor'].reconnect();
35+
if (!result) {
36+
// If the server responded and refused to reconnect, stop auto-retrying.
37+
break;
38+
}
39+
return;
40+
} catch (err) {
41+
console.error(err);
42+
}
43+
}
44+
45+
this.reconnectDisplay.failed();
46+
}
47+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface CircuitHandler {
2+
/** Invoked when a server connection is established or re-established after a connection failure.
3+
*/
4+
onConnectionUp?() : void;
5+
6+
/** Invoked when a server connection is dropped.
7+
* @param {Error} error Optionally argument containing the error that caused the connection to close (if any).
8+
*/
9+
onConnectionDown?(error?: Error): void;
10+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ReconnectDisplay } from "./ReconnectDisplay";
2+
import { AutoReconnectCircuitHandler } from "./AutoReconnectCircuitHandler";
3+
export class DefaultReconnectDisplay implements ReconnectDisplay {
4+
modal: HTMLDivElement;
5+
message: HTMLHeadingElement;
6+
button: HTMLButtonElement;
7+
addedToDom: boolean = false;
8+
constructor(private document: Document) {
9+
this.modal = this.document.createElement('div');
10+
this.modal.id = AutoReconnectCircuitHandler.DialogId;
11+
12+
const modalStyles = [
13+
"position: fixed",
14+
"top: 0",
15+
"right: 0",
16+
"bottom: 0",
17+
"left: 0",
18+
"z-index: 1000",
19+
"display: none",
20+
"overflow: hidden",
21+
"background-color: #fff",
22+
"opacity: 0.8",
23+
"text-align: center",
24+
"font-weight: bold"
25+
];
26+
27+
this.modal.style.cssText = modalStyles.join(';');
28+
this.modal.innerHTML = '<h5 style="margin-top: 20px"></h5><button style="margin:5px auto 5px">Retry?</button>';
29+
this.message = this.modal.querySelector('h5')!;
30+
this.button = this.modal.querySelector('button')!;
31+
32+
this.button.addEventListener('click', () => window['Blazor'].reconnect());
33+
}
34+
show(): void {
35+
if (!this.addedToDom) {
36+
this.addedToDom = true;
37+
this.document.body.appendChild(this.modal);
38+
}
39+
this.modal.style.display = 'block';
40+
this.button.style.display = 'none';
41+
this.message.textContent = 'Attempting to reconnect to the server...';
42+
}
43+
hide(): void {
44+
this.modal.style.display = 'none';
45+
}
46+
failed(): void {
47+
this.button.style.display = 'block';
48+
this.message.textContent = 'Failed to reconnect to the server.';
49+
}
50+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface ReconnectDisplay {
2+
show(): void;
3+
hide(): void;
4+
failed(): void;
5+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ReconnectDisplay } from "./ReconnectDisplay";
2+
export class UserSpecifiedDisplay implements ReconnectDisplay {
3+
static readonly ShowClassName = 'components-reconnect-show';
4+
static readonly HideClassName = 'components-reconnect-hide';
5+
static readonly FailedClassName = 'components-reconnect-failed';
6+
constructor(private dialog: HTMLElement) {
7+
}
8+
show(): void {
9+
this.removeClasses();
10+
this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName);
11+
}
12+
hide(): void {
13+
this.removeClasses();
14+
this.dialog.classList.add(UserSpecifiedDisplay.HideClassName);
15+
}
16+
failed(): void {
17+
this.removeClasses();
18+
this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName);
19+
}
20+
private removeClasses() {
21+
this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.HideClassName, UserSpecifiedDisplay.FailedClassName);
22+
}
23+
}

src/Components/Browser.JS/src/Services/UriHelper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import '@dotnet/jsinterop';
2+
13
let hasRegisteredEventListeners = false;
24

35
// Will be initialized once someone registers
Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
11
{
2-
"compilerOptions": {
3-
"noImplicitAny": false,
4-
"noEmitOnError": true,
5-
"removeComments": false,
6-
"sourceMap": true,
7-
"target": "es5",
8-
"lib": ["es2015", "dom"],
9-
"strict": true
10-
},
11-
"exclude": [
12-
"node_modules",
13-
"wwwroot"
14-
]
2+
"extends": "../tsconfig.base.json"
153
}

0 commit comments

Comments
 (0)