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,