From 6f0355989d0a51a4cd5f9b9c457336dcdf5b6322 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:17:09 +0200 Subject: [PATCH 1/4] feat: replaces manual WebSocket with the actual SignalR library on the preview context --- .../src/apps/preview/preview.context.ts | 79 ++++++++++++------- .../src/assets/lang/da.ts | 5 +- .../src/assets/lang/en.ts | 2 + 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index 75de96a38c5f..fcedd761aabb 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -7,6 +7,9 @@ import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import { UmbDocumentPreviewRepository } from '@umbraco-cms/backoffice/document'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { HubConnectionBuilder, type HubConnection } from '@umbraco-cms/backoffice/external/signalr'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; const UMB_LOCALSTORAGE_SESSION_KEY = 'umb:previewSessions'; @@ -31,7 +34,7 @@ export class UmbPreviewContext extends UmbContextBase { #culture?: string | null; #segment?: string | null; #serverUrl: string = ''; - #webSocket?: WebSocket; + #connection?: HubConnection; #iframeReady = new UmbBooleanState(false); public readonly iframeReady = this.#iframeReady.asObservable(); @@ -41,12 +44,13 @@ export class UmbPreviewContext extends UmbContextBase { #documentPreviewRepository = new UmbDocumentPreviewRepository(this); + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + #localize = new UmbLocalizationController(this); + constructor(host: UmbControllerHost) { super(host, UMB_PREVIEW_CONTEXT); this.consumeContext(UMB_SERVER_CONTEXT, (instance) => { - this.#serverUrl = instance?.getServerUrl() ?? ''; - const params = new URLSearchParams(window.location.search); this.#unique = params.get('id'); @@ -58,37 +62,55 @@ export class UmbPreviewContext extends UmbContextBase { return; } - this.#setPreviewUrl(); - }); - } + const serverUrl = instance?.getServerUrl(); - #configureWebSocket() { - if (this.#webSocket && this.#webSocket.readyState < 2) return; + if (!serverUrl) { + console.error('No server URL found in context'); + return; + } - const url = `${this.#serverUrl.replace('https://', 'wss://')}/umbraco/PreviewHub`; + this.#serverUrl = serverUrl; - this.#webSocket = new WebSocket(url); + this.#setPreviewUrl({ serverUrl }); - this.#webSocket.addEventListener('open', () => { - // NOTE: SignalR protocol handshake; it requires a terminating control character. - const endChar = String.fromCharCode(30); - this.#webSocket?.send(`{"protocol":"json","version":1}${endChar}`); + this.#initHubConnection(serverUrl); }); - this.#webSocket.addEventListener('message', (event: MessageEvent) => { - if (!event?.data) return; + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => { + this.#notificationContext = instance; + }); + } - // NOTE: Strip the terminating control character, (from SignalR). - const data = event.data.substring(0, event.data.length - 1); - const json = JSON.parse(data) as { type: number; target: string; arguments: Array }; + async #initHubConnection(serverUrl: string) { + const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`; - if (json.type === 1 && json.target === 'refreshed') { - const pageId = json.arguments?.[0]; - if (pageId === this.#unique) { - this.#setPreviewUrl({ rnd: Math.random() }); - } + this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl).build(); + + this.#connection.on('refreshed', (payload) => { + if (payload === this.#unique) { + this.#setPreviewUrl({ rnd: Math.random() }); } }); + + this.#connection.onclose(() => { + this.#notificationContext?.peek('warning', { + data: { + headline: this.#localize.term('general_preview'), + message: this.#localize.term('preview_connectionLost'), + }, + }); + }); + + try { + await this.#connection.start(); + } catch { + this.#notificationContext?.peek('warning', { + data: { + headline: this.#localize.term('general_preview'), + message: this.#localize.term('preview_connectionFailed'), + }, + }); + } } async #getPublishedUrl(): Promise { @@ -144,7 +166,7 @@ export class UmbPreviewContext extends UmbContextBase { params.delete(segmentParam); } - const previewUrl = new URL(url.pathname + '?' + params.toString(), host); + const previewUrl = new URL(`${url.pathname}?${params.toString()}`, host); const previewUrlString = previewUrl.toString(); this.#previewUrl.setValue(previewUrlString); @@ -180,9 +202,9 @@ export class UmbPreviewContext extends UmbContextBase { await this.#documentPreviewRepository.exit(); } - if (this.#webSocket) { - this.#webSocket.close(); - this.#webSocket = undefined; + if (this.#connection) { + this.#connection.stop(); + this.#connection = undefined; } let url = await this.#getPublishedUrl(); @@ -202,7 +224,6 @@ export class UmbPreviewContext extends UmbContextBase { iframeLoaded(iframe: HTMLIFrameElement) { if (!iframe) return; - this.#configureWebSocket(); this.#iframeReady.setValue(true); } diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts index fad3f6064c53..ba72b4d97171 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts @@ -2602,13 +2602,16 @@ export default { returnToPreviewHeadline: 'Forhåndsvisning af indholdet?', returnToPreviewDescription: 'Du har afslutet forhåndsvisning, vil du starte forhåndsvisning igen for at\n se seneste gemte version af indholdet?\n ', + returnToPreviewAcceptButton: 'Start forhåndsvisning igen', returnToPreviewDeclineButton: 'Se udgivet indhold', viewPublishedContentHeadline: 'Se udgivet indhold?', viewPublishedContentDescription: 'Du er i forhåndsvisning, vil du afslutte for at se den udgivet\n version?\n ', viewPublishedContentAcceptButton: 'Se udgivet version', viewPublishedContentDeclineButton: 'Forbliv i forhåndsvisning', - returnToPreviewAcceptButton: 'Preview latest version', + 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.', }, permissions: { FolderCreation: 'Mappeoprettelse', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 20bfbf1a2580..b7e4c1a11f75 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2748,6 +2748,8 @@ export default { 'You are in Preview Mode, do you want exit in order to view the published version of your website?', viewPublishedContentAcceptButton: 'View published version', 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.', }, permissions: { FolderCreation: 'Folder creation', From e334eaf76e134f8bf87a94b3904d5a8ad17756c5 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:24:20 +0200 Subject: [PATCH 2/4] feat: informs the developer what went wrong in preview mode --- src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index fcedd761aabb..18c8fcfcd332 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -103,7 +103,8 @@ export class UmbPreviewContext extends UmbContextBase { try { await this.#connection.start(); - } catch { + } catch (error) { + console.error('The SignalR connection could not be established', error); this.#notificationContext?.peek('warning', { data: { headline: this.#localize.term('general_preview'), From 644a2f7c54f6014164c147d78c6f66cc91b647d6 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:25:59 +0200 Subject: [PATCH 3/4] feat: awaits the stop connection before proceeding --- src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index 18c8fcfcd332..a5d29030d085 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -204,7 +204,7 @@ export class UmbPreviewContext extends UmbContextBase { } if (this.#connection) { - this.#connection.stop(); + await this.#connection.stop(); this.#connection = undefined; } From d881c8cb130c65304c287797daf033f4de75374e Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:50:53 +0200 Subject: [PATCH 4/4] feat: ensures no existing connection exists --- .../src/apps/preview/preview.context.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index a5d29030d085..2acbd754dedf 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -84,6 +84,12 @@ export class UmbPreviewContext extends UmbContextBase { async #initHubConnection(serverUrl: string) { const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`; + // Make sure that no previous connection exists. + if (this.#connection) { + await this.#connection.stop(); + this.#connection = undefined; + } + this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl).build(); this.#connection.on('refreshed', (payload) => {