Compare commits

...
Author SHA1 Message Date
Andy Butland 28fa43c371 Tightened up comment. 2026-06-19 17:52:19 +02:00
Andy Butland 0ded9f1cbf Addressed code review feedback. 2026-06-19 16:27:15 +02:00
Andy Butland a41bb7145a Add reconnection policy for SignalR. 2026-06-19 15:20:56 +02:00
7 changed files with 121 additions and 7 deletions
@@ -2645,6 +2645,8 @@ export default {
connectionFailed:
'Kunne ikke etablere forbindelse til serveren, forhåndsvisning af liveopdateringer vil ikke fungere.',
connectionLost: 'Forbindelse til serveren mistet, forhåndsvisning af liveopdateringer vil ikke fungere.',
connectionReconnecting: 'Forbindelse til serveren mistet, forsøger at genoprette forbindelsen…',
connectionRestored: 'Forbindelse til serveren genoprettet, forhåndsvisning af liveopdateringer fungerer igen.',
},
permissions: {
FolderCreation: 'Mappeoprettelse',
@@ -2850,6 +2850,8 @@ export default {
viewPublishedContentDeclineButton: 'Stay in preview mode',
connectionFailed: 'Could not establish a connection to the server, preview live updates will not work.',
connectionLost: 'Connection to the server lost, preview live updates will not work.',
connectionReconnecting: 'Connection to the server lost, trying to reconnect…',
connectionRestored: 'Connection to the server restored, preview live updates are working again.',
},
permissions: {
FolderCreation: 'Folder creation',
@@ -1,6 +1,7 @@
export * from './server-connection.js';
export * from './server.context-token.js';
export * from './server.context.js';
export * from './signalr-reconnect-policy.js';
export * from './conditions/index.js';
export type * from './types.js';
@@ -0,0 +1,35 @@
import { UmbSignalRReconnectPolicy } from './signalr-reconnect-policy.js';
import type { RetryContext } from '@umbraco-cms/backoffice/external/signalr';
import { expect } from '@open-wc/testing';
const retryContext = (previousRetryCount: number): RetryContext => ({
previousRetryCount,
elapsedMilliseconds: 0,
retryReason: new Error('test'),
});
describe('UmbSignalRReconnectPolicy', () => {
let policy: UmbSignalRReconnectPolicy;
beforeEach(() => {
policy = new UmbSignalRReconnectPolicy();
});
it('reconnects immediately on the first attempt', () => {
expect(policy.nextRetryDelayInMilliseconds(retryContext(0))).to.equal(0);
});
it('backs off over the next attempts', () => {
expect(policy.nextRetryDelayInMilliseconds(retryContext(1))).to.equal(2000);
expect(policy.nextRetryDelayInMilliseconds(retryContext(2))).to.equal(5000);
expect(policy.nextRetryDelayInMilliseconds(retryContext(3))).to.equal(10000);
});
it('caps the delay and keeps retrying indefinitely', () => {
// Beyond the explicit schedule the delay is capped, and a number (never null) is always
// returned so the connection never gives up.
for (const count of [4, 10, 100, 10000]) {
expect(policy.nextRetryDelayInMilliseconds(retryContext(count))).to.equal(30000);
}
});
});
@@ -0,0 +1,21 @@
import type { IRetryPolicy, RetryContext } from '@umbraco-cms/backoffice/external/signalr';
const RECONNECT_DELAYS_MS = [0, 2000, 5000, 10000];
const MAX_RECONNECT_DELAY_MS = 30000;
/**
* A SignalR retry policy that reconnects indefinitely with a capped backoff.
* The default `withAutomaticReconnect()` policy gives up after ~60 seconds, which leaves an idle
* backoffice (preview, server events) permanently disconnected.
*/
export class UmbSignalRReconnectPolicy implements IRetryPolicy {
/**
* Gets the delay before the next reconnect attempt, following the capped backoff schedule.
* @param {RetryContext} retryContext - The context for the current retry, including the previous retry count.
* @returns {number} The delay in milliseconds before the next reconnect attempt (never null, so retries continue indefinitely).
* @memberof UmbSignalRReconnectPolicy
*/
nextRetryDelayInMilliseconds(retryContext: RetryContext): number {
return RECONNECT_DELAYS_MS[retryContext.previousRetryCount] ?? MAX_RECONNECT_DELAY_MS;
}
}
@@ -9,7 +9,7 @@ import {
type HubConnection,
type IHttpConnectionOptions,
} from '@umbraco-cms/backoffice/external/signalr';
import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
import { UMB_SERVER_CONTEXT, UmbSignalRReconnectPolicy } from '@umbraco-cms/backoffice/server';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
@@ -81,7 +81,15 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
});
}
#initHubConnection(token: string) {
async #initHubConnection(token: string) {
// Make sure that no previous connection exists, otherwise an orphaned connection would keep
// reconnecting in the background and emitting events. Await the stop so it can't race with
// building the new connection.
if (this.#connection) {
await this.#connection.stop();
this.#connection = undefined;
}
const serverURL = this.#serverContext?.getServerUrl();
if (!serverURL) {
@@ -102,7 +110,10 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
hubOptions.transport = HttpTransportType.WebSockets;
}
this.#connection = new HubConnectionBuilder().withUrl(serverEventHubUrl, hubOptions).build();
this.#connection = new HubConnectionBuilder()
.withUrl(serverEventHubUrl, hubOptions)
.withAutomaticReconnect(new UmbSignalRReconnectPolicy())
.build();
this.#connection.on('notify', (payload) => {
const event: UmbManagementApiServerEventModel = {
@@ -113,6 +124,11 @@ export class UmbManagementApiServerEventContext extends UmbContextBase {
this.#events.next(event);
});
// While reconnecting we treat the connection as down so cache invalidation consumers refetch
// rather than trust a cache that may have missed events during the gap.
this.#connection.onreconnecting(() => this.#isConnected.setValue(false));
this.#connection.onreconnected(() => this.#isConnected.setValue(true));
this.#connection
.start()
.then(() => this.#isConnected.setValue(true))
@@ -5,7 +5,7 @@ import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observa
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
import { UMB_SERVER_CONTEXT, UmbSignalRReconnectPolicy } from '@umbraco-cms/backoffice/server';
import type { HubConnection, IHttpConnectionOptions } from '@umbraco-cms/backoffice/external/signalr';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
@@ -104,10 +104,13 @@ export class UmbPreviewContext extends UmbContextBase {
async #initHubConnection(serverUrl: string, serverContext?: typeof UMB_SERVER_CONTEXT.TYPE) {
const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`;
// Make sure that no previous connection exists.
// Make sure that no previous connection exists, otherwise an orphaned connection would keep
// reconnecting in the background. Clear the reference before stopping so the old connection's
// onclose handler sees it is no longer the active one and stays silent.
if (this.#connection) {
await this.#connection.stop();
const previousConnection = this.#connection;
this.#connection = undefined;
await previousConnection.stop();
}
const skipNegotiation = serverContext?.getServerConnection()?.getSignalRSkipNegotiation() ?? false;
@@ -119,7 +122,14 @@ export class UmbPreviewContext extends UmbContextBase {
hubOptions.transport = HttpTransportType.WebSockets;
}
this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl, hubOptions).build();
this.#connection = new HubConnectionBuilder()
.withUrl(previewHubUrl, hubOptions)
.withAutomaticReconnect(new UmbSignalRReconnectPolicy())
.build();
// Capture this specific connection so its onclose handler can tell whether it is still the active
// one; if it has since been replaced or cleared, the close was deliberate and should stay silent.
const connection = this.#connection;
this.#connection.on('refreshed', (payload) => {
if (payload === this.#unique.getValue()) {
@@ -127,7 +137,34 @@ export class UmbPreviewContext extends UmbContextBase {
}
});
this.#connection.onreconnecting(() => {
this.#notificationContext?.peek('warning', {
data: {
headline: this.#localize.term('general_preview'),
message: this.#localize.term('preview_connectionReconnecting'),
},
});
});
this.#connection.onreconnected(() => {
// A 'refreshed' event may have been missed while disconnected, so reload the iframe to catch up.
this.#setPreviewUrl({ rnd: Math.random() });
this.#notificationContext?.peek('positive', {
data: {
headline: this.#localize.term('general_preview'),
message: this.#localize.term('preview_connectionRestored'),
},
});
});
this.#connection.onclose(() => {
// With automatic reconnect, onclose only fires when we stop the connection ourselves (teardown,
// exit, or replacing it) or for a close that cannot be recovered. Only warn when this is still
// the active connection — i.e. an unexpected drop we did not initiate.
if (this.#connection !== connection) {
return;
}
this.#notificationContext?.peek('warning', {
data: {
headline: this.#localize.term('general_preview'),