Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-personalize-web-experiences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sitecore-content-sdk/personalize': patch
---

Fix Sitecore Personalize web experiences not rendering under Content SDK 2.x. When `webPersonalization` is enabled, the personalize browser plugin injects the Sitecore-hosted web personalization library (`cloud-version.min.js` / `cloud-lib.min.js`), which reads a `window.scCloudSDK` global at load time. The Content SDK only set `window.scContentSDK`, so the library threw `Cannot read properties of undefined (reading 'personalize')` and `Cannot read properties of undefined (reading 'getGuestId')`, and no web experiences (banners, popups, takeovers) rendered. The browser plugin now populates `window.scCloudSDK` (`core.getBrowserId`/`core.getGuestId`/`core.settings` and `personalize.settings`) entirely from the Content SDK's own identity functions — no additional Sitecore Edge calls, and nothing outside `@sitecore-content-sdk/*` is used. A `window.scCloudSDK` already registered by another script is left untouched.
59 changes: 59 additions & 0 deletions packages/personalize/src/initialization/plugin-browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as coreModule from '@sitecore-content-sdk/core';
import * as analyticsPluginModule from '@sitecore-content-sdk/analytics-core/internal';
import * as analyticsUtilsModule from '@sitecore-content-sdk/analytics-core/utils';
import * as getCdnUrlModule from '../web-personalization/get-cdn-url';
import * as setWebPersonalizationGlobalModule from '../web-personalization/set-web-personalization-global';
import * as getProfileIdModule from './get-profile-id';
import { PersonalizeAdapter } from './types';
import { jest, expect } from '@jest/globals';
Expand Down Expand Up @@ -44,6 +45,10 @@ jest.mock('../web-personalization/get-cdn-url', () => ({
getCdnUrl: jest.fn(),
}));

jest.mock('../web-personalization/set-web-personalization-global', () => ({
setWebPersonalizationGlobal: jest.fn(),
}));

describe('personalizeBrowserPlugin', () => {
const mockGetProfileId = jest.fn() as jest.Mock<PersonalizeAdapter['getProfileId']>;
const mockSetProfileId = jest.fn() as jest.Mock<PersonalizeAdapter['setProfileId']>;
Expand Down Expand Up @@ -444,6 +449,60 @@ describe('personalizeBrowserPlugin', () => {
language: 'en',
});
});

it('should populate the web personalization global via setWebPersonalizationGlobal before injecting the CDN script', async () => {
const adapter = createMockAdapter();
(
getCdnUrlModule.getCdnUrl as jest.Mock<typeof getCdnUrlModule.getCdnUrl>
).mockResolvedValue('https://cdn.test.com/script.js');

const plugin = personalizeBrowserPlugin({
adapter,
options: { webPersonalization: { async: true } },
});

(sharedModule.getPersonalizePlugin as jest.Mock).mockReturnValue(plugin);

await plugin.init();

expect(setWebPersonalizationGlobalModule.setWebPersonalizationGlobal).toHaveBeenCalledWith(
mockCoreContext.config,
{ async: true, defer: false, language: undefined }
);
});

it('should not populate the web personalization global when webPersonalization is false', async () => {
const adapter = createMockAdapter();

const plugin = personalizeBrowserPlugin({
adapter,
options: { webPersonalization: false },
});

(sharedModule.getPersonalizePlugin as jest.Mock).mockReturnValue(plugin);

await plugin.init();

expect(setWebPersonalizationGlobalModule.setWebPersonalizationGlobal).not.toHaveBeenCalled();
});

it('should not populate the web personalization global when CDN URL is not available', async () => {
const adapter = createMockAdapter();
(
getCdnUrlModule.getCdnUrl as jest.Mock<typeof getCdnUrlModule.getCdnUrl>
).mockResolvedValue(null);

const plugin = personalizeBrowserPlugin({
adapter,
options: { webPersonalization: { async: true } },
});

(sharedModule.getPersonalizePlugin as jest.Mock).mockReturnValue(plugin);

await plugin.init();

expect(setWebPersonalizationGlobalModule.setWebPersonalizationGlobal).not.toHaveBeenCalled();
});
});
});
});
6 changes: 6 additions & 0 deletions packages/personalize/src/initialization/plugin-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { EVENTS_PLUGIN_NAME } from '@sitecore-content-sdk/events/internal';
import { getCoreContext } from '@sitecore-content-sdk/core';
import { getCdnUrl } from '../web-personalization/get-cdn-url';
import { setWebPersonalizationGlobal } from '../web-personalization/set-web-personalization-global';
import { appendScriptWithAttributes } from '@sitecore-content-sdk/analytics-core/utils';
import { getPersonalizePlugin } from './shared';
import {
Expand Down Expand Up @@ -62,6 +63,11 @@ async function init() {

if (!cdnUrl) return;

// The Sitecore-hosted web personalization library injected below reads a `window.scCloudSDK`
// global at load time. Populate it from the Content SDK's own identity before injecting the
// script so the library can render web experiences. See setWebPersonalizationGlobal for details.
setWebPersonalizationGlobal(coreConfig, personalizeOptions.webPersonalization);

appendScriptWithAttributes({
async: personalizeOptions.webPersonalization.async,
src: cdnUrl,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { setWebPersonalizationGlobal } from './set-web-personalization-global';
import * as getProfileIdModule from '../initialization/get-profile-id';
import * as analyticsInternalModule from '@sitecore-content-sdk/analytics-core/internal';
import { PACKAGE_VERSION } from '../consts';
import { WebPersonalizationOptions } from '../initialization/types';
import { jest, expect } from '@jest/globals';

jest.mock('@sitecore-content-sdk/analytics-core/internal', () => ({
getAnalyticsPlugin: jest.fn(),
}));

jest.mock('../initialization/get-profile-id', () => ({
getProfileId: jest.fn(),
}));

describe('setWebPersonalizationGlobal', () => {
const coreConfig = {
siteName: 'test-site',
contextId: 'test-context-id',
edgeUrl: 'https://edge.test.com',
};

const webPersonalization: WebPersonalizationOptions = {
async: true,
defer: false,
language: 'en-us',
};

const mockGetClientId = jest.fn() as jest.Mock<() => string | null>;

beforeEach(() => {
jest.clearAllMocks();
mockGetClientId.mockReturnValue('client-id-123');
(analyticsInternalModule.getAnalyticsPlugin as jest.Mock).mockReturnValue({
adapter: { getClientId: mockGetClientId },
});
if (typeof window !== 'undefined') {
delete (window as any).scCloudSDK;
}
});

it('should not throw or set the global when window is undefined', () => {
const originalWindow = global.window;
// @ts-expect-error - simulating SSR environment
delete global.window;

expect(() => setWebPersonalizationGlobal(coreConfig, webPersonalization)).not.toThrow();

global.window = originalWindow;
});

it('should expose personalize.settings so the CDN bootstrap does not throw', () => {
setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
expect(sdk).toBeDefined();
expect(sdk.personalize.settings).toEqual(webPersonalization);
expect(sdk.personalize.version).toBe(PACKAGE_VERSION);
});

it('should copy the web personalization options onto personalize.settings', () => {
setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
// A copy, not the same reference, so the library writing guestId/params onto settings does not
// leak onto the options exposed on window.scContentSDK.personalize.options.
expect(sdk.personalize.settings).not.toBe(webPersonalization);
expect(sdk.personalize.settings).toEqual(webPersonalization);
});

it('should populate core.settings from the core config', () => {
setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
expect(sdk.core.settings).toEqual({
siteName: 'test-site',
sitecoreEdgeContextId: 'test-context-id',
sitecoreEdgeUrl: 'https://edge.test.com',
});
expect(sdk.core.version).toBe(PACKAGE_VERSION);
});

it('should wire core.getBrowserId to the analytics client id', () => {
setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
expect(sdk.core.getBrowserId()).toBe('client-id-123');
expect(mockGetClientId).toHaveBeenCalled();
});

it('should return an empty string from core.getBrowserId when the client id is missing', () => {
mockGetClientId.mockReturnValue(null);

setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
expect(sdk.core.getBrowserId()).toBe('');
});

it('should wire core.getGuestId to getProfileId', async () => {
(
getProfileIdModule.getProfileId as jest.Mock<typeof getProfileIdModule.getProfileId>
).mockResolvedValue('guest-ref-789');

setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
await expect(sdk.core.getGuestId()).resolves.toBe('guest-ref-789');
});

it('should preserve a global already registered by another script', () => {
const existingGetGuestId = jest.fn();
const existingGetBrowserId = jest.fn();
(window as any).scCloudSDK = {
core: {
getGuestId: existingGetGuestId,
getBrowserId: existingGetBrowserId,
settings: {
siteName: 'existing-site',
sitecoreEdgeContextId: 'existing-ctx',
sitecoreEdgeUrl: 'https://existing.edge',
},
version: '9.9.9',
},
personalize: {
settings: { async: false, defer: true },
version: '9.9.9',
},
};

setWebPersonalizationGlobal(coreConfig, webPersonalization);

const sdk = (window as any).scCloudSDK;
// An already-registered runtime is authoritative and must be preserved.
expect(sdk.core.getGuestId).toBe(existingGetGuestId);
expect(sdk.core.getBrowserId).toBe(existingGetBrowserId);
expect(sdk.core.version).toBe('9.9.9');
expect(sdk.core.settings.siteName).toBe('existing-site');
expect(sdk.personalize.version).toBe('9.9.9');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { getAnalyticsPlugin } from '@sitecore-content-sdk/analytics-core/internal';
import { getProfileId } from '../initialization/get-profile-id';
import { PACKAGE_VERSION } from '../consts';
import { WebPersonalizationOptions } from '../initialization/types';

/**
* Core configuration values required to construct the web personalization global.
* @internal
*/
export interface WebPersonalizationGlobalConfig {
siteName: string;
contextId: string;
edgeUrl: string;
}

/**
* Returns the browser id, read from the `sc_cid` cookie via the analytics adapter.
* @returns {string} The browser id, or an empty string when it is not available.
* @internal
*/
function getBrowserId(): string {
return getAnalyticsPlugin().adapter.getClientId() || '';
}

/**
* Populates the `window.scCloudSDK` global that the Sitecore-hosted web personalization library
* (the `cloud-version.min.js` / `cloud-lib.min.js` scripts injected from the Personalize CDN) reads
* at load time. That library is served by Sitecore Edge - it is not shipped with this SDK - and
* `scCloudSDK` is the global name it looks for:
*
* - `scCloudSDK.personalize.settings` is read by the CDN bootstrap; an undefined value throws
* `Cannot read properties of undefined (reading 'personalize')`.
* - `scCloudSDK.core.getGuestId()` / `scCloudSDK.core.getBrowserId()` provide the guest identity
* used to render web experiences; an undefined `core` throws
* `Cannot read properties of undefined (reading 'getGuestId')`.
* - `scCloudSDK.core.settings.{siteName,sitecoreEdgeContextId,sitecoreEdgeUrl}` are used to send
* the WEB event back to Sitecore Edge.
*
* The Content SDK otherwise only sets `window.scContentSDK`, so the library found no global to read
* and threw. This builds `window.scCloudSDK` entirely from the Content SDK's own identity functions
* (`getClientId` for the browser id, `getProfileId` for the guest id); no additional Sitecore Edge
* calls are made and nothing outside `@sitecore-content-sdk/*` is used.
*
* A `window.scCloudSDK` already registered by another script is preserved and never overridden, so
* existing consumers keep working.
* @param {WebPersonalizationGlobalConfig} coreConfig - The resolved core configuration (site name, context id, edge url).
* @param {WebPersonalizationOptions} webPersonalization - The resolved web personalization options.
* @internal
*/
export function setWebPersonalizationGlobal(
coreConfig: WebPersonalizationGlobalConfig,
webPersonalization: WebPersonalizationOptions
): void {
if (typeof window === 'undefined') return;

const existing = window.scCloudSDK;

window.scCloudSDK = {
...existing,
core: {
getBrowserId,
getGuestId: getProfileId,
settings: {
siteName: coreConfig.siteName,
sitecoreEdgeContextId: coreConfig.contextId,
sitecoreEdgeUrl: coreConfig.edgeUrl,
},
version: PACKAGE_VERSION,
// Preserve a runtime another script may have registered; don't clobber it.
...existing?.core,
},
personalize: {
version: PACKAGE_VERSION,
// Copy the options so the library writing guestId/params onto settings does not mutate the
// options exposed on window.scContentSDK.personalize.options.
settings: { ...webPersonalization },
...existing?.personalize,
},
};
}

declare global {
// eslint-disable-next-line no-unused-vars
interface WebPersonalizationGlobalCore {
getBrowserId: () => string;
getGuestId: () => Promise<string>;
settings: {
siteName: string;
sitecoreEdgeContextId: string;
sitecoreEdgeUrl: string;
};
version: string;
}
// eslint-disable-next-line no-unused-vars
interface WebPersonalizationGlobalPersonalize {
settings: WebPersonalizationOptions;
version: string;
}
// eslint-disable-next-line no-unused-vars
interface WebPersonalizationGlobal {
core: WebPersonalizationGlobalCore;
personalize: WebPersonalizationGlobalPersonalize;
}
// eslint-disable-next-line no-unused-vars
interface Window {
scCloudSDK?: WebPersonalizationGlobal;
}
}