diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index aef90b68b..ccfc14e01 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -393,17 +393,25 @@ describe('CoreStateful blocked event handling', () => { } }) - it('supports only one stateful instance per runtime until destroy is called', () => { + it('allows only one stateful instance when singleton is enforced (disableSingleton: false)', () => { const first = createCoreStateful() - const createSecondCore = (): CoreStateful => new CoreStateful(config) + const createSecond = (): CoreStateful => new CoreStateful(config, { disableSingleton: false }) - expect(createSecondCore).toThrowError(/already initialized/i) + expect(createSecond).toThrowError(/already initialized/i) first.destroy() - expect(() => { - createCoreStateful() - }).not.toThrow() + const third = createCoreStateful() + expect(third).toBeDefined() + }) + + it('allows multiple stateful instances when singleton is disabled (disableSingleton: true) — simulates SSR', () => { + const first = new CoreStateful(config, { disableSingleton: true }) + const createSecond = (): CoreStateful => new CoreStateful(config, { disableSingleton: true }) + + expect(createSecond).not.toThrow() + + first.destroy() }) it('flushes Insights API and Experience API queues with force on destroy', async () => { diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index fa55a6824..71443887f 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -217,6 +217,7 @@ const OPTIMIZATION_UNLOCKING_EVENT_TYPES: readonly EventType[] = [ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController, ConsentGuard { private readonly singletonOwner: string + private readonly singletonEnforced: boolean private destroyed = false protected readonly allowedEventTypes: AllowedEventType[] protected readonly experienceQueue: ExperienceQueue @@ -248,7 +249,10 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController profile: toObservable(profileSignal), } - constructor(config: CoreStatefulConfig) { + constructor( + config: CoreStatefulConfig, + { disableSingleton = false }: { disableSingleton?: boolean } = {}, + ) { const locale = normalizeExplicitLocale(config.locale) super( @@ -262,7 +266,8 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.eventBuilder.getConsent = () => consentSignal.value this.singletonOwner = `CoreStateful#${++statefulInstanceCounter}` - acquireStatefulRuntimeSingleton(this.singletonOwner) + this.singletonEnforced = !disableSingleton + acquireStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) try { const { allowedEventTypes, defaults, getAnonymousId, onEventBlocked, queuePolicy } = config @@ -305,7 +310,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.initializeEffects() } catch (error) { - releaseStatefulRuntimeSingleton(this.singletonOwner) + releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) throw error } } @@ -454,7 +459,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController }) this.insightsQueue.clearPeriodicFlushTimer() - releaseStatefulRuntimeSingleton(this.singletonOwner) + releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) } reset(): void { diff --git a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts new file mode 100644 index 000000000..0e17dfd5f --- /dev/null +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts @@ -0,0 +1,109 @@ +import { + acquireStatefulRuntimeSingleton, + releaseStatefulRuntimeSingleton, +} from './StatefulRuntimeSingleton' + +type SingletonGlobal = typeof globalThis & { + __ctfl_optimization_stateful_runtime_lock__?: unknown +} + +function clearLock(): void { + const g = globalThis as SingletonGlobal + g.__ctfl_optimization_stateful_runtime_lock__ = undefined +} + +describe('StatefulRuntimeSingleton — enforce: true (browser)', () => { + beforeEach(() => { + clearLock() + }) + afterEach(() => { + clearLock() + }) + + it('acquires the lock for the first owner', () => { + expect(() => { + acquireStatefulRuntimeSingleton('owner-1', true) + }).not.toThrow() + }) + + it('throws when a second owner tries to acquire a held lock', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', true) + }).toThrowError(/already initialized/i) + }) + + it('releases the lock so a new owner can acquire it', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + releaseStatefulRuntimeSingleton('owner-1', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', true) + }).not.toThrow() + }) + + it('does not release the lock when a different owner calls release', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + releaseStatefulRuntimeSingleton('owner-2', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-3', true) + }).toThrowError(/already initialized/i) + }) + + it('allows repeated acquire/release cycles', () => { + for (let i = 0; i < 3; i++) { + const owner = `owner-${i}` + expect(() => { + acquireStatefulRuntimeSingleton(owner, true) + }).not.toThrow() + expect(() => { + releaseStatefulRuntimeSingleton(owner, true) + }).not.toThrow() + } + }) +}) + +describe('StatefulRuntimeSingleton — enforce: false (server / SSR)', () => { + beforeEach(() => { + clearLock() + }) + afterEach(() => { + clearLock() + }) + + it('does not throw when acquiring', () => { + expect(() => { + acquireStatefulRuntimeSingleton('owner-1', false) + }).not.toThrow() + }) + + it('does not throw on a second acquire — no lock contention', () => { + acquireStatefulRuntimeSingleton('owner-1', false) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', false) + }).not.toThrow() + }) + + it('does not throw when releasing', () => { + acquireStatefulRuntimeSingleton('owner-1', false) + + expect(() => { + releaseStatefulRuntimeSingleton('owner-1', false) + }).not.toThrow() + }) + + it('allows repeated acquire/release cycles — simulates multiple SSR requests', () => { + for (let i = 0; i < 3; i++) { + const owner = `owner-${i}` + expect(() => { + acquireStatefulRuntimeSingleton(owner, false) + }).not.toThrow() + expect(() => { + releaseStatefulRuntimeSingleton(owner, false) + }).not.toThrow() + } + }) +}) diff --git a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts index cc77c9987..6df65e994 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts @@ -16,7 +16,9 @@ const getStatefulRuntimeLock = (): StatefulRuntimeLockState => { return singletonGlobal[STATEFUL_RUNTIME_LOCK_KEY] } -export const acquireStatefulRuntimeSingleton = (owner: string): void => { +export const acquireStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return + const lock = getStatefulRuntimeLock() if (lock.owner) { @@ -28,7 +30,9 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { lock.owner = owner } -export const releaseStatefulRuntimeSingleton = (owner: string): void => { +export const releaseStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return + const lock = getStatefulRuntimeLock() if (lock.owner === owner) { diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 2cfea12d8..040fa8f9b 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -125,43 +125,6 @@ describe('@contentful/optimization-react-web core providers', () => { expect(optimization.trackHover).toBeTypeOf('function') expect(optimization.tracking).toBeDefined() rendered.unmount() - - capturedOptimization = undefined - const withoutLocale = renderClient( - - - , - ) - - expect(requireOptimizationSdk(capturedOptimization).locale).toBeUndefined() - withoutLocale.unmount() - }) - - it('does not create an owned optimization instance during server render', () => { - let renderedChild = false - - function Probe(): null { - renderedChild = true - return null - } - - const markup = renderToString( - - - , - ) - - expect(markup).toBe('') - expect(renderedChild).toBe(false) - expect(window.contentfulOptimization).toBeUndefined() }) it('provides optimization and live updates from OptimizationRoot', () => { @@ -549,7 +512,7 @@ describe('@contentful/optimization-react-web core providers', () => { expect(results).toEqual([true, false, true]) }) - it('destroys the optimization singleton on provider unmount', () => { + it('keeps the SDK singleton alive after provider unmount', () => { const container = document.createElement('div') document.body.append(container) const root = createRoot(container) @@ -572,7 +535,7 @@ describe('@contentful/optimization-react-web core providers', () => { root.unmount() }) - expect(window.contentfulOptimization).toBeUndefined() + expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization) const remountRoot = createRoot(container) @@ -597,7 +560,7 @@ describe('@contentful/optimization-react-web core providers', () => { container.remove() }) - it('cleans up layout-effect initialization during StrictMode replay', () => { + it('keeps the SDK singleton alive after StrictMode unmount', () => { const rendered = renderClient( { rendered.unmount() - expect(window.contentfulOptimization).toBeUndefined() + expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization) }) it('uses an injected sdk instance without taking ownership of teardown', () => { @@ -649,4 +612,29 @@ describe('@contentful/optimization-react-web core providers', () => { expect(destroySpy).not.toHaveBeenCalled() container.remove() }) + + it('adopts a window singleton left by an interrupted concurrent-mode render instead of destroying it', () => { + // Simulate a concurrent-mode render that was interrupted before effects ran: + // the SDK constructor set window.contentfulOptimization but the component was + // discarded without cleanup. The new behavior is to reuse the instance rather + // than destroy-and-recreate it. + const stale = new ContentfulOptimization(testConfig) + const destroySpy = rs.spyOn(stale, 'destroy') + window.contentfulOptimization = stale + + const rendered = renderClient( + +
+ , + ) + + expect(destroySpy).not.toHaveBeenCalled() + expect(window.contentfulOptimization).toBe(stale) + + rendered.unmount() + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c7569c612..6fb608e72 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -154,6 +154,11 @@ describe('OptimizationProvider onStatesReady', () => { resetAutoPageEmitterState() }) + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + }) + it('accepts onStatesReady on OptimizationProvider and OptimizationRoot props', () => { const onStatesReady = rs.fn() const providerProps: OptimizationProviderProps = { @@ -334,7 +339,7 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not construct owned sdk instances during server render', () => { + it('renders children and initializes sdk synchronously during server render', () => { let childRendered = false function Probe(): null { @@ -342,7 +347,7 @@ describe('OptimizationProvider onStatesReady', () => { return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) - expect(window.contentfulOptimization).toBeUndefined() + // The SDK is always initialized synchronously in useState so children render during SSR. + // The singleton is registered on window.contentfulOptimization in happy-dom (which has window); + // on a real Node.js server, window is undefined so window.contentfulOptimization is never set. + expect(childRendered).toBe(true) + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization }) it('renders injected sdk children during initial render when no state setup is needed', () => { @@ -428,10 +436,9 @@ describe('OptimizationProvider onStatesReady', () => { expect(onStatesReady).not.toHaveBeenCalled() }) - it('destroys owned sdk instances when onStatesReady throws', () => { + it('surfaces an error context when onStatesReady throws', () => { const error = new Error('states setup failed') let capturedContext: OptimizationContextValue | null = null - const destroySpy = rs.spyOn(ContentfulOptimization.prototype, 'destroy') const rendered = createClientRoot() function Probe(): null { @@ -457,9 +464,6 @@ describe('OptimizationProvider onStatesReady', () => { isReady: false, error, }) - expect(destroySpy).toHaveBeenCalledTimes(1) - expect(window.contentfulOptimization).toBeUndefined() - destroySpy.mockRestore() rendered.unmount() }) @@ -491,15 +495,8 @@ describe('OptimizationProvider onStatesReady', () => { expect(destroySpy).not.toHaveBeenCalled() }) - it('runs onStatesReady cleanup before owned sdk teardown', () => { - const order: string[] = [] - const { destroy: originalDestroy } = ContentfulOptimization.prototype - const destroySpy = rs - .spyOn(ContentfulOptimization.prototype, 'destroy') - .mockImplementation(function destroy(this: ContentfulOptimization): void { - order.push('destroy') - originalDestroy.call(this) - }) + it('runs onStatesReady cleanup on unmount', () => { + const cleanup = rs.fn() const rendered = createClientRoot() rendered.render( @@ -507,9 +504,7 @@ describe('OptimizationProvider onStatesReady', () => { clientId={testConfig.clientId} environment={testConfig.environment} api={testConfig.api} - onStatesReady={() => () => { - order.push('cleanup') - }} + onStatesReady={() => cleanup} >
, @@ -517,9 +512,7 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() - expect(order).toEqual(['cleanup', 'destroy']) - expect(destroySpy).toHaveBeenCalledTimes(1) - destroySpy.mockRestore() + expect(cleanup).toHaveBeenCalledTimes(1) }) it('captures provider props on first mount until the key changes', () => { diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.singleton.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.singleton.test.tsx new file mode 100644 index 000000000..b15f8052a --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.singleton.test.tsx @@ -0,0 +1,223 @@ +import { describe, expect, it, rs } from '@rstest/core' +import { act, type ReactElement } from 'react' +import { createRoot } from 'react-dom/client' +import { OptimizationProvider } from '../index' + +const constructorCalls: Array> = [] +const setConfigCalls: Array> = [] + +let mockInstanceCount = 0 + +rs.mock('@contentful/optimization-web', () => { + class MockContentfulOptimization { + readonly instanceId: number + + constructor(config: Record) { + this.instanceId = ++mockInstanceCount + constructorCalls.push(config) + Reflect.set(window, 'contentfulOptimization', this) + } + + destroy(): void { + Reflect.deleteProperty(window, 'contentfulOptimization') + } + + setLocale(locale: string): string { + return locale + } + + setConfig(patch: Record): void { + setConfigCalls.push(patch) + } + + static getOrCreate(config: Record): MockContentfulOptimization { + const current: unknown = Reflect.get(window, 'contentfulOptimization') + if (current instanceof MockContentfulOptimization) { + current.setConfig(config) + return current + } + return new MockContentfulOptimization(config) + } + } + + return { isBrowser: () => true, default: MockContentfulOptimization } +}) + +function renderProvider(element: ReactElement): { + unmount: () => void + update: (element: ReactElement) => void +} { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + + act(() => { + root.render(element) + }) + + return { + update(nextElement: ReactElement) { + act(() => { + root.render(nextElement) + }) + }, + unmount() { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +const BASE_PROPS = { clientId: 'test-client-id', environment: 'main' } + +describe('OptimizationProvider singleton lifecycle', () => { + beforeEach(() => { + constructorCalls.length = 0 + setConfigCalls.length = 0 + mockInstanceCount = 0 + }) + + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + }) + + it('creates a new SDK when no singleton exists on mount', () => { + const rendered = renderProvider( + +
+ , + ) + + expect(constructorCalls).toHaveLength(1) + + rendered.unmount() + }) + + it('adopts the existing singleton instead of constructing a new one when window.contentfulOptimization is already set on mount', () => { + const rendered = renderProvider( + +
+ , + ) + + expect(constructorCalls).toHaveLength(1) + const firstInstanceId = mockInstanceCount + + const rendered2 = renderProvider( + +
+ , + ) + + expect(constructorCalls).toHaveLength(1) + expect(mockInstanceCount).toBe(firstInstanceId) + + rendered.unmount() + rendered2.unmount() + }) + + it('calls setConfig with current props when adopting an existing singleton', () => { + const rendered = renderProvider( + +
+ , + ) + + setConfigCalls.length = 0 + + const rendered2 = renderProvider( + +
+ , + ) + + expect(setConfigCalls.length).toBeGreaterThanOrEqual(1) + expect(setConfigCalls.some((c) => c.locale === 'de-DE')).toBe(true) + + rendered.unmount() + rendered2.unmount() + }) + + it('does not call destroy on unmount', () => { + const destroySpy = rs.fn() + const rendered = renderProvider( + +
+ , + ) + + const sdk = window.contentfulOptimization + if (sdk) sdk.destroy = destroySpy + + rendered.unmount() + + expect(destroySpy).not.toHaveBeenCalled() + }) + + it('calls setConfig when a mutable prop (locale) changes on re-render', () => { + const rendered = renderProvider( + +
+ , + ) + + setConfigCalls.length = 0 + + rendered.update( + +
+ , + ) + + expect(setConfigCalls).toHaveLength(1) + expect(setConfigCalls[0]).toMatchObject({ locale: 'de-DE' }) + + rendered.unmount() + }) + + it('calls setConfig when autoTrackEntryInteraction changes on re-render', () => { + const rendered = renderProvider( + +
+ , + ) + + setConfigCalls.length = 0 + + rendered.update( + +
+ , + ) + + expect(setConfigCalls).toHaveLength(1) + expect(setConfigCalls[0]).toMatchObject({ + autoTrackEntryInteraction: expect.objectContaining({ views: false }), + }) + + rendered.unmount() + }) + + it('only constructs one SDK instance across a StrictMode unmount+remount cycle when a singleton already exists', () => { + const rendered = renderProvider( + +
+ , + ) + + const countAfterFirst = constructorCalls.length + + rendered.update( + +
+ , + ) + + expect(constructorCalls.length).toBe(countAfterFirst) + + rendered.unmount() + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx new file mode 100644 index 000000000..7e9c78ff0 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx @@ -0,0 +1,168 @@ +import ContentfulOptimization from '@contentful/optimization-web' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { OptimizationProvider, useOptimization, type OptimizationSdk } from '../index' + +// Simulate a Node.js / SSR environment: disable browser APIs so the SDK behaves +// as it would on the server — no IntersectionObserver, MutationObserver, or DOM listeners. +rs.mock('@contentful/optimization-web/constants', () => ({ + CAN_ADD_LISTENERS: false, + HAS_MUTATION_OBSERVER: false, + OPTIMIZATION_WEB_SDK_NAME: '@contentful/optimization-web', + OPTIMIZATION_WEB_SDK_VERSION: '0.0.0', + ANONYMOUS_ID_COOKIE: '__ctfl_anon', + ENTRY_ID_ATTRIBUTE: 'data-ctfl-entry-id', + ENTRY_SELECTOR: '[data-ctfl-entry-id]', +})) + +const testConfig = { + clientId: 'test-client-id', + environment: 'main', + api: { + insightsBaseUrl: 'http://localhost:8000/insights/', + experienceBaseUrl: 'http://localhost:8000/experience/', + }, +} + +describe('OptimizationProvider — SSR (CAN_ADD_LISTENERS: false, synchronous useState init)', () => { + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + }) + + it('renders children during renderToString — provider does not return null on server', () => { + let renderedChild = false + + function Probe(): null { + renderedChild = true + return null + } + + renderToString( + + + , + ) + + expect(renderedChild).toBe(true) + }) + + it('useOptimization returns a real ContentfulOptimization SDK during renderToString', () => { + let capturedSdk: OptimizationSdk | undefined = undefined + + function Probe(): null { + capturedSdk = useOptimization() + return null + } + + renderToString( + + + , + ) + + expect(capturedSdk).toBeInstanceOf(ContentfulOptimization) + }) + + it('useOptimization returns a real SDK to deeply nested children during renderToString', () => { + const capturedSdks: OptimizationSdk[] = [] + + function DeepChild(): null { + capturedSdks.push(useOptimization()) + return null + } + + function MiddleComponent(): ReactElement { + return + } + + renderToString( + + + , + ) + + expect(capturedSdks).toHaveLength(1) + expect(capturedSdks[0]).toBeInstanceOf(ContentfulOptimization) + }) + + it('server render produces non-empty HTML markup', () => { + function Content(): ReactElement { + return
hello
+ } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('hello') + }) + + it('allows multiple sequential renderToString calls — no singleton lock contention across requests', () => { + function Probe(): null { + useOptimization() + return null + } + + const render = (): void => { + renderToString( + + + , + ) + // In a real Node.js environment (no window), each render creates a fresh SDK that is + // garbage-collected after renderToString without cleanup. In happy-dom, window is present + // so window.contentfulOptimization must be cleared between renders to avoid the browser + // singleton guard from triggering — simulating the per-request isolation of a real server. + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + } + + expect(render).not.toThrow() + expect(render).not.toThrow() + expect(render).not.toThrow() + }) + + it('does not throw when window has a stale singleton from an interrupted render — simulates concurrent render restart', () => { + // Concurrent React can run the render phase (which calls init() and constructs + // an SDK) and then discard the work-in-progress tree before effects execute. + // The SDK constructor sets window.contentfulOptimization but no cleanup runs, + // so the next render attempt finds a stale singleton and must destroy it first. + const stale = new ContentfulOptimization(testConfig) + window.contentfulOptimization = stale + + expect(() => { + renderToString( + +
+ , + ) + }).not.toThrow() + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx index ac3ca4a28..dc9ac7184 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx @@ -4,10 +4,10 @@ import { createRoot } from 'react-dom/client' import { OptimizationProvider } from '../index' const constructedConfigs: Array> = [] -const setLocaleCalls: string[] = [] +const setConfigCalls: Array> = [] -rs.mock('@contentful/optimization-web', () => ({ - default: class MockContentfulOptimization { +rs.mock('@contentful/optimization-web', () => { + class MockContentfulOptimization { constructor(config: Record) { constructedConfigs.push(config) Reflect.set(window, 'contentfulOptimization', this) @@ -18,11 +18,25 @@ rs.mock('@contentful/optimization-web', () => ({ } setLocale(locale: string): string { - setLocaleCalls.push(locale) return locale } - }, -})) + + setConfig(patch: Record): void { + setConfigCalls.push(patch) + } + + static getOrCreate(config: Record): MockContentfulOptimization { + const current: unknown = Reflect.get(window, 'contentfulOptimization') + if (current instanceof MockContentfulOptimization) { + current.setConfig(config) + return current + } + return new MockContentfulOptimization(config) + } + } + + return { isBrowser: () => true, default: MockContentfulOptimization } +}) function renderProvider(element: ReactElement): { unmount: () => void @@ -64,7 +78,7 @@ function requireConfig(index: number): Record { describe('OptimizationProvider trackEntryInteraction', () => { it('maps default React tracking options to Web SDK auto tracking options', () => { constructedConfigs.length = 0 - setLocaleCalls.length = 0 + setConfigCalls.length = 0 const rendered = renderProvider( @@ -83,7 +97,7 @@ describe('OptimizationProvider trackEntryInteraction', () => { it('maps explicit React tracking options to Web SDK auto tracking options', () => { constructedConfigs.length = 0 - setLocaleCalls.length = 0 + setConfigCalls.length = 0 const rendered = renderProvider( { it('updates the owned SDK when the locale prop changes', () => { constructedConfigs.length = 0 - setLocaleCalls.length = 0 + setConfigCalls.length = 0 const rendered = renderProvider( @@ -116,7 +130,7 @@ describe('OptimizationProvider trackEntryInteraction', () => { , ) - expect(setLocaleCalls).toEqual(['en-US']) + expect(setConfigCalls.some((c) => c.locale === 'en-US')).toBe(true) rendered.update( @@ -124,7 +138,7 @@ describe('OptimizationProvider trackEntryInteraction', () => { , ) - expect(setLocaleCalls).toEqual(['en-US', 'de-DE']) + expect(setConfigCalls.some((c) => c.locale === 'de-DE')).toBe(true) rendered.unmount() }) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index eaa002daa..b13a6258b 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -2,14 +2,19 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' import { - createOptimizationRootSdkBinding, - disposeOptimizationRootSdkBinding, - type OptimizationRootSdkBinding, + resolveTrackEntryInteractionOptions, type OptimizationRootSdkConfig, type OnStatesReady as SharedOnStatesReady, type TrackEntryInteractionOptions as SharedTrackEntryInteractionOptions, } from '@contentful/optimization-web/presentation' -import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' @@ -21,16 +26,32 @@ import { OptimizationContext, type OptimizationSdk } from '../context/Optimizati export type OnStatesReady = SharedOnStatesReady export type TrackEntryInteractionOptions = SharedTrackEntryInteractionOptions -type OptimizationProviderBaseConfigProps = OptimizationRootSdkConfig -type ProviderSdkBinding = OptimizationRootSdkBinding - -interface ProviderState { - readonly error: Error | undefined - readonly isReady: boolean - readonly sdk: OptimizationSdk | undefined -} +export type OptimizationProviderConfigProps = PropsWithChildren< + OptimizationRootSdkConfig & { + /** + * Server-returned Optimization state to apply before provider children mount. + * + * @remarks + * Use this for server-to-browser state handoff. Keep `defaults` for configuration and default + * state such as consent policy. + */ + readonly serverOptimizationState?: OptimizationData + /** + * Controls automatic entry interaction tracking for OptimizedEntry components. + * + * @defaultValue `{ views: true, clicks: true, hovers: true }` + */ + readonly trackEntryInteraction?: TrackEntryInteractionOptions + /** + * Called once the SDK state surface is initialized and before provider children mount. + * Return a cleanup function to unsubscribe app-level state observers on teardown. + */ + readonly onStatesReady?: OnStatesReady + readonly sdk?: never + } +> -interface ServerOptimizationStateProps { +export type OptimizationProviderSdkProps = PropsWithChildren<{ /** * Server-returned Optimization state to apply before provider children mount. * @@ -39,36 +60,13 @@ interface ServerOptimizationStateProps { * state such as consent policy. */ readonly serverOptimizationState?: OptimizationData -} - -export type OptimizationProviderConfigProps = PropsWithChildren< - OptimizationProviderBaseConfigProps & - ServerOptimizationStateProps & { - /** - * Controls automatic entry interaction tracking for OptimizedEntry components. - * - * @defaultValue `{ views: true, clicks: true, hovers: true }` - */ - readonly trackEntryInteraction?: TrackEntryInteractionOptions - /** - * Called once the SDK state surface is initialized and before provider children mount. - * Return a cleanup function to unsubscribe app-level state observers on teardown. - */ - readonly onStatesReady?: OnStatesReady - readonly sdk?: never - } -> - -export type OptimizationProviderSdkProps = PropsWithChildren< - ServerOptimizationStateProps & { - /** - * Called with the injected SDK state surface before provider children mount. - * Return a cleanup function to unsubscribe app-level state observers on teardown. - */ - readonly onStatesReady?: OnStatesReady - readonly sdk: OptimizationSdk - } -> + /** + * Called with the injected SDK state surface before provider children mount. + * Return a cleanup function to unsubscribe app-level state observers on teardown. + */ + readonly onStatesReady?: OnStatesReady + readonly sdk: OptimizationSdk +}> export type OptimizationProviderProps = | OptimizationProviderConfigProps @@ -78,181 +76,104 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -function createInjectedSdkBinding(props: OptimizationProviderSdkProps): ProviderSdkBinding { - const { sdk } = props - - return createOptimizationRootSdkBinding({ sdk }) -} - -function createOwnedSdkBinding(props: OptimizationProviderConfigProps): ProviderSdkBinding { +function resolveOwnedSdk(props: OptimizationProviderConfigProps): OptimizationSdk { const { children: _children, onStatesReady: _onStatesReady, sdk: _sdk, serverOptimizationState: _serverOptimizationState, trackEntryInteraction, + locale, ...config } = props - return createOptimizationRootSdkBinding({ - config, - createSdk: (sdkConfig) => new ContentfulOptimization(sdkConfig), - trackEntryInteraction, + return ContentfulOptimization.getOrCreate({ + ...config, + locale, + autoTrackEntryInteraction: resolveTrackEntryInteractionOptions(trackEntryInteraction), }) } -function disposeSdkBinding(sdkBinding: ProviderSdkBinding | undefined): void { - disposeOptimizationRootSdkBinding(sdkBinding) -} - -function bindOnStatesReady( - sdkBinding: ProviderSdkBinding, - onStatesReady: OnStatesReady | undefined, -): ProviderSdkBinding { - const cleanup = onStatesReady?.(sdkBinding.sdk.states) - - if (typeof cleanup !== 'function') { - return sdkBinding - } - - return { ...sdkBinding, cleanup } -} - -async function initializeServerOptimizationState( - sdkBinding: ProviderSdkBinding, - serverOptimizationState: OptimizationData, - onStatesReady: OnStatesReady | undefined, -): Promise { - try { - await hydrateOptimizationData(sdkBinding.sdk, serverOptimizationState) - - return bindOnStatesReady(sdkBinding, onStatesReady) - } catch (error: unknown) { - disposeSdkBinding(sdkBinding) - throw error - } -} - -function initializeProviderSdk( - props: OptimizationProviderProps, -): ProviderSdkBinding | Promise { - const sdkBinding = - props.sdk === undefined ? createOwnedSdkBinding(props) : createInjectedSdkBinding(props) - - if (props.serverOptimizationState === undefined) { - try { - return bindOnStatesReady(sdkBinding, props.onStatesReady) - } catch (error: unknown) { - disposeSdkBinding(sdkBinding) - throw error - } - } - - return initializeServerOptimizationState( - sdkBinding, - props.serverOptimizationState, - props.onStatesReady, - ) -} - -function isPromiseLike(value: T | Promise): value is Promise { - return value instanceof Promise -} - -function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean { - return ( - props.sdk !== undefined && - props.onStatesReady === undefined && - props.serverOptimizationState === undefined - ) +function hasSetupCallbacks(props: OptimizationProviderProps): boolean { + return props.serverOptimizationState !== undefined || props.onStatesReady !== undefined } export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { - const { children } = props - const initialPropsRef = useRef(props) - const liveLocale = props.sdk === undefined ? props.locale : undefined - const canRenderInjectedSdk = canUseInjectedSdkDuringInitialRender(props) - const [state, setState] = useState(() => ({ - error: undefined, - isReady: canRenderInjectedSdk, - sdk: canRenderInjectedSdk ? props.sdk : undefined, - })) - - useLayoutEffect(() => { - const { current: initialProps } = initialPropsRef - - if (canUseInjectedSdkDuringInitialRender(initialProps)) { - return - } + // SDK ref is initialized once during render — safe for SSR and StrictMode. + const sdkRef = useRef(null) + sdkRef.current ??= props.sdk ?? resolveOwnedSdk(props) - const setupState = { disposed: false } - let sdkBinding: ProviderSdkBinding | undefined = undefined - let disposedBinding: ProviderSdkBinding | undefined = undefined + // Cleanup from onStatesReady; called on unmount when async setup ran. + const cleanupRef = useRef<(() => void) | null>(null) - function disposeOnce(binding: ProviderSdkBinding | undefined): void { - if (binding === undefined || binding === disposedBinding) return + // State only tracks whether async setup is done or failed. + // sdk is always available synchronously from sdkRef. + const [error, setError] = useState(undefined) + const [setupDone, setSetupDone] = useState(() => !hasSetupCallbacks(props)) - disposeSdkBinding(binding) - disposedBinding = binding - } - - function setInitializedState(initializedBinding: ProviderSdkBinding): void { - if (setupState.disposed) { - disposeOnce(initializedBinding) - return - } - - sdkBinding = initializedBinding - setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) - } - - function setInitializationError(error: unknown): void { - if (!setupState.disposed) { - setState({ error: toError(error), isReady: false, sdk: undefined }) - } - } + // Handles async setup: serverOptimizationState hydration and onStatesReady subscription. + // Runs at most once per mount; cleanup unsubscribes onStatesReady listeners on teardown. + useLayoutEffect(() => { + if (!hasSetupCallbacks(props)) return - try { - const initializedBinding = initializeProviderSdk(initialProps) + let disposed = false + const { current: sdk } = sdkRef - if (!isPromiseLike(initializedBinding)) { - setInitializedState(initializedBinding) + if (sdk === null) return - return () => { - setupState.disposed = true - disposeOnce(sdkBinding) + const runSetup = async (): Promise => { + try { + if (props.serverOptimizationState !== undefined) { + await hydrateOptimizationData(sdk, props.serverOptimizationState) } + if (disposed) return + const result = props.onStatesReady?.(sdk.states) + if (typeof result === 'function') cleanupRef.current = result + setSetupDone(true) + } catch (err: unknown) { + if (!disposed) setError(toError(err)) } - - void initializedBinding.then(setInitializedState, setInitializationError) - } catch (error: unknown) { - setInitializationError(error) - return } + void runSetup() + return () => { - setupState.disposed = true - disposeOnce(sdkBinding) + disposed = true + cleanupRef.current?.() + cleanupRef.current = null } }, []) - useLayoutEffect(() => { - if (state.sdk === undefined || props.sdk !== undefined || liveLocale === undefined) { - return - } - - try { - state.sdk.setLocale(liveLocale) - } catch (error: unknown) { - setState({ error: toError(error), isReady: true, sdk: state.sdk }) - } - }, [liveLocale, props.sdk, state.sdk]) + const configProps = props.sdk === undefined ? props : undefined - const shouldRenderChildren = state.isReady || state.error !== undefined - - if (!shouldRenderChildren) { + useLayoutEffect(() => { + const { current: sdk } = sdkRef + if (sdk === null || configProps === undefined) return + + sdk.setConfig({ + locale: configProps.locale, + autoTrackEntryInteraction: resolveTrackEntryInteractionOptions( + configProps.trackEntryInteraction, + ), + }) + }, [configProps?.locale, configProps?.trackEntryInteraction]) + + const contextValue = useMemo(() => { + const sdk = setupDone && error === undefined ? (sdkRef.current ?? undefined) : undefined + return { sdk, isReady: sdk !== undefined, error } + }, [setupDone, error]) + + // Gate rendering when async setup must complete first: + // - onStatesReady: the callback subscribes to SDK state and must run before children mount. + // - serverOptimizationState: async hydration must finish before children see SDK-resolved data. + // In all other cases, always render so Next.js SSR produces HTML and client hydration matches. + if (hasSetupCallbacks(props) && !setupDone && error === undefined) { return null } - return {children} + return ( + + {props.children} + + ) } diff --git a/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts new file mode 100644 index 000000000..490228d1e --- /dev/null +++ b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts @@ -0,0 +1,93 @@ +import type { CoreConfig } from '@contentful/optimization-core' +import { batch, signals } from '@contentful/optimization-core' +import ContentfulOptimization from './ContentfulOptimization' + +// Simulate a Node.js / SSR environment: no browser APIs available. +// This must appear before the module under test is imported so that +// CAN_ADD_LISTENERS resolves to false throughout the module graph. +rs.mock('./constants', () => ({ + CAN_ADD_LISTENERS: false, + HAS_MUTATION_OBSERVER: false, + OPTIMIZATION_WEB_SDK_NAME: 'optimization-web', + OPTIMIZATION_WEB_SDK_VERSION: '0.0.0', + ANONYMOUS_ID_COOKIE: '__ctfl_anon', +})) + +const config: CoreConfig = { + clientId: 'key_123', + environment: 'main', +} + +describe('ContentfulOptimization — server (CAN_ADD_LISTENERS: false)', () => { + afterEach(() => { + if (typeof window !== 'undefined') { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + } + + batch(() => { + signals.consent.value = undefined + signals.persistenceConsent.value = undefined + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + signals.changes.value = undefined + }) + }) + + it('constructs without throwing when browser APIs are unavailable', () => { + expect(() => new ContentfulOptimization(config)).not.toThrow() + }) + + it('constructs without throwing when IntersectionObserver is not defined and consent is granted — simulates Node.js / SSR', () => { + const originalIO = (globalThis as Record).IntersectionObserver + delete (globalThis as Record).IntersectionObserver + + let sdk: ContentfulOptimization | undefined + + try { + // consent: true triggers syncAutoTrackedEntryInteractions → views.start() → new ElementViewObserver + // which calls new IntersectionObserver — must not throw on the server + expect(() => { + sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + }).not.toThrow() + } finally { + sdk?.destroy() + if (originalIO !== undefined) { + ;(globalThis as Record).IntersectionObserver = originalIO + } + } + }) + + it('exposes states with no browser-sourced values', () => { + const sdk = new ContentfulOptimization(config) + + expect(sdk.states.consent.current).toBeUndefined() + expect(sdk.states.profile.current).toBeUndefined() + expect(sdk.states.selectedOptimizations.current).toBeUndefined() + }) + + it('destroys without throwing when browser APIs are unavailable', () => { + const sdk = new ContentfulOptimization(config) + + expect(() => { + sdk.destroy() + }).not.toThrow() + }) + + it('allows a second construction after destroy — no singleton lock contention across SSR requests', () => { + const first = new ContentfulOptimization(config) + first.destroy() + + expect(() => new ContentfulOptimization(config)).not.toThrow() + }) + + it('allows multiple sequential construct/destroy cycles — simulates multiple SSR requests in the same process', () => { + for (let i = 0; i < 3; i++) { + const sdk = new ContentfulOptimization(config) + expect(sdk.states).toBeDefined() + expect(() => { + sdk.destroy() + }).not.toThrow() + } + }) +}) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 45205bd44..0d7feee7c 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -667,4 +667,105 @@ describe('ContentfulOptimization', () => { expect(localStorage.getItem(ANONYMOUS_ID_KEY)).toBeNull() expect(document.cookie).not.toContain(`${ANONYMOUS_ID_COOKIE}=${DEFAULT_PROFILE.id}`) }) + + describe('getOrCreate()', () => { + it('constructs a new instance when no singleton exists', () => { + const sdk = ContentfulOptimization.getOrCreate(config) + + expect(sdk).toBeInstanceOf(ContentfulOptimization) + expect(window.contentfulOptimization).toBe(sdk) + }) + + it('returns the existing singleton when one already exists', () => { + const first = new ContentfulOptimization(config) + + expect(ContentfulOptimization.getOrCreate(config)).toBe(first) + }) + + it('calls setConfig on the existing singleton when adopting', () => { + const first = new ContentfulOptimization(config) + const setConfig = rs.spyOn(first, 'setConfig') + + ContentfulOptimization.getOrCreate({ ...config, locale: 'de-DE' }) + + expect(setConfig).toHaveBeenCalledWith(expect.objectContaining({ locale: 'de-DE' })) + }) + + it('does not destroy the existing singleton when adopting', () => { + const first = new ContentfulOptimization(config) + const destroy = rs.spyOn(first, 'destroy') + + ContentfulOptimization.getOrCreate(config) + + expect(destroy).not.toHaveBeenCalled() + }) + + it('passes autoTrackEntryInteraction to setConfig when adopting', () => { + const first = new ContentfulOptimization(config) + const setConfig = rs.spyOn(first, 'setConfig') + + ContentfulOptimization.getOrCreate({ + ...config, + autoTrackEntryInteraction: { views: false }, + }) + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ autoTrackEntryInteraction: { views: false } }), + ) + }) + }) + + describe('setConfig()', () => { + it('calls setLocale when locale is provided', () => { + const web = new ContentfulOptimization(config) + const setLocale = rs.spyOn(web, 'setLocale') + + web.setConfig({ locale: 'de-DE' }) + + expect(setLocale).toHaveBeenCalledWith('de-DE') + }) + + it('does not call setLocale when locale is absent from the patch', () => { + const web = new ContentfulOptimization(config) + const setLocale = rs.spyOn(web, 'setLocale') + + web.setConfig({}) + + expect(setLocale).not.toHaveBeenCalled() + }) + + it('updates autoTrackEntryInteraction when provided', () => { + const web = new ContentfulOptimization(config) + + expect(getAutoTrackEntryViews(web)).toBe(true) + expect(getAutoTrackEntryClicks(web)).toBe(true) + expect(getAutoTrackEntryHovers(web)).toBe(true) + + web.setConfig({ autoTrackEntryInteraction: { views: false } }) + + expect(getAutoTrackEntryViews(web)).toBe(false) + expect(getAutoTrackEntryClicks(web)).toBe(true) + expect(getAutoTrackEntryHovers(web)).toBe(true) + }) + + it('does not change autoTrackEntryInteraction when absent from the patch', () => { + const web = new ContentfulOptimization({ + ...config, + autoTrackEntryInteraction: { views: false }, + }) + + web.setConfig({ locale: 'en-US' }) + + expect(getAutoTrackEntryViews(web)).toBe(false) + }) + + it('leaves already-set locale unchanged when patching other fields', () => { + const web = new ContentfulOptimization({ ...config, locale: 'en-US' }) + + web.setConfig({ autoTrackEntryInteraction: { clicks: false } }) + + expect(web.locale).toBe('en-US') + expect(getAutoTrackEntryClicks(web)).toBe(false) + }) + }) }) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index a00650d02..5a337f23f 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -36,6 +36,7 @@ import { createVisibilityChangeListener, } from './handlers' import { getCookie, removeCookie, setCookie, type CookieAttributes } from './lib/cookies' +import { isBrowser } from './lib/isBrowser' import LocalStore from './storage/LocalStore' export type { CookieAttributes } from './lib/cookies' @@ -89,6 +90,17 @@ export interface OptimizationWebConfig extends CoreStatefulConfig { cookie?: CookieAttributes } +/** + * Mutable subset of {@link OptimizationWebConfig} that can be updated after construction + * via {@link ContentfulOptimization.setConfig}. + * + * @public + */ +export type SetOptimizationWebConfig = Pick< + OptimizationWebConfig, + 'locale' | 'autoTrackEntryInteraction' +> + /** * Public tracking API exposed by {@link ContentfulOptimization#tracking}. * @@ -293,7 +305,7 @@ class ContentfulOptimization extends CoreStateful { const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) - super(mergedConfig) + super(mergedConfig, { disableSingleton: !isBrowser() }) const canLoadPersistedContinuity = mergedConfig.defaults?.persistenceConsent === true const { cookieValue, legacyCookieValue } = readInitialCookieValues(canLoadPersistedContinuity) @@ -447,6 +459,49 @@ class ContentfulOptimization extends CoreStateful { super.reset() } + /** + * Return the existing browser singleton or construct a new one. + * + * @remarks + * When `window.contentfulOptimization` already exists, its mutable config is updated + * via {@link ContentfulOptimization.setConfig} and the instance is returned with + * `owned: false` — the caller must not call `destroy()` on it. + * + * When no singleton exists, a new instance is constructed from `config` and returned + * with `owned: true` — the caller is responsible for eventually calling `destroy()`. + * + * @public + */ + static getOrCreate(config: OptimizationWebConfig): ContentfulOptimization { + if (typeof window !== 'undefined' && window.contentfulOptimization) { + const { autoTrackEntryInteraction, locale } = config + window.contentfulOptimization.setConfig({ autoTrackEntryInteraction, locale }) + return window.contentfulOptimization + } + + return new ContentfulOptimization(config) + } + + /** + * Update mutable configuration options without recreating the SDK instance. + * + * @remarks + * Supports live updates to `locale` and `autoTrackEntryInteraction`. + * Structural options (`clientId`, `environment`, `accessToken`, `apiHost`) are + * constructor-only and are silently ignored here. + * + * @public + */ + setConfig(patch: SetOptimizationWebConfig): void { + if (patch.locale !== undefined) { + this.setLocale(patch.locale) + } + + if (patch.autoTrackEntryInteraction !== undefined) { + this.entryInteractionRuntime.setAutoTrackOptions(patch.autoTrackEntryInteraction) + } + } + /** * Track the current browser page with route-key deduplication. * diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index 4ecc4c344..f48630b8f 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts @@ -1,4 +1,9 @@ -import { ENTRY_ID_ATTRIBUTE, ENTRY_SELECTOR, HAS_MUTATION_OBSERVER } from '../constants' +import { + CAN_ADD_LISTENERS, + ENTRY_ID_ATTRIBUTE, + ENTRY_SELECTOR, + HAS_MUTATION_OBSERVER, +} from '../constants' import { safeCall } from '../lib/safeCall' import type { EntryInteractionDetector } from './EntryInteractionDetector' import { @@ -198,6 +203,14 @@ export class EntryInteractionRuntime { } } + public setAutoTrackOptions(options: AutoTrackEntryInteractionOptions): void { + const { clicks, views, hovers } = resolveAutoTrackEntryInteractionOptions(options) + this.autoTrack.clicks = clicks + this.autoTrack.views = views + this.autoTrack.hovers = hovers + this.reconcileAllInteractions() + } + public reset(): void { this.stopAllEntryInteractions() this.clearAllElementOverrides() @@ -264,6 +277,8 @@ export class EntryInteractionRuntime { } private startEntryInteraction(interaction: EntryInteraction, autoTrackingEnabled: boolean): void { + if (!CAN_ADD_LISTENERS) return + const detector = this.getDetector(interaction) detector.setAuto?.(autoTrackingEnabled) diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index c50541522..fea114e9a 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -43,6 +43,7 @@ export type { EntryViewInteractionStartOptions, } from './entry-tracking' export * from './handlers/beaconHandler' +export { isBrowser } from './lib/isBrowser' export * from './storage/LocalStore' export default ContentfulOptimization diff --git a/packages/web/web-sdk/src/lib/isBrowser.test.ts b/packages/web/web-sdk/src/lib/isBrowser.test.ts new file mode 100644 index 000000000..f2281a5c4 --- /dev/null +++ b/packages/web/web-sdk/src/lib/isBrowser.test.ts @@ -0,0 +1,28 @@ +import { isBrowser } from './isBrowser' + +describe('isBrowser', () => { + it('returns true when window is defined', () => { + expect(typeof window).toBe('object') + expect(isBrowser()).toBe(true) + }) + + it('returns false when window is undefined', () => { + const original = globalThis.window + + Object.defineProperty(globalThis, 'window', { + value: undefined, + configurable: true, + writable: true, + }) + + try { + expect(isBrowser()).toBe(false) + } finally { + Object.defineProperty(globalThis, 'window', { + value: original, + configurable: true, + writable: true, + }) + } + }) +}) diff --git a/packages/web/web-sdk/src/lib/isBrowser.ts b/packages/web/web-sdk/src/lib/isBrowser.ts new file mode 100644 index 000000000..d7c582ba1 --- /dev/null +++ b/packages/web/web-sdk/src/lib/isBrowser.ts @@ -0,0 +1,11 @@ +/** + * Returns `true` when the current environment has a browser `window` global. + * + * @remarks + * Used to decide whether browser-only features (singleton enforcement, + * DOM listeners, cookies) should be activated. Extracted as a named + * function so tests can mock the result without manipulating globals. + * + * @internal + */ +export const isBrowser = (): boolean => typeof window !== 'undefined' diff --git a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts index 6fd207c50..29b5f3a4b 100644 --- a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts +++ b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts @@ -1,6 +1,6 @@ import type { Observable } from '@contentful/optimization-core' import type ContentfulOptimization from '../ContentfulOptimization' -import type { OptimizationWebConfig } from '../ContentfulOptimization' +import type { OptimizationWebConfig, SetOptimizationWebConfig } from '../ContentfulOptimization' import { resolveAutoTrackEntryInteractionOptions, type AutoTrackEntryInteractionOptions, @@ -22,6 +22,7 @@ export interface OptimizationRootSdk } destroy: () => void setLocale: (locale: string) => string | undefined + setConfig: (patch: SetOptimizationWebConfig) => void } export type OnStatesReady = ( diff --git a/packages/web/web-sdk/src/storage/LocalStore.ts b/packages/web/web-sdk/src/storage/LocalStore.ts index af60d227f..5795d6e7d 100644 --- a/packages/web/web-sdk/src/storage/LocalStore.ts +++ b/packages/web/web-sdk/src/storage/LocalStore.ts @@ -23,6 +23,8 @@ import type { z } from 'zod/mini' const logger = createScopedLogger('Web:LocalStore') +const canUseStorage = typeof localStorage !== 'undefined' + /** * Local storage abstraction used by the Web SDK to persist optimization state. * @@ -30,7 +32,7 @@ const logger = createScopedLogger('Web:LocalStore') * @remarks * Wraps browser `localStorage` access and uses zod parsers to safely read and * write typed values. All getters return `undefined` when no valid data is - * present. + * present. All operations are no-ops on the server (SSR). */ const LocalStore = { /** @@ -67,6 +69,8 @@ const LocalStore = { * @returns The stored anonymous ID string, or `undefined` when absent. */ get anonymousId(): string | undefined { + if (!canUseStorage) return undefined + const legacyAnonymousIdValue = localStorage.getItem(ANONYMOUS_ID_KEY_LEGACY) if (legacyAnonymousIdValue) LocalStore.setCache(ANONYMOUS_ID_KEY_LEGACY, undefined) @@ -90,6 +94,8 @@ const LocalStore = { * `denied`, or `undefined` when no value is stored. */ get consent(): boolean | undefined { + if (!canUseStorage) return undefined + return decodeConsentStorageValue(localStorage.getItem(CONSENT_KEY)) }, @@ -104,6 +110,8 @@ const LocalStore = { }, get persistenceConsent(): boolean | undefined { + if (!canUseStorage) return undefined + return resolvePersistedPersistenceConsent( decodeConsentStorageValue(localStorage.getItem(PERSISTENCE_CONSENT_KEY)), LocalStore.consent, @@ -120,6 +128,8 @@ const LocalStore = { * @returns `true` or `false` when stored, or `undefined` otherwise. */ get debug(): boolean | undefined { + if (!canUseStorage) return undefined + const debug = localStorage.getItem(DEBUG_FLAG_KEY) return debug ? debug === 'true' : undefined @@ -197,6 +207,8 @@ const LocalStore = { * @returns Parsed data when present and valid, otherwise `undefined`. */ getCache(key: string, parser: T): z.output | undefined { + if (!canUseStorage) return undefined + const cacheString = localStorage.getItem(key) if (!cacheString) return @@ -221,6 +233,8 @@ const LocalStore = { * restricted storage environments (e.g. quota exhaustion, denied access). */ setCache(key: string, data: unknown): void { + if (!canUseStorage) return + try { if (data === undefined) { localStorage.removeItem(key) diff --git a/packages/web/web-sdk/src/web-components/index.test.ts b/packages/web/web-sdk/src/web-components/index.test.ts index 29a38075d..a63b51467 100644 --- a/packages/web/web-sdk/src/web-components/index.test.ts +++ b/packages/web/web-sdk/src/web-components/index.test.ts @@ -162,6 +162,7 @@ function createSdk( reset: () => undefined, resolveOptimizedEntry, screen: resolveAccepted, + setConfig: () => undefined, setLocale: () => undefined, states, track: resolveAccepted,