Skip to content

Commit 8a50cd5

Browse files
[release/8.0] [Blazor] Close the circuit when all Blazor Server components are disposed (#50170)
# [Blazor] Close the circuit when all Blazor Server components are disposed Allows a Blazor Server circuit to close when all root Blazor Server components get dynamically removed from the page. ## Description The overall approach I've taken is: 1. Define what it means for the circuit to be in use (`WebRootComponentManager.hasAnyExistingOrPendingServerComponents()`): * There are interactive Blazor Server components on the page, or... * The initialization of an interactive Blazor Server component has started, but hasn't completed yet, or... * There are SSR'd components on the page that haven't been initialized for interactivity yet (consider stream rendering, where we don't activate new components until the response completes), but they have either a "Server" or "Auto" render mode 2. Determine the cases where a circuit's "in use" status may have changed: * After a render batch is applied (see `attachCircuitAfterRenderCallback` in `WebRootComponentManager.ts`) * An applied render batch may result in the creation/disposal of a root component * After an SSR update occurs, but before the first render batch is applied * Consider the case where an SSR'd component with a Server render mode gets removed from the page, but before the circuit has a chance to initialize 3. Decide what to do if the "in use" status may have changed (`WebRootComponentManager.circuitMayHaveNoRootComponents()`): * If the circuit is not in use: * Start a timeout with some configurable duration (`SsrStartOptions.circuitInactivityTimeoutMs`), if it hasn't started already * When the timeout expires, dispose the circuit * If the circuit is not in use: * Clear any existing timeout This PR also: - [X] Addresses a bug preventing Virtualize from working correctly when a WebAssembly and Server instance is present on the page at the same time - [X] Adds E2E tests Fixes #48765
1 parent 9781991 commit 8a50cd5

26 files changed

+810
-332
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.web.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,78 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
import { DotNet } from '@microsoft/dotnet-js-interop';
54
import { Blazor } from './GlobalExports';
6-
import { HubConnectionBuilder, HubConnection, HttpTransportType } from '@microsoft/signalr';
7-
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';
8-
import { showErrorNotification } from './BootErrors';
9-
import { RenderQueue } from './Platform/Circuits/RenderQueue';
105
import { ConsoleLogger } from './Platform/Logging/Loggers';
11-
import { LogLevel, Logger } from './Platform/Logging/Logger';
12-
import { CircuitDescriptor } from './Platform/Circuits/CircuitManager';
6+
import { LogLevel } from './Platform/Logging/Logger';
7+
import { CircuitManager } from './Platform/Circuits/CircuitManager';
138
import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
149
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
15-
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
1610
import { discoverPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
17-
import { sendJSDataStream } from './Platform/Circuits/CircuitStreamingInterop';
1811
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.Server';
19-
import { WebRendererId } from './Rendering/WebRendererId';
2012
import { RootComponentManager } from './Services/RootComponentManager';
2113

22-
let renderingFailed = false;
23-
let hasStarted = false;
24-
let connection: HubConnection;
25-
let circuit: CircuitDescriptor;
26-
let dispatcher: DotNet.ICallDispatcher;
27-
let userOptions: Partial<CircuitStartOptions> | undefined;
14+
let started = false;
15+
let appState: string;
16+
let circuit: CircuitManager;
17+
let options: CircuitStartOptions;
18+
let logger: ConsoleLogger;
2819

2920
export function setCircuitOptions(circuitUserOptions?: Partial<CircuitStartOptions>) {
30-
if (userOptions) {
21+
if (options) {
3122
throw new Error('Circuit options have already been configured.');
3223
}
3324

34-
userOptions = circuitUserOptions;
25+
options = resolveOptions(circuitUserOptions);
3526
}
3627

37-
export async function startCircuit(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
38-
if (hasStarted) {
28+
export async function startServer(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
29+
if (started) {
3930
throw new Error('Blazor Server has already started.');
4031
}
4132

42-
hasStarted = true;
33+
started = true;
34+
appState = discoverPersistedState(document) || '';
35+
logger = new ConsoleLogger(options.logLevel);
36+
circuit = new CircuitManager(components, appState, options, logger);
4337

44-
// Establish options to be used
45-
const options = resolveOptions(userOptions);
46-
const jsInitializer = await fetchAndInvokeInitializers(options);
47-
48-
const logger = new ConsoleLogger(options.logLevel);
38+
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');
4939

50-
Blazor.reconnect = async (existingConnection?: HubConnection): Promise<boolean> => {
51-
if (renderingFailed) {
40+
Blazor.reconnect = async () => {
41+
if (circuit.didRenderingFail()) {
5242
// We can't reconnect after a failure, so exit early.
5343
return false;
5444
}
5545

56-
const reconnection = existingConnection || await initializeConnection(options, logger, circuit);
57-
if (!(await circuit.reconnect(reconnection))) {
46+
if (!(await circuit.reconnect())) {
5847
logger.log(LogLevel.Information, 'Reconnection attempt to the circuit was rejected by the server. This may indicate that the associated state is no longer available on the server.');
5948
return false;
6049
}
6150

62-
options.reconnectionHandler!.onConnectionUp();
63-
6451
return true;
6552
};
66-
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
6753

54+
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
6855
options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
69-
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');
70-
71-
const appState = discoverPersistedState(document);
72-
circuit = new CircuitDescriptor(components, appState || '');
7356

7457
// Configure navigation via SignalR
7558
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
76-
return connection.send('OnLocationChanged', uri, state, intercepted);
59+
return circuit.sendLocationChanged(uri, state, intercepted);
7760
}, (callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
78-
return connection.send('OnLocationChanging', callId, uri, state, intercepted);
61+
return circuit.sendLocationChanging(callId, uri, state, intercepted);
7962
});
8063

81-
Blazor._internal.forceCloseConnection = () => connection.stop();
82-
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);
83-
84-
dispatcher = DotNet.attachDispatcher({
85-
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson): void => {
86-
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
87-
},
88-
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
89-
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
90-
},
91-
sendByteArray: (id: number, data: Uint8Array): void => {
92-
connection.send('ReceiveByteArray', id, data);
93-
},
94-
});
64+
Blazor._internal.forceCloseConnection = () => circuit.disconnect();
65+
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => circuit.sendJsDataStream(data, streamId, chunkSize);
9566

96-
const initialConnection = await initializeConnection(options, logger, circuit);
97-
const circuitStarted = await circuit.startCircuit(initialConnection);
67+
const jsInitializer = await fetchAndInvokeInitializers(options);
68+
const circuitStarted = await circuit.start();
9869
if (!circuitStarted) {
9970
logger.log(LogLevel.Error, 'Failed to start the circuit.');
10071
return;
10172
}
10273

103-
let disconnectSent = false;
10474
const cleanup = () => {
105-
if (!disconnectSent) {
106-
const data = new FormData();
107-
const circuitId = circuit.circuitId!;
108-
data.append('circuitId', circuitId);
109-
disconnectSent = navigator.sendBeacon('_blazor/disconnect', data);
110-
}
75+
circuit.sendDisconnectBeacon();
11176
};
11277

11378
Blazor.disconnect = cleanup;
@@ -119,94 +84,35 @@ export async function startCircuit(components: RootComponentManager<ServerCompon
11984
jsInitializer.invokeAfterStartedCallbacks(Blazor);
12085
}
12186

122-
async function initializeConnection(options: CircuitStartOptions, logger: Logger, circuit: CircuitDescriptor): Promise<HubConnection> {
123-
const hubProtocol = new MessagePackHubProtocol();
124-
(hubProtocol as unknown as { name: string }).name = 'blazorpack';
125-
126-
const connectionBuilder = new HubConnectionBuilder()
127-
.withUrl('_blazor')
128-
.withHubProtocol(hubProtocol);
129-
130-
options.configureSignalR(connectionBuilder);
131-
132-
const newConnection = connectionBuilder.build();
133-
134-
newConnection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(WebRendererId.Server, circuit.resolveElement(selector, componentId), componentId, false));
135-
newConnection.on('JS.BeginInvokeJS', dispatcher.beginInvokeJSFromDotNet.bind(dispatcher));
136-
newConnection.on('JS.EndInvokeDotNet', dispatcher.endInvokeDotNetFromJS.bind(dispatcher));
137-
newConnection.on('JS.ReceiveByteArray', dispatcher.receiveByteArray.bind(dispatcher));
138-
139-
newConnection.on('JS.BeginTransmitStream', (streamId: number) => {
140-
const readableStream = new ReadableStream({
141-
start(controller) {
142-
newConnection.stream('SendDotNetStreamToJS', streamId).subscribe({
143-
next: (chunk: Uint8Array) => controller.enqueue(chunk),
144-
complete: () => controller.close(),
145-
error: (err) => controller.error(err),
146-
});
147-
},
148-
});
149-
150-
dispatcher.supplyDotNetStream(streamId, readableStream);
151-
});
152-
153-
const renderQueue = RenderQueue.getOrCreate(logger);
154-
newConnection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
155-
logger.log(LogLevel.Debug, `Received render batch with id ${batchId} and ${batchData.byteLength} bytes.`);
156-
renderQueue.processBatch(batchId, batchData, newConnection);
157-
});
158-
159-
newConnection.on('JS.EndLocationChanging', Blazor._internal.navigationManager.endLocationChanging);
160-
161-
newConnection.onclose(error => !renderingFailed && options.reconnectionHandler!.onConnectionDown(options.reconnectionOptions, error));
162-
newConnection.on('JS.Error', error => {
163-
renderingFailed = true;
164-
unhandledError(newConnection, error, logger);
165-
showErrorNotification();
166-
});
167-
168-
try {
169-
await newConnection.start();
170-
connection = newConnection;
171-
} catch (ex: any) {
172-
unhandledError(newConnection, ex as Error, logger);
173-
174-
if (ex.errorType === 'FailedToNegotiateWithServerError') {
175-
// Connection with the server has been interrupted, and we're in the process of reconnecting.
176-
// Throw this exception so it can be handled at the reconnection layer, and don't show the
177-
// error notification.
178-
throw ex;
179-
} else {
180-
showErrorNotification();
181-
}
87+
export function startCircuit(): Promise<boolean> {
88+
if (!started) {
89+
throw new Error('Cannot start the circuit until Blazor Server has started.');
90+
}
18291

183-
if (ex.innerErrors) {
184-
if (ex.innerErrors.some(e => e.errorType === 'UnsupportedTransportError' && e.transport === HttpTransportType.WebSockets)) {
185-
logger.log(LogLevel.Error, 'Unable to connect, please ensure you are using an updated browser that supports WebSockets.');
186-
} else if (ex.innerErrors.some(e => e.errorType === 'FailedToStartTransportError' && e.transport === HttpTransportType.WebSockets)) {
187-
logger.log(LogLevel.Error, 'Unable to connect, please ensure WebSockets are available. A VPN or proxy may be blocking the connection.');
188-
} else if (ex.innerErrors.some(e => e.errorType === 'DisabledTransportError' && e.transport === HttpTransportType.LongPolling)) {
189-
logger.log(LogLevel.Error, 'Unable to initiate a SignalR connection to the server. This might be because the server is not configured to support WebSockets. For additional details, visit https://aka.ms/blazor-server-websockets-error.');
190-
}
191-
}
92+
if (circuit.didRenderingFail()) {
93+
// We can't start a new circuit after a rendering failure because the renderer
94+
// might be in an invalid state.
95+
return Promise.resolve(false);
19296
}
19397

194-
// Check if the connection is established using the long polling transport,
195-
// using the `features.inherentKeepAlive` property only present with long polling.
196-
if ((newConnection as any).connection?.features?.inherentKeepAlive) {
197-
logger.log(LogLevel.Warning, 'Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.');
98+
if (circuit.isDisposedOrDisposing()) {
99+
// If the current circuit is no longer available, create a new one.
100+
circuit = new CircuitManager(circuit.getRootComponentManager(), appState, options, logger);
198101
}
199102

200-
return newConnection;
103+
// Start the circuit. If the circuit has already started, this will return the existing
104+
// circuit start promise.
105+
return circuit.start();
201106
}
202107

203-
function unhandledError(connection: HubConnection, err: Error, logger: Logger): void {
204-
logger.log(LogLevel.Error, err);
108+
export function hasStartedServer(): boolean {
109+
return started;
110+
}
205111

206-
// Disconnect on errors.
207-
//
208-
// Trying to call methods on the connection after its been closed will throw.
209-
if (connection) {
210-
connection.stop();
211-
}
112+
export async function disposeCircuit() {
113+
await circuit?.dispose();
114+
}
115+
116+
export function isCircuitAvailable(): boolean {
117+
return !circuit.isDisposedOrDisposing();
212118
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { Blazor } from './GlobalExports';
55
import { shouldAutoStart } from './BootCommon';
66
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
7-
import { setCircuitOptions, startCircuit } from './Boot.Server.Common';
7+
import { setCircuitOptions, startServer } from './Boot.Server.Common';
88
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
99
import { DotNet } from '@microsoft/dotnet-js-interop';
1010
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
@@ -21,7 +21,7 @@ function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {
2121

2222
const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
2323
const components = new InitialRootComponentsList(serverComponents);
24-
return startCircuit(components);
24+
return startServer(components);
2525
}
2626

2727
Blazor.start = boot;

src/Components/Web.JS/src/Boot.Web.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { attachComponentDescriptorHandler, registerAllComponentDescriptors } fro
2121
import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils';
2222

2323
let started = false;
24-
const rootComponentManager = new WebRootComponentManager();
24+
let rootComponentManager: WebRootComponentManager;
2525

2626
function boot(options?: Partial<WebStartOptions>) : Promise<void> {
2727
if (started) {
@@ -43,6 +43,8 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
4343
setCircuitOptions(options?.circuit);
4444
setWebAssemblyOptions(options?.webAssembly);
4545

46+
rootComponentManager = new WebRootComponentManager(options?.ssr?.circuitInactivityTimeoutMs ?? 2000);
47+
4648
attachComponentDescriptorHandler(rootComponentManager);
4749
attachStreamingRenderingListener(options?.ssr, rootComponentManager);
4850

src/Components/Web.JS/src/Boot.WebAssembly.Common.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { RootComponentManager } from './Services/RootComponentManager';
1919

2020
let options: Partial<WebAssemblyStartOptions> | undefined;
2121
let platformLoadPromise: Promise<void> | undefined;
22-
let hasStarted = false;
22+
let loadedWebAssemblyPlatform = false;
23+
let started = false;
2324

2425
let resolveBootConfigPromise: (value: MonoConfig) => void;
2526
const bootConfigPromise = new Promise<MonoConfig>(resolve => {
@@ -35,11 +36,11 @@ export function setWebAssemblyOptions(webAssemblyOptions?: Partial<WebAssemblySt
3536
}
3637

3738
export async function startWebAssembly(components: RootComponentManager<WebAssemblyComponentDescriptor>): Promise<void> {
38-
if (hasStarted) {
39+
if (started) {
3940
throw new Error('Blazor WebAssembly has already started.');
4041
}
4142

42-
hasStarted = true;
43+
started = true;
4344

4445
if (inAuthRedirectIframe()) {
4546
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -54,7 +55,7 @@ export async function startWebAssembly(components: RootComponentManager<WebAssem
5455
// focus, in turn triggering a 'change' event. It may also be possible to listen to other DOM mutation events
5556
// that are themselves triggered by the application of a renderbatch.
5657
const renderer = getRendererer(browserRendererId);
57-
if (renderer.eventDelegator.getHandler(eventHandlerId)) {
58+
if (renderer?.eventDelegator.getHandler(eventHandlerId)) {
5859
monoPlatform.invokeWhenHeapUnlocked(continuation);
5960
}
6061
});
@@ -146,15 +147,30 @@ export async function startWebAssembly(components: RootComponentManager<WebAssem
146147
api.invokeLibraryInitializers('afterStarted', [Blazor]);
147148
}
148149

150+
export function hasStartedWebAssembly(): boolean {
151+
return started;
152+
}
153+
149154
export function waitForBootConfigLoaded(): Promise<MonoConfig> {
150155
return bootConfigPromise;
151156
}
152157

153158
export function loadWebAssemblyPlatformIfNotStarted(): Promise<void> {
154-
platformLoadPromise ??= monoPlatform.load(options ?? {}, resolveBootConfigPromise);
159+
platformLoadPromise ??= (async () => {
160+
await monoPlatform.load(options ?? {}, resolveBootConfigPromise);
161+
loadedWebAssemblyPlatform = true;
162+
})();
155163
return platformLoadPromise;
156164
}
157165

166+
export function hasStartedLoadingWebAssemblyPlatform(): boolean {
167+
return platformLoadPromise !== undefined;
168+
}
169+
170+
export function hasLoadedWebAssemblyPlatform(): boolean {
171+
return loadedWebAssemblyPlatform;
172+
}
173+
158174
// obsolete, legacy, don't use for new code!
159175
function invokeJSFromDotNet(callInfo: Pointer, arg0: any, arg1: any, arg2: any): any {
160176
const functionIdentifier = monoPlatform.readStringField(callInfo, 0)!;

0 commit comments

Comments
 (0)