diff --git a/src/common/inIframe.ts b/src/common/inIframe.ts index 988ca413..2e5f4b7f 100644 --- a/src/common/inIframe.ts +++ b/src/common/inIframe.ts @@ -8,6 +8,14 @@ export function inIframe(): boolean { } } +export function inVisualEditor(): boolean{ + try { + return inIframe() && window?.name == 'visual-editor' + } catch (e) { + return false; + } +} + export function isOpeningInNewTab(): boolean { try { if(hasWindow()) { diff --git a/src/configManager/__test__/configManager.test.ts b/src/configManager/__test__/configManager.test.ts index 74f6328d..dad4cbff 100644 --- a/src/configManager/__test__/configManager.test.ts +++ b/src/configManager/__test__/configManager.test.ts @@ -1,4 +1,4 @@ -import Config, { updateConfigFromUrl } from "../configManager"; +import Config, { updateConfigFromUrl, syncToStackSdk } from "../configManager"; import { getDefaultConfig, getUserInitData } from "../config.default"; import { DeepSignal } from "deepsignal"; import { IConfig } from "../../types/types"; @@ -102,6 +102,73 @@ describe("config default flags", () => { }); }); +describe("syncToStackSdk", () => { + beforeEach(() => { + Config.reset(); + }); + + afterAll(() => { + Config.reset(); + }); + + test("should set hash, stackSdkLivePreview.hash and stackSdkLivePreview.live_preview when hash is provided", () => { + syncToStackSdk({ hash: "abc123" }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.hash).toBe("abc123"); + expect(config.stackSdk.live_preview.live_preview).toBe("abc123"); + expect(config.stackSdk.live_preview.content_type_uid).toBeUndefined(); + expect(config.stackSdk.live_preview.entry_uid).toBeUndefined(); + }); + + test("should set content_type_uid on stackSdk when contentTypeUid is provided", () => { + syncToStackSdk({ contentTypeUid: "blog" }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.content_type_uid).toBe("blog"); + expect(config.stackSdk.live_preview.hash).toBeUndefined(); + expect(config.stackSdk.live_preview.entry_uid).toBeUndefined(); + }); + + test("should set entry_uid on stackSdk when entryUid is provided", () => { + syncToStackSdk({ entryUid: "entry-42" }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.entry_uid).toBe("entry-42"); + expect(config.stackSdk.live_preview.hash).toBeUndefined(); + expect(config.stackSdk.live_preview.content_type_uid).toBeUndefined(); + }); + + test("should set all three fields when all params are provided", () => { + syncToStackSdk({ hash: "h1", contentTypeUid: "page", entryUid: "e1" }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.hash).toBe("h1"); + expect(config.stackSdk.live_preview.live_preview).toBe("h1"); + expect(config.stackSdk.live_preview.content_type_uid).toBe("page"); + expect(config.stackSdk.live_preview.entry_uid).toBe("e1"); + }); + + test("should skip falsy values — null and undefined are ignored", () => { + syncToStackSdk({ hash: null, contentTypeUid: undefined, entryUid: null }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.hash).toBeUndefined(); + expect(config.stackSdk.live_preview.content_type_uid).toBeUndefined(); + expect(config.stackSdk.live_preview.entry_uid).toBeUndefined(); + }); + + test("should not overwrite existing stackSdk values for keys not passed", () => { + syncToStackSdk({ hash: "first", contentTypeUid: "ct1", entryUid: "e1" }); + syncToStackSdk({ hash: "second" }); + + const config = Config.get(); + expect(config.stackSdk.live_preview.hash).toBe("second"); + expect(config.stackSdk.live_preview.content_type_uid).toBe("ct1"); + expect(config.stackSdk.live_preview.entry_uid).toBe("e1"); + }); +}); + describe("update config from url", () => { let config: DeepSignal; diff --git a/src/configManager/config.default.ts b/src/configManager/config.default.ts index e07e1a82..5b7dab70 100644 --- a/src/configManager/config.default.ts +++ b/src/configManager/config.default.ts @@ -118,5 +118,6 @@ export function getDefaultConfig(): IConfig { payload: [], }, enableLivePreviewOutsideIframe: undefined, + pageContext: null, }; } diff --git a/src/configManager/configManager.ts b/src/configManager/configManager.ts index 949aa6ad..6bbc9fbe 100644 --- a/src/configManager/configManager.ts +++ b/src/configManager/configManager.ts @@ -73,22 +73,50 @@ export function setConfigFromParams( const content_type_uid = urlParams.get("content_type_uid"); const entry_uid = urlParams.get("entry_uid"); - const stackSdkLivePreview = Config.get().stackSdk.live_preview; - if (live_preview) { Config.set("hash", live_preview); - stackSdkLivePreview.hash = live_preview; - stackSdkLivePreview.live_preview = live_preview; } if (content_type_uid) { Config.set("stackDetails.contentTypeUid", content_type_uid); - stackSdkLivePreview.content_type_uid = content_type_uid; } if (entry_uid) { Config.set("stackDetails.entryUid", entry_uid); - stackSdkLivePreview.entry_uid = entry_uid; + } + + syncToStackSdk({ + hash: live_preview, + contentTypeUid: content_type_uid, + entryUid: entry_uid, + }); +} + +/** + * Syncs hash, contentTypeUid, and entryUid into the user's stackSdk.live_preview object. + * Auto-effects via deepsignal were ruled out because Config.reset() replaces the deepSignal + * instance, which would blind any bound effect. Explicit sync is the safe alternative. + */ +export function syncToStackSdk({ + hash, + contentTypeUid, + entryUid, +}: { + hash?: string | null; + contentTypeUid?: string | null; + entryUid?: string | null; +}): void { + const stackSdkLivePreview = Config.get().stackSdk.live_preview; + + if (hash) { + stackSdkLivePreview.hash = hash; + stackSdkLivePreview.live_preview = hash; + } + if (contentTypeUid) { + stackSdkLivePreview.content_type_uid = contentTypeUid; + } + if (entryUid) { + stackSdkLivePreview.entry_uid = entryUid; } Config.set("stackSdk.live_preview", stackSdkLivePreview); diff --git a/src/livePreview/__test__/live-preview.test.ts b/src/livePreview/__test__/live-preview.test.ts index 57c58f85..c4ef5398 100644 --- a/src/livePreview/__test__/live-preview.test.ts +++ b/src/livePreview/__test__/live-preview.test.ts @@ -10,6 +10,7 @@ import Config from "../../configManager/configManager"; import { PublicLogger } from "../../logger/logger"; import { ILivePreviewWindowType } from "../../types/types"; import { addLivePreviewQueryTags } from '../../utils/addLivePreviewQueryTags'; +import * as utils from "../../utils"; import livePreviewPostMessage from "../eventManager/livePreviewEventManager"; import { LIVE_PREVIEW_POST_MESSAGE_EVENTS } from "../eventManager/livePreviewEventManager.constant"; import { @@ -17,8 +18,11 @@ import { OnChangeLivePreviewPostMessageEventData, } from "../eventManager/types/livePreviewPostMessageEvent.type"; import LivePreview from "../live-preview"; +import * as postMessageHooks from "../eventManager/postMessageEvent.hooks"; +import { LivePreviewEditButton } from "../editButton/editButton"; import { mockLivePreviewInitEventListener } from "./mock"; + vi.mock("../../utils/addLivePreviewQueryTags", () => ({ addLivePreviewQueryTags: vi.fn(), })); @@ -543,3 +547,224 @@ describe("testing window event listeners", () => { expect(addLivePreviewQueryTags).toBeCalled(); }); }); + +describe("enable=false early return", () => { + beforeEach(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + livePreviewPostMessage?.on( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.INIT, + mockLivePreviewInitEventListener + ); + }); + + afterEach(() => { + document.getElementsByTagName("html")[0].innerHTML = ""; + vi.restoreAllMocks(); + }); + + afterAll(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + }); + + test("should not call sendInitializeLivePreviewPostMessageEvent when enable is false", () => { + const sendInitSpy = vi.spyOn( + postMessageHooks, + "sendInitializeLivePreviewPostMessageEvent" + ); + Config.replace({ enable: false, cleanCslpOnProduction: false }); + + new LivePreview(); + + expect(sendInitSpy).not.toHaveBeenCalled(); + }); + + test("should call sendInitializeLivePreviewPostMessageEvent when enable is true", () => { + const sendInitSpy = vi.spyOn( + postMessageHooks, + "sendInitializeLivePreviewPostMessageEvent" + ); + Config.replace({ enable: true }); + + new LivePreview(); + + expect(sendInitSpy).toHaveBeenCalled(); + }); + + test("should not create LivePreviewEditButton when enable is false", () => { + Config.replace({ + enable: false, + editButton: { enable: true }, + }); + + new LivePreview(); + + expect(document.getElementById("cslp-tooltip")).toBeNull(); + }); + + test("should not create LivePreviewEditButton when enable is false even if mode is builder", () => { + Config.replace({ + enable: false, + mode: "builder", + stackDetails: { environment: "preview", apiKey: "test-key" }, + }); + + new LivePreview(); + + expect(document.getElementById("cslp-tooltip")).toBeNull(); + }); +}); + +describe("LivePreview edit button condition", () => { + beforeEach(() => { + Config.reset(); + LivePreviewEditButton.livePreviewEditButton = undefined as any; + livePreviewPostMessage?.destroy({ soft: true }); + livePreviewPostMessage?.on( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.INIT, + mockLivePreviewInitEventListener + ); + vi.spyOn(utils, "isOpeningInTimeline").mockReturnValue(false); + }); + + afterEach(() => { + document.getElementsByTagName("html")[0].innerHTML = ""; + vi.restoreAllMocks(); + }); + + afterAll(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + }); + + test("should not create LivePreviewEditButton when editButton.enable is false and mode is preview", () => { + Config.replace({ + enable: true, + editButton: { enable: false }, + mode: "preview", + }); + + new LivePreview(); + + expect(LivePreviewEditButton.livePreviewEditButton).toBeUndefined(); + }); + + test("should instantiate LivePreviewEditButton when mode is builder even if editButton.enable is false", () => { + LivePreviewEditButton.livePreviewEditButton = undefined as any; + Config.replace({ + enable: true, + editButton: { enable: false }, + mode: "builder", + stackDetails: { environment: "preview", apiKey: "test-key" }, + }); + + new LivePreview(); + + expect(LivePreviewEditButton.livePreviewEditButton).toBeDefined(); + }); + + test("should not create LivePreviewEditButton when isOpeningInTimeline returns true", () => { + vi.spyOn(utils, "isOpeningInTimeline").mockReturnValue(true); + Config.replace({ + enable: true, + editButton: { enable: true }, + }); + + new LivePreview(); + + expect(document.getElementById("cslp-tooltip")).toBeNull(); + }); +}); + +describe("multiple LivePreview inits", () => { + beforeEach(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + livePreviewPostMessage?.on( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.INIT, + mockLivePreviewInitEventListener + ); + }); + + afterEach(() => { + document.getElementsByTagName("html")[0].innerHTML = ""; + vi.restoreAllMocks(); + }); + + afterAll(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + }); + + test("should not throw when constructed multiple times", () => { + Config.replace({ enable: true }); + + expect(() => { + new LivePreview(); + new LivePreview(); + }).not.toThrow(); + }); + + test("should have at most one edit button in the DOM after multiple inits", () => { + vi.spyOn(utils, "isOpeningInTimeline").mockReturnValue(false); + Config.replace({ + enable: true, + editButton: { enable: true }, + }); + + new LivePreview(); + new LivePreview(); + + const buttons = document.querySelectorAll("#cslp-tooltip"); + expect(buttons.length).toBe(1); + }); +}); + +describe("LivePreview init with partial stackDetails", () => { + beforeEach(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + livePreviewPostMessage?.on( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.INIT, + mockLivePreviewInitEventListener + ); + }); + + afterEach(() => { + document.getElementsByTagName("html")[0].innerHTML = ""; + vi.restoreAllMocks(); + }); + + afterAll(() => { + Config.reset(); + livePreviewPostMessage?.destroy({ soft: true }); + }); + + test("should not throw when constructed with partial stackDetails", () => { + Config.replace({ + enable: true, + stackDetails: { + apiKey: "partial-key", + } as any, + }); + + expect(() => new LivePreview()).not.toThrow(); + }); + + test("should preserve provided stackDetails fields and retain defaults for omitted ones", () => { + Config.replace({ + enable: true, + stackDetails: { + apiKey: "my-api-key", + } as any, + }); + + new LivePreview(); + + const { stackDetails } = Config.get(); + expect(stackDetails.apiKey).toBe("my-api-key"); + expect(stackDetails.locale).toBe("en-us"); + expect(stackDetails.masterLocale).toBe("en-us"); + }); +}); diff --git a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts index 4970b6e3..0dcbfcd1 100644 --- a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts +++ b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts @@ -3,7 +3,7 @@ */ import { vi } from "vitest"; -import Config, { setConfigFromParams } from "../../../configManager/configManager"; +import Config, { syncToStackSdk } from "../../../configManager/configManager"; import { PublicLogger } from "../../../logger/logger"; import livePreviewPostMessage from "../livePreviewEventManager"; import { LIVE_PREVIEW_POST_MESSAGE_EVENTS } from "../livePreviewEventManager.constant"; @@ -17,7 +17,8 @@ import { useHistoryPostMessageEvent, sendInitializeLivePreviewPostMessageEvent, } from "../postMessageEvent.hooks"; -import { isOpeningInNewTab } from "../../../common/inIframe"; +import { isOpeningInNewTab, inVisualEditor } from "../../../common/inIframe"; +import { addParamsToUrl } from "../../../utils"; import { ILivePreviewWindowType } from "../../../types/types"; // Mock dependencies @@ -26,7 +27,7 @@ vi.mock("../../../configManager/configManager", () => ({ get: vi.fn(), set: vi.fn(), }, - setConfigFromParams: vi.fn(), + syncToStackSdk: vi.fn(), })); vi.mock("../../../logger/logger", () => ({ @@ -44,6 +45,7 @@ vi.mock("../livePreviewEventManager", () => ({ vi.mock("../../../common/inIframe", () => ({ isOpeningInNewTab: vi.fn(), + inVisualEditor: vi.fn(() => false), })); vi.mock("../../../utils", () => ({ @@ -138,9 +140,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockOnChange).toHaveBeenCalled(); }); @@ -155,9 +156,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockOnChange).not.toHaveBeenCalled(); }); }); @@ -179,9 +179,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.reload).toHaveBeenCalled(); expect(mockOnChange).not.toHaveBeenCalled(); }); @@ -197,9 +196,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.reload).not.toHaveBeenCalled(); expect(mockOnChange).not.toHaveBeenCalled(); }); @@ -307,9 +305,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "new-hash-value", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "new-hash-value"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "new-hash-value" }); expect(mockWindow.history.pushState).toHaveBeenCalledWith( {}, "", @@ -330,9 +327,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "updated-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "updated-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "updated-hash" }); expect(mockWindow.history.pushState).toHaveBeenCalledWith( {}, "", @@ -360,9 +356,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.href).toBe("https://newdomain.com/new-page"); }); @@ -378,9 +373,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ - live_preview: "test-hash", - }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.href).toBe(originalHref); }); }); @@ -431,9 +425,9 @@ describe("postMessageEvent.hooks", () => { ); }); - it("should handle errors when setConfigFromParams throws", () => { - (setConfigFromParams as any).mockImplementation(() => { - throw new Error("setConfigFromParams error"); + it("should handle errors when syncToStackSdk throws", () => { + (syncToStackSdk as any).mockImplementation(() => { + throw new Error("syncToStackSdk error"); }); const eventData: OnChangeLivePreviewPostMessageEventData = { @@ -463,7 +457,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "test-hash" }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.reload).not.toHaveBeenCalled(); expect(mockOnChange).not.toHaveBeenCalled(); }); @@ -483,7 +478,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "new-hash" }); + expect(Config.set).toHaveBeenCalledWith("hash", "new-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "new-hash" }); expect(mockWindow.history.pushState).not.toHaveBeenCalled(); }); @@ -504,7 +500,8 @@ describe("postMessageEvent.hooks", () => { const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; callback({ data: eventData }); - expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "test-hash" }); + expect(Config.set).toHaveBeenCalledWith("hash", "test-hash"); + expect(syncToStackSdk).toHaveBeenCalledWith({ hash: "test-hash" }); expect(mockWindow.location.href).toBe(originalHref); }); }); @@ -636,7 +633,7 @@ describe("postMessageEvent.hooks", () => { ); }); - it("should call setConfigFromParams with content_type_uid and entry_uid when INIT response provides them", async () => { + it("should sync contentTypeUid and entryUid to Config and stackSdk when INIT response provides them", async () => { mockConfig = { ssr: true, mode: 1, @@ -651,10 +648,9 @@ describe("postMessageEvent.hooks", () => { await sendInitializeLivePreviewPostMessageEvent(); await Promise.resolve(); - expect(setConfigFromParams).toHaveBeenCalledWith({ - content_type_uid: "blog", - entry_uid: "entry-123", - }); + expect(Config.set).toHaveBeenCalledWith("stackDetails.contentTypeUid", "blog"); + expect(Config.set).toHaveBeenCalledWith("stackDetails.entryUid", "entry-123"); + expect(syncToStackSdk).toHaveBeenCalledWith({ contentTypeUid: "blog", entryUid: "entry-123" }); }); it("should return early and skip post-init setup when windowType is BUILDER", async () => { @@ -673,8 +669,34 @@ describe("postMessageEvent.hooks", () => { await sendInitializeLivePreviewPostMessageEvent(); await Promise.resolve(); - expect(setConfigFromParams).not.toHaveBeenCalled(); + expect(syncToStackSdk).not.toHaveBeenCalled(); + expect(Config.set).not.toHaveBeenCalledWith("stackDetails.contentTypeUid", expect.anything()); + expect(Config.set).not.toHaveBeenCalledWith("stackDetails.entryUid", expect.anything()); + }); + + it("should return early and skip post-init setup when inVisualEditor is true", async () => { + mockConfig = { + ssr: true, + mode: 1, + }; + (Config.get as any).mockReturnValue(mockConfig); + (livePreviewPostMessage as any).send.mockResolvedValue({ + windowType: ILivePreviewWindowType.PREVIEW, + contentTypeUid: "blog", + entryUid: "entry-123", + }); + (inVisualEditor as any).mockReturnValueOnce(true); + + await sendInitializeLivePreviewPostMessageEvent(); + await Promise.resolve(); + + expect(syncToStackSdk).not.toHaveBeenCalled(); expect(Config.set).not.toHaveBeenCalled(); + expect(addParamsToUrl).not.toHaveBeenCalled(); + expect(livePreviewPostMessage?.on).not.toHaveBeenCalledWith( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE, + expect.any(Function), + ); }); it("should start CHECK_ENTRY_PAGE interval when ssr is false", async () => { diff --git a/src/livePreview/eventManager/postMessageEvent.hooks.ts b/src/livePreview/eventManager/postMessageEvent.hooks.ts index d886fe59..b63991bf 100644 --- a/src/livePreview/eventManager/postMessageEvent.hooks.ts +++ b/src/livePreview/eventManager/postMessageEvent.hooks.ts @@ -1,5 +1,5 @@ -import { isOpeningInNewTab } from "../../common/inIframe"; -import Config, { setConfigFromParams } from "../../configManager/configManager"; +import { inVisualEditor, isOpeningInNewTab } from "../../common/inIframe"; +import Config, { syncToStackSdk } from "../../configManager/configManager"; import { PublicLogger } from "../../logger/logger"; import { ILivePreviewWindowType } from "../../types/types"; import { addParamsToUrl, isOpeningInTimeline } from "../../utils"; @@ -52,9 +52,11 @@ export function useOnEntryUpdatePostMessageEvent(): void { try { const { ssr, onChange, stackDetails } = Config.get(); const event_type = event.data._metadata?.event_type; - setConfigFromParams({ - live_preview: event.data.hash, - }); + // hash is typed as required string, guard is a safety net + if (event.data.hash) { + Config.set("hash", event.data.hash); + syncToStackSdk({ hash: event.data.hash }); + } // This section will run when there is a change in the entry and the website is CSR if (!ssr && !event_type) { @@ -151,18 +153,20 @@ export function sendInitializeLivePreviewPostMessageEvent(): void { windowType = ILivePreviewWindowType.PREVIEW, } = data || {}; - // TODO: This is a fix for the issue where we were calling sending init in the builder - // Let's remove this condition when we fix it. + if(inVisualEditor()){ + return; + } + + // TODO: the upper condition will the handle the visual editor init double firing issue so later we can remove this once verified if (Config?.get()?.windowType && Config.get().windowType === ILivePreviewWindowType.BUILDER) { return; } if (contentTypeUid && entryUid) { - // TODO: we should not use this function. Instead we should have sideEffect run automatically when we set the config. - setConfigFromParams({ - content_type_uid: contentTypeUid, - entry_uid: entryUid, - }); + // Sync is explicit here intentionally: auto-effects via deepsignal would go blind when Config.reset() is called. + Config.set("stackDetails.contentTypeUid", contentTypeUid); + Config.set("stackDetails.entryUid", entryUid); + syncToStackSdk({ contentTypeUid, entryUid }); } else { // TODO: add debug logs that runs conditionally // PublicLogger.debug( diff --git a/src/livePreview/onPageTraversal.ts b/src/livePreview/onPageTraversal.ts index 15f5754d..e142bc69 100644 --- a/src/livePreview/onPageTraversal.ts +++ b/src/livePreview/onPageTraversal.ts @@ -2,7 +2,7 @@ import livePreviewPostMessage from "./eventManager/livePreviewEventManager"; import { LIVE_PREVIEW_POST_MESSAGE_EVENTS } from "./eventManager/livePreviewEventManager.constant"; export function handlePageTraversal(): void { - window.addEventListener("unload", () => { + window.addEventListener("pagehide", () => { const targetURL = (document.activeElement as HTMLAnchorElement).href; if (targetURL) { livePreviewPostMessage?.send( diff --git a/src/preview/__test__/contentstack-live-preview-HOC.test.ts b/src/preview/__test__/contentstack-live-preview-HOC.test.ts index 031b2ee1..0dafa3e8 100644 --- a/src/preview/__test__/contentstack-live-preview-HOC.test.ts +++ b/src/preview/__test__/contentstack-live-preview-HOC.test.ts @@ -14,6 +14,7 @@ import { LIVE_PREVIEW_POST_MESSAGE_EVENTS } from "../../livePreview/eventManager import { PublicLogger } from "../../logger/logger"; import { IInitData } from "../../types/types"; import ContentstackLivePreview from "../contentstack-live-preview-HOC"; +import * as inIframeModule from "../../common/inIframe"; import { vi } from "vitest"; Object.defineProperty(globalThis, "crypto", { @@ -547,6 +548,64 @@ describe("Live Preview HOC unsubscribeOnEntryChange", () => { }); }); +describe("setPageContext", () => { + const context = { entryUid: "entry-123", contentTypeUid: "blog_post" }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should store the page context on Config", () => { + vi.spyOn(inIframeModule, "inIframe").mockReturnValue(false); + + ContentstackLivePreview.setPageContext(context); + + expect(Config.get().pageContext).toEqual(context); + }); + + test("should send PAGE_CONTEXT post message when inside an iframe", () => { + vi.spyOn(inIframeModule, "inIframe").mockReturnValue(true); + const sendSpy = vi + .spyOn(visualBuilderPostMessage as any, "send") + .mockResolvedValue(undefined); + + ContentstackLivePreview.setPageContext(context); + + expect(sendSpy).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.PAGE_CONTEXT, + context + ); + }); + + test("should not send PAGE_CONTEXT post message when not inside an iframe", () => { + vi.spyOn(inIframeModule, "inIframe").mockReturnValue(false); + const sendSpy = vi + .spyOn(visualBuilderPostMessage as any, "send") + .mockResolvedValue(undefined); + + ContentstackLivePreview.setPageContext(context); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + test("should log an error when the post message send fails", async () => { + vi.spyOn(inIframeModule, "inIframe").mockReturnValue(true); + const error = new Error("postMessage failed"); + vi.spyOn(visualBuilderPostMessage as any, "send").mockRejectedValue( + error + ); + const errorSpy = vi.spyOn(PublicLogger, "error"); + + ContentstackLivePreview.setPageContext(context); + await sleep(0); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to send page context to Visual Builder.", + error + ); + }); +}); + describe("getSdkVersion", () => { test("should return current version", () => { // we put the version from the package.json file diff --git a/src/preview/contentstack-live-preview-HOC.ts b/src/preview/contentstack-live-preview-HOC.ts index 7906cba9..908cd24e 100644 --- a/src/preview/contentstack-live-preview-HOC.ts +++ b/src/preview/contentstack-live-preview-HOC.ts @@ -1,5 +1,6 @@ import { cloneDeep, isEmpty, pick } from "lodash-es"; import { v4 as uuidv4 } from "uuid"; +import { inIframe } from "../common/inIframe"; import { getUserInitData } from "../configManager/config.default"; import Config, { updateConfigFromUrl } from "../configManager/configManager"; import LivePreview from "../livePreview/live-preview"; @@ -16,6 +17,11 @@ import { PublicLogger } from "../logger/logger"; import { handleWebCompare } from "../timeline/compare/compare"; import type { IExportedConfig, IInitData } from "../types/types"; import { VisualBuilder } from "../visualBuilder"; +import visualBuilderPostMessage from "../visualBuilder/utils/visualBuilderPostMessage"; +import { + IPageContextPostMessageEvent, + VisualBuilderPostMessageEvents, +} from "../visualBuilder/utils/types/postMessage.types"; class ContentstackLivePreview { private static previewConstructors: @@ -247,6 +253,44 @@ class ContentstackLivePreview { ); } + /** + * Sets the page-level entry context for the current page. + * Used by the Visual Builder "Start Editing" button to know which entry + * the current page is rendering, enabling accurate VB navigation. + * + * Place this call alongside your existing `addEditableTags` call — both + * reference the same `entry` object so there is no extra lookup. + * + * @example + * ```js + * // In your page component / useEffect + * Utils.addEditableTags(entry, "blog_post", true, "en-us"); + * ContentstackLivePreview.setPageContext({ entryUid: entry.uid, contentTypeUid: "blog_post" }); + * ``` + */ + static setPageContext(context: { entryUid: string; contentTypeUid: string }): void { + Config.set("pageContext", context); + // init() fires before async data fetching, so the INIT post-message has no + // entry context in CSR apps. Send it now so VB can update its current entry. + // Only send when inside an iframe — skip when the site opens in a plain browser tab. + if (inIframe()) { + visualBuilderPostMessage + ?.send( + VisualBuilderPostMessageEvents.PAGE_CONTEXT, + { + entryUid: context.entryUid, + contentTypeUid: context.contentTypeUid, + } + ) + .catch((error) => { + PublicLogger.error( + "Failed to send page context to Visual Builder.", + error + ); + }); + } + } + /** * Retrieves the version of the SDK. * @returns The version of the SDK as a string. diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 00000000..ca9a58b0 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,9 @@ +import type { IPageContext } from "./types"; + +declare global { + interface Window { + __CS_PAGE_CONTEXT__?: IPageContext; + } +} + +export {}; diff --git a/src/types/types.ts b/src/types/types.ts index d601094d..926b9e7a 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -65,6 +65,11 @@ export enum ILivePreviewWindowType { INDEPENDENT = "independent", } +export declare interface IPageContext { + entryUid: string; + contentTypeUid: string; +} + export declare interface IConfig { ssr: boolean; enable: boolean; @@ -89,6 +94,7 @@ export declare interface IConfig { }; collab: ICollabConfig["collab"]; enableLivePreviewOutsideIframe: boolean | undefined; + pageContext: IPageContext | null; } diff --git a/src/utils/handlePageTraversal.ts b/src/utils/handlePageTraversal.ts deleted file mode 100644 index a8d3c621..00000000 --- a/src/utils/handlePageTraversal.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const handlePageTraversal = () => { - window.addEventListener("unload", () => { - const targetURL = (document.activeElement as HTMLAnchorElement).href; - if (targetURL) { - window.parent.postMessage( - { - from: "live-preview", - type: "url-change", - data: { - targetURL, - }, - }, - "*" - ); - } - }); -}; diff --git a/src/visualBuilder/eventManager/__test__/useCollab.test.ts b/src/visualBuilder/eventManager/__test__/useCollab.test.ts index 1d33696c..f7bdbeed 100644 --- a/src/visualBuilder/eventManager/__test__/useCollab.test.ts +++ b/src/visualBuilder/eventManager/__test__/useCollab.test.ts @@ -410,3 +410,51 @@ describe("cleanup", () => { }); }); }); + +// Integration tests using real Config to document the intentional read-only isolation design. +// Share recipients receive COLLAB_ENABLE with fromShare:true, which sets pauseFeedback and +// isFeedbackMode but intentionally does NOT set collab.enable. As a result, share recipients +// never enter the collab block in mouseClick.ts (guarded by `collab.enable === true`) and +// bypass the full collab flow by design. +describe("read-only isolation — fromShare integration", () => { + let realConfig: { get: () => any; set: (key: string, value: any) => void; reset: () => void }; + let collabEnableHandler: (data: any) => void; + let collabDisableHandler: (data: any) => void; + + beforeEach(async () => { + vi.clearAllMocks(); + const realConfigModule = await vi.importActual<{ default: typeof realConfig }>( + "../../../configManager/configManager" + ); + realConfig = realConfigModule.default; + realConfig.reset(); + vi.mocked(Config.set).mockImplementation((key, value) => realConfig.set(key, value)); + vi.mocked(Config.get).mockImplementation(() => realConfig.get()); + useCollab(); + collabEnableHandler = getHandler(0); + collabDisableHandler = getHandler(2); + }); + + afterEach(() => { + realConfig.reset(); + vi.mocked(Config.get).mockReset(); + vi.mocked(Config.set).mockReset(); + }); + + it("should set pauseFeedback but not enable after COLLAB_ENABLE with fromShare:true", () => { + collabEnableHandler({ + data: { collab: { fromShare: true, pauseFeedback: true, isFeedbackMode: true } }, + }); + + expect(realConfig.get().collab.pauseFeedback).toBe(true); + expect(realConfig.get().collab.enable).not.toBe(true); + }); + + it("should set collab.pauseFeedback when COLLAB_DISABLE fires with fromShare:true", () => { + collabDisableHandler({ + data: { collab: { fromShare: true, pauseFeedback: true } }, + }); + + expect(realConfig.get().collab.pauseFeedback).toBe(true); + }); +}); diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts index 7040bba7..f484f1a2 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -15,6 +15,7 @@ import { generateStartEditingButton } from "./generators/generateStartEditingBut import { addFocusOverlay } from "./generators/generateOverlay"; import { getEntryIdentifiersInCurrentPage } from "./utils/getEntryIdentifiersInCurrentPage"; +import { resolvePageContext } from "./utils/resolvePageContext"; import visualBuilderPostMessage from "./utils/visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "./utils/types/postMessage.types"; @@ -308,11 +309,16 @@ export class VisualBuilder { return; } + const { entryUid, contentTypeUid } = resolvePageContext(); + const initPayload: any = { + isSSR: config.ssr, + href: window.location.href, + entry_uid: entryUid, + content_type_uid: contentTypeUid, + }; + visualBuilderPostMessage - ?.send("init", { - isSSR: config.ssr, - href: window.location.href, - }) + ?.send("init", initPayload) .then((data) => { const { windowType = ILivePreviewWindowType.BUILDER, diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts index c203cb8c..cb65241a 100644 --- a/src/visualBuilder/listeners/__test__/mouseClick.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts @@ -73,11 +73,15 @@ vi.mock("../../components/FieldRevert/FieldRevertComponent", () => ({ vi.mock("get-xpath", () => ({ default: vi.fn().mockReturnValue("/div") })); -vi.mock("../../configManager/configManager", () => ({ +const hoistedConfigMocks = vi.hoisted(() => ({ + configGet: vi.fn().mockReturnValue({ + collab: { enable: false, isFeedbackMode: false, pauseFeedback: false }, + }), +})); + +vi.mock("../../../configManager/configManager", () => ({ default: { - get: vi.fn().mockReturnValue({ - collab: { enable: false, isFeedbackMode: false, pauseFeedback: false }, - }), + get: hoistedConfigMocks.configGet, set: vi.fn(), }, })); @@ -110,6 +114,7 @@ const { getCsDataOfElement } = await import("../../utils/getCsDataOfElement"); const { addFocusOverlay } = await import("../../generators/generateOverlay"); const { handleIndividualFields } = await import("../../utils/handleIndividualFields"); const { isCustomFieldMultipleInstance } = await import("../../utils/isCustomFieldMultipleInstance"); +const { generateThread, toggleCollabPopup } = await import("../../generators/generateThread"); function makeEditableElement(): HTMLElement { const el = document.createElement("div"); @@ -210,3 +215,38 @@ describe("handleBuilderInteraction — custom field multiple instance suppressio expect(addFocusOverlay).toHaveBeenCalled(); }); }); + +describe("handleBuilderInteraction — pauseFeedback guard", () => { + let editableElement: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + editableElement = makeEditableElement(); + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = null; + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false; + }); + + it("should return early without processing click when collab is enabled and pauseFeedback is true", async () => { + hoistedConfigMocks.configGet.mockReturnValue({ + collab: { enable: true, pauseFeedback: true, isFeedbackMode: true }, + }); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(getCsDataOfElement).not.toHaveBeenCalled(); + expect(generateThread).not.toHaveBeenCalled(); + expect(toggleCollabPopup).not.toHaveBeenCalled(); + expect(addFocusOverlay).not.toHaveBeenCalled(); + }); + + it("should call generateThread when collab is enabled and pauseFeedback is false", async () => { + hoistedConfigMocks.configGet.mockReturnValue({ + collab: { enable: true, pauseFeedback: false, isFeedbackMode: true }, + }); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(generateThread).toHaveBeenCalled(); + }); +}); diff --git a/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts b/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts index 70a76b19..06162d8e 100644 --- a/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts +++ b/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts @@ -1,5 +1,6 @@ import Config from "../../configManager/configManager"; import { extractDetailsFromCslp, isValidCslp } from "../../cslp"; +import { resolvePageContext } from "./resolvePageContext"; /** * Returns the redirection URL for the Visual builder. @@ -36,6 +37,15 @@ export default function getVisualBuilderRedirectionUrl(): URL { searchParams.set("locale", localeToUse); } + const { entryUid, contentTypeUid } = resolvePageContext(); + + if (entryUid) { + searchParams.set("entry_uid", entryUid); + } + if (contentTypeUid) { + searchParams.set("content_type_uid", contentTypeUid); + } + const completeURL = new URL( `/#!/stack/${apiKey}/visual-editor?${searchParams.toString()}`, appUrl diff --git a/src/visualBuilder/utils/resolvePageContext.ts b/src/visualBuilder/utils/resolvePageContext.ts new file mode 100644 index 00000000..538ed4f1 --- /dev/null +++ b/src/visualBuilder/utils/resolvePageContext.ts @@ -0,0 +1,28 @@ +import Config from "../../configManager/configManager"; + +// Meta tags are kept as a fallback because: +// - Next.js App Router's metadata/generateMetadata() and Nuxt's useHead() produce +// tags natively — there is no built-in hook for injecting window globals. +// - Strict CSP policies (no unsafe-inline) block inline