Preview: Fix preview showing published version when Save and Preview is clicked multiple times (closes #20981) (#20992)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
Jacob Overgaard
2025-12-01 12:16:35 +01:00
committed by GitHub
parent b40ea0df8c
commit 820c34432a
6 changed files with 45 additions and 32 deletions

View File

@@ -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;

View File

@@ -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);
}
/// <inheritdoc/>
@@ -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) =>

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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="));
}