diff --git a/src/Umbraco.Core/Services/PreviewService.cs b/src/Umbraco.Core/Services/PreviewService.cs index 9810d8f07e19..43e24f0e07f7 100644 --- a/src/Umbraco.Core/Services/PreviewService.cs +++ b/src/Umbraco.Core/Services/PreviewService.cs @@ -34,7 +34,10 @@ public async Task TryEnterPreviewAsync(IUser user) if (attempt.Success) { - _cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, attempt.Result!, true, true, "None"); + // Preview cookies must use SameSite=None and Secure=true to support cross-site scenarios + // (e.g., when the backoffice is on a different domain/port than the frontend during development). + // SameSite=None requires Secure=true per browser specifications. + _cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, attempt.Result!, httpOnly: true, secure: true, sameSiteMode: "None"); } return attempt.Success; diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs index 854699cd29d1..0d7aad9d4978 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs @@ -27,15 +27,7 @@ public void ExpireCookie(string cookieName) return; } - var cookieValue = httpContext.Request.Cookies[cookieName]; - - httpContext.Response.Cookies.Append( - cookieName, - cookieValue ?? string.Empty, - new CookieOptions - { - Expires = DateTime.Now.AddYears(-1), - }); + httpContext.Response.Cookies.Delete(cookieName); } /// @@ -45,15 +37,14 @@ public void ExpireCookie(string cookieName) public void SetCookieValue(string cookieName, string value, bool httpOnly, bool secure, string sameSiteMode) { SameSiteMode sameSiteModeValue = ParseSameSiteMode(sameSiteMode); - _httpContextAccessor.HttpContext?.Response.Cookies.Append( - cookieName, - value, - new CookieOptions - { - HttpOnly = httpOnly, - SameSite = sameSiteModeValue, - Secure = secure, - }); + var options = new CookieOptions + { + HttpOnly = httpOnly, + SameSite = sameSiteModeValue, + Secure = secure, + }; + + _httpContextAccessor.HttpContext?.Response.Cookies.Append(cookieName, value, options); } private static SameSiteMode ParseSameSiteMode(string sameSiteMode) => diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index 6dadb4ae7c95..e29677615d1a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -72,6 +72,8 @@ export class UmbDocumentWorkspaceContext #documentSegmentRepository = new UmbDocumentSegmentRepository(this); #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; #localize = new UmbLocalizationController(this); + #previewWindow?: WindowProxy | null = null; + #previewWindowDocumentId?: string | null = null; constructor(host: UmbControllerHost) { super(host, { @@ -343,7 +345,20 @@ export class UmbDocumentWorkspaceContext await this.performCreateOrUpdate(variantIds, saveData); } - // Get the preview URL from the server. + // Check if preview window is still open and showing the same document + // If so, just focus it and let SignalR handle the refresh + try { + if (this.#previewWindow && !this.#previewWindow.closed && this.#previewWindowDocumentId === unique) { + this.#previewWindow.focus(); + return; + } + } catch { + // Window reference is stale, continue to create new preview session + this.#previewWindow = null; + this.#previewWindowDocumentId = null; + } + + // Preview not open, create new preview session and open window const previewRepository = new UmbPreviewRepository(this); const previewUrlData = await previewRepository.getPreviewUrl( unique, @@ -353,8 +368,12 @@ export class UmbDocumentWorkspaceContext ); if (previewUrlData.url) { - const previewWindow = window.open(previewUrlData.url, `umbpreview-${unique}`); - previewWindow?.focus(); + // Add cache-busting parameter to ensure the preview tab reloads with the new preview session + const previewUrl = new URL(previewUrlData.url, window.document.baseURI); + previewUrl.searchParams.set('rnd', Date.now().toString()); + this.#previewWindow = window.open(previewUrl.toString(), `umbpreview-${unique}`); + this.#previewWindowDocumentId = unique; + this.#previewWindow?.focus(); return; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts index de4b0267c76d..7e315c3a31e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/context/preview.context.ts @@ -200,18 +200,15 @@ export class UmbPreviewContext extends UmbContextBase { async exitPreview() { await this.#previewRepository.exit(); + // Stop SignalR connection without waiting - window will close anyway if (this.#connection) { - await this.#connection.stop(); + this.#connection.stop(); this.#connection = undefined; } - let url = await this.#getPublishedUrl(); - - if (!url) { - url = this.#previewUrl.getValue() as string; - } - - window.location.replace(url); + // Close the preview window + // This ensures that subsequent "Save and Preview" actions will create a new preview session + window.close(); } iframeLoaded(iframe: HTMLIFrameElement) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-environments.element.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-environments.element.ts index 8b0bf0452adc..5cb43b2371a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-environments.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-environments.element.ts @@ -78,7 +78,10 @@ export class UmbPreviewEnvironmentsElement extends UmbLitElement { ); if (previewUrlData.url) { - const previewWindow = window.open(previewUrlData.url, `umbpreview-${this._unique}`); + // Add cache-busting parameter to ensure the preview tab reloads with the new preview session + const previewUrl = new URL(previewUrlData.url, window.document.baseURI); + previewUrl.searchParams.set('rnd', Date.now().toString()); + const previewWindow = window.open(previewUrl.toString(), `umbpreview-${this._unique}`); previewWindow?.focus(); return; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManagerTests.cs index e367870a50df..58789979c5eb 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManagerTests.cs @@ -71,7 +71,7 @@ public void Can_Expire_Cookie() cookieManager.ExpireCookie(CookieName); var setCookieHeader = httpContext.Response.Headers.SetCookie.ToString(); - Assert.IsTrue(setCookieHeader.StartsWith(GetExpectedCookie())); + Assert.IsTrue(setCookieHeader.StartsWith("testCookie=")); Assert.IsTrue(setCookieHeader.Contains($"expires=")); }