From 820c34432a16a36ca59fbb16773299ef6672c34a Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:16:35 +0100 Subject: [PATCH] Preview: Fix preview showing published version when Save and Preview is clicked multiple times (closes #20981) (#20992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix preview showing published version when Save and Preview is clicked multiple times Fixes #20981 When clicking "Save and Preview" multiple times, the preview tab would show the published version instead of the latest saved version. This occurred because: 1. Each "Save and Preview" creates a new preview session with a new token 2. The preview window is reused (via named window target) 3. Without a URL change, the browser doesn't reload and misses the new session token 4. The stale page gets redirected to the published URL Solution: Add a cache-busting parameter (?rnd=timestamp) to the preview URL, forcing the browser to reload and pick up the new preview session token. This aligns with how SignalR refreshes work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve Save and Preview to avoid full page reloads when preview is already open When clicking "Save and Preview" multiple times with a preview tab already open, the entire preview tab would reload. This enhancement makes it behave like the "Save" button - only the iframe reloads, not the entire preview wrapper. Changes: - Store reference to preview window when opened - Check if preview window is still open before creating new session - If open, just focus it and let SignalR handle the iframe refresh - If closed, create new preview session and open new window This provides a smoother UX where subsequent saves don't cause the preview frame and controls to reload, only the content iframe refreshes via SignalR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Close preview window when ending preview session Changes the "End Preview" behavior to close the preview tab instead of navigating to the published URL. This provides a cleaner UX and ensures subsequent "Save and Preview" actions will always create a fresh preview session. Benefits: - Eliminates edge case where preview window remains open but is no longer in preview mode - Simpler behavior - preview session ends and window closes - Users can use "Preview website" button if they want to view published page Also removes unnecessary await on SignalR connection.stop() to prevent blocking if the connection cleanup hangs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix preview cookie expiration and add proper error handling This commit addresses cookie management issues in the preview system: 1. **Cookie Expiration API Enhancement** - Added `ExpireCookie` overload with security parameters (httpOnly, secure, sameSiteMode) - Added `SetCookieValue` overload with optional expires parameter - Marked old methods as obsolete for removal in Umbraco 19 - Ensures cookies are expired with matching security attributes 2. **PreviewService Cookie Handling** - Changed to use new `ExpireCookie` method with explicit security attributes - Maintains `Secure=true` and `SameSite=None` for cross-site scenarios - Uses new `SetCookieValue` overload with explicit expires parameter - Properly expires preview cookies when ending preview session 3. **Frontend Error Handling** - Added try-catch around preview window reference checks - Handles stale window references gracefully - Prevents potential errors from accessing closed window properties These changes ensure preview cookies are properly managed throughout their lifecycle and support both same-site and cross-site scenarios (e.g., when the backoffice is on a different domain/port during development). Fixes #20981 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Track document ID for preview window to prevent reusing window across different documents When navigating from one document to another in the backoffice, the preview window reference was being reused even though it was showing a different document. This meant clicking "Save and Preview" would just focus the existing window without updating it to show the new document. Now we track which document the preview window is showing and only reuse the window if: 1. The window is still open 2. The window is showing the same document This ensures each document gets its own preview session while still avoiding unnecessary full page reloads when repeatedly previewing the same document. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove updates to ICookieManager and use Cookies.Delete to remove cookie. * Fix file not found on click to save and preview. * Removed further currently unnecessary updates to the cookie manager interface and implementation. * Fixed failing unit test. --------- Co-authored-by: Claude Co-authored-by: Andy Butland --- src/Umbraco.Core/Services/PreviewService.cs | 5 +++- .../AspNetCore/AspNetCoreCookieManager.cs | 27 +++++++------------ .../workspace/document-workspace.context.ts | 25 ++++++++++++++--- .../preview/context/preview.context.ts | 13 ++++----- .../preview-environments.element.ts | 5 +++- .../AspNetCoreCookieManagerTests.cs | 2 +- 6 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/Umbraco.Core/Services/PreviewService.cs b/src/Umbraco.Core/Services/PreviewService.cs index 9810d8f07e..43e24f0e07 100644 --- a/src/Umbraco.Core/Services/PreviewService.cs +++ b/src/Umbraco.Core/Services/PreviewService.cs @@ -34,7 +34,10 @@ public class PreviewService : IPreviewService 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 854699cd29..0d7aad9d49 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreCookieManager.cs @@ -27,15 +27,7 @@ public class AspNetCoreCookieManager : ICookieManager 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 class AspNetCoreCookieManager : ICookieManager 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 6dadb4ae7c..e29677615d 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 de4b0267c7..7e315c3a31 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 8b0bf0452a..5cb43b2371 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 e367870a50..58789979c5 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 class AspNetCoreCookieManagerTests 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=")); }