Skip to content

Commit

Permalink
[release/8.0] [Blazor] Close the circuit when all Blazor Server compo…
Browse files Browse the repository at this point in the history
…nents 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
  • Loading branch information
MackinnonBuck authored Aug 25, 2023
1 parent 9781991 commit 8a50cd5
Show file tree
Hide file tree
Showing 26 changed files with 810 additions and 332 deletions.
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

194 changes: 50 additions & 144 deletions src/Components/Web.JS/src/Boot.Server.Common.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,78 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { DotNet } from '@microsoft/dotnet-js-interop';
import { Blazor } from './GlobalExports';
import { HubConnectionBuilder, HubConnection, HttpTransportType } from '@microsoft/signalr';
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';
import { showErrorNotification } from './BootErrors';
import { RenderQueue } from './Platform/Circuits/RenderQueue';
import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel, Logger } from './Platform/Logging/Logger';
import { CircuitDescriptor } from './Platform/Circuits/CircuitManager';
import { LogLevel } from './Platform/Logging/Logger';
import { CircuitManager } from './Platform/Circuits/CircuitManager';
import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
import { discoverPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { sendJSDataStream } from './Platform/Circuits/CircuitStreamingInterop';
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.Server';
import { WebRendererId } from './Rendering/WebRendererId';
import { RootComponentManager } from './Services/RootComponentManager';

let renderingFailed = false;
let hasStarted = false;
let connection: HubConnection;
let circuit: CircuitDescriptor;
let dispatcher: DotNet.ICallDispatcher;
let userOptions: Partial<CircuitStartOptions> | undefined;
let started = false;
let appState: string;
let circuit: CircuitManager;
let options: CircuitStartOptions;
let logger: ConsoleLogger;

export function setCircuitOptions(circuitUserOptions?: Partial<CircuitStartOptions>) {
if (userOptions) {
if (options) {
throw new Error('Circuit options have already been configured.');
}

userOptions = circuitUserOptions;
options = resolveOptions(circuitUserOptions);
}

export async function startCircuit(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
if (hasStarted) {
export async function startServer(components: RootComponentManager<ServerComponentDescriptor>): Promise<void> {
if (started) {
throw new Error('Blazor Server has already started.');
}

hasStarted = true;
started = true;
appState = discoverPersistedState(document) || '';
logger = new ConsoleLogger(options.logLevel);
circuit = new CircuitManager(components, appState, options, logger);

// Establish options to be used
const options = resolveOptions(userOptions);
const jsInitializer = await fetchAndInvokeInitializers(options);

const logger = new ConsoleLogger(options.logLevel);
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');

Blazor.reconnect = async (existingConnection?: HubConnection): Promise<boolean> => {
if (renderingFailed) {
Blazor.reconnect = async () => {
if (circuit.didRenderingFail()) {
// We can't reconnect after a failure, so exit early.
return false;
}

const reconnection = existingConnection || await initializeConnection(options, logger, circuit);
if (!(await circuit.reconnect(reconnection))) {
if (!(await circuit.reconnect())) {
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.');
return false;
}

options.reconnectionHandler!.onConnectionUp();

return true;
};
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);

Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');

const appState = discoverPersistedState(document);
circuit = new CircuitDescriptor(components, appState || '');

// Configure navigation via SignalR
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanged', uri, state, intercepted);
return circuit.sendLocationChanged(uri, state, intercepted);
}, (callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanging', callId, uri, state, intercepted);
return circuit.sendLocationChanging(callId, uri, state, intercepted);
});

Blazor._internal.forceCloseConnection = () => connection.stop();
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);

dispatcher = DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson): void => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
},
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
},
sendByteArray: (id: number, data: Uint8Array): void => {
connection.send('ReceiveByteArray', id, data);
},
});
Blazor._internal.forceCloseConnection = () => circuit.disconnect();
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => circuit.sendJsDataStream(data, streamId, chunkSize);

const initialConnection = await initializeConnection(options, logger, circuit);
const circuitStarted = await circuit.startCircuit(initialConnection);
const jsInitializer = await fetchAndInvokeInitializers(options);
const circuitStarted = await circuit.start();
if (!circuitStarted) {
logger.log(LogLevel.Error, 'Failed to start the circuit.');
return;
}

let disconnectSent = false;
const cleanup = () => {
if (!disconnectSent) {
const data = new FormData();
const circuitId = circuit.circuitId!;
data.append('circuitId', circuitId);
disconnectSent = navigator.sendBeacon('_blazor/disconnect', data);
}
circuit.sendDisconnectBeacon();
};

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

async function initializeConnection(options: CircuitStartOptions, logger: Logger, circuit: CircuitDescriptor): Promise<HubConnection> {
const hubProtocol = new MessagePackHubProtocol();
(hubProtocol as unknown as { name: string }).name = 'blazorpack';

const connectionBuilder = new HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(hubProtocol);

options.configureSignalR(connectionBuilder);

const newConnection = connectionBuilder.build();

newConnection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(WebRendererId.Server, circuit.resolveElement(selector, componentId), componentId, false));
newConnection.on('JS.BeginInvokeJS', dispatcher.beginInvokeJSFromDotNet.bind(dispatcher));
newConnection.on('JS.EndInvokeDotNet', dispatcher.endInvokeDotNetFromJS.bind(dispatcher));
newConnection.on('JS.ReceiveByteArray', dispatcher.receiveByteArray.bind(dispatcher));

newConnection.on('JS.BeginTransmitStream', (streamId: number) => {
const readableStream = new ReadableStream({
start(controller) {
newConnection.stream('SendDotNetStreamToJS', streamId).subscribe({
next: (chunk: Uint8Array) => controller.enqueue(chunk),
complete: () => controller.close(),
error: (err) => controller.error(err),
});
},
});

dispatcher.supplyDotNetStream(streamId, readableStream);
});

const renderQueue = RenderQueue.getOrCreate(logger);
newConnection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
logger.log(LogLevel.Debug, `Received render batch with id ${batchId} and ${batchData.byteLength} bytes.`);
renderQueue.processBatch(batchId, batchData, newConnection);
});

newConnection.on('JS.EndLocationChanging', Blazor._internal.navigationManager.endLocationChanging);

newConnection.onclose(error => !renderingFailed && options.reconnectionHandler!.onConnectionDown(options.reconnectionOptions, error));
newConnection.on('JS.Error', error => {
renderingFailed = true;
unhandledError(newConnection, error, logger);
showErrorNotification();
});

try {
await newConnection.start();
connection = newConnection;
} catch (ex: any) {
unhandledError(newConnection, ex as Error, logger);

if (ex.errorType === 'FailedToNegotiateWithServerError') {
// Connection with the server has been interrupted, and we're in the process of reconnecting.
// Throw this exception so it can be handled at the reconnection layer, and don't show the
// error notification.
throw ex;
} else {
showErrorNotification();
}
export function startCircuit(): Promise<boolean> {
if (!started) {
throw new Error('Cannot start the circuit until Blazor Server has started.');
}

if (ex.innerErrors) {
if (ex.innerErrors.some(e => e.errorType === 'UnsupportedTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure you are using an updated browser that supports WebSockets.');
} else if (ex.innerErrors.some(e => e.errorType === 'FailedToStartTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure WebSockets are available. A VPN or proxy may be blocking the connection.');
} else if (ex.innerErrors.some(e => e.errorType === 'DisabledTransportError' && e.transport === HttpTransportType.LongPolling)) {
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.');
}
}
if (circuit.didRenderingFail()) {
// We can't start a new circuit after a rendering failure because the renderer
// might be in an invalid state.
return Promise.resolve(false);
}

// Check if the connection is established using the long polling transport,
// using the `features.inherentKeepAlive` property only present with long polling.
if ((newConnection as any).connection?.features?.inherentKeepAlive) {
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.');
if (circuit.isDisposedOrDisposing()) {
// If the current circuit is no longer available, create a new one.
circuit = new CircuitManager(circuit.getRootComponentManager(), appState, options, logger);
}

return newConnection;
// Start the circuit. If the circuit has already started, this will return the existing
// circuit start promise.
return circuit.start();
}

function unhandledError(connection: HubConnection, err: Error, logger: Logger): void {
logger.log(LogLevel.Error, err);
export function hasStartedServer(): boolean {
return started;
}

// Disconnect on errors.
//
// Trying to call methods on the connection after its been closed will throw.
if (connection) {
connection.stop();
}
export async function disposeCircuit() {
await circuit?.dispose();
}

export function isCircuitAvailable(): boolean {
return !circuit.isDisposedOrDisposing();
}
4 changes: 2 additions & 2 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Blazor } from './GlobalExports';
import { shouldAutoStart } from './BootCommon';
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { setCircuitOptions, startCircuit } from './Boot.Server.Common';
import { setCircuitOptions, startServer } from './Boot.Server.Common';
import { ServerComponentDescriptor, discoverComponents } from './Services/ComponentDescriptorDiscovery';
import { DotNet } from '@microsoft/dotnet-js-interop';
import { InitialRootComponentsList } from './Services/InitialRootComponentsList';
Expand All @@ -21,7 +21,7 @@ function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {

const serverComponents = discoverComponents(document, 'server') as ServerComponentDescriptor[];
const components = new InitialRootComponentsList(serverComponents);
return startCircuit(components);
return startServer(components);
}

Blazor.start = boot;
Expand Down
4 changes: 3 additions & 1 deletion src/Components/Web.JS/src/Boot.Web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { attachComponentDescriptorHandler, registerAllComponentDescriptors } fro
import { hasProgrammaticEnhancedNavigationHandler, performProgrammaticEnhancedNavigation } from './Services/NavigationUtils';

let started = false;
const rootComponentManager = new WebRootComponentManager();
let rootComponentManager: WebRootComponentManager;

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

rootComponentManager = new WebRootComponentManager(options?.ssr?.circuitInactivityTimeoutMs ?? 2000);

attachComponentDescriptorHandler(rootComponentManager);
attachStreamingRenderingListener(options?.ssr, rootComponentManager);

Expand Down
26 changes: 21 additions & 5 deletions src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { RootComponentManager } from './Services/RootComponentManager';

let options: Partial<WebAssemblyStartOptions> | undefined;
let platformLoadPromise: Promise<void> | undefined;
let hasStarted = false;
let loadedWebAssemblyPlatform = false;
let started = false;

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

export async function startWebAssembly(components: RootComponentManager<WebAssemblyComponentDescriptor>): Promise<void> {
if (hasStarted) {
if (started) {
throw new Error('Blazor WebAssembly has already started.');
}

hasStarted = true;
started = true;

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

export function hasStartedWebAssembly(): boolean {
return started;
}

export function waitForBootConfigLoaded(): Promise<MonoConfig> {
return bootConfigPromise;
}

export function loadWebAssemblyPlatformIfNotStarted(): Promise<void> {
platformLoadPromise ??= monoPlatform.load(options ?? {}, resolveBootConfigPromise);
platformLoadPromise ??= (async () => {
await monoPlatform.load(options ?? {}, resolveBootConfigPromise);
loadedWebAssemblyPlatform = true;
})();
return platformLoadPromise;
}

export function hasStartedLoadingWebAssemblyPlatform(): boolean {
return platformLoadPromise !== undefined;
}

export function hasLoadedWebAssemblyPlatform(): boolean {
return loadedWebAssemblyPlatform;
}

// obsolete, legacy, don't use for new code!
function invokeJSFromDotNet(callInfo: Pointer, arg0: any, arg1: any, arg2: any): any {
const functionIdentifier = monoPlatform.readStringField(callInfo, 0)!;
Expand Down
Loading

0 comments on commit 8a50cd5

Please sign in to comment.