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..1e313dda9 100644
--- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx
@@ -141,29 +141,6 @@ describe('@contentful/optimization-react-web core providers', () => {
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', () => {
let capturedOptimization: OptimizationSdk | undefined = undefined
let capturedGlobalLiveUpdates: boolean | null = null
@@ -649,4 +626,29 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(destroySpy).not.toHaveBeenCalled()
container.remove()
})
+
+ it('destroys a stale window singleton before constructing a new SDK instance', () => {
+ // Simulate a concurrent-mode render that was interrupted before effects ran:
+ // the SDK constructor set window.contentfulOptimization but the component was
+ // discarded without cleanup.
+ const stale = new ContentfulOptimization(testConfig)
+ const destroySpy = rs.spyOn(stale, 'destroy')
+ window.contentfulOptimization = stale
+
+ const rendered = renderClient(
+
+
+ ,
+ )
+
+ expect(destroySpy).toHaveBeenCalledOnce()
+ expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization)
+ expect(window.contentfulOptimization).not.toBe(stale)
+
+ rendered.unmount()
+ })
})
diff --git a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx
new file mode 100644
index 000000000..1fb2f01de
--- /dev/null
+++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx
@@ -0,0 +1,273 @@
+import { describe, expect, it, rs } from '@rstest/core'
+import { act, StrictMode, useLayoutEffect, type ReactElement } from 'react'
+import { createRoot } from 'react-dom/client'
+import { useLifecycle, type InitResult } from './useLifecycle'
+
+function createContainer(): { root: ReturnType; unmount: () => void } {
+ const el = document.createElement('div')
+ document.body.appendChild(el)
+ const root = createRoot(el)
+ return {
+ root,
+ unmount() {
+ act(() => {
+ root.unmount()
+ })
+ el.remove()
+ },
+ }
+}
+
+function render(element: ReactElement): { unmount: () => void } {
+ const { root, unmount } = createContainer()
+ act(() => {
+ root.render(element)
+ })
+ return { unmount }
+}
+
+describe('useLifecycle', () => {
+ describe('init', () => {
+ it('returns { value } when init resolves synchronously', () => {
+ const value = { id: 1 }
+ let result: InitResult = undefined
+
+ function Component(): null {
+ result = useLifecycle(() => value, rs.fn()).init()
+ return null
+ }
+
+ const { unmount } = render()
+ expect(result).toEqual({ value })
+ unmount()
+ })
+
+ it('returns { error } when init throws', () => {
+ const error = new Error('init failed')
+ let result: InitResult = undefined
+
+ function Component(): null {
+ result = useLifecycle((): unknown => {
+ throw error
+ }, rs.fn<(v: unknown) => void>()).init()
+ return null
+ }
+
+ const { unmount } = render()
+ expect(result).toEqual({ error })
+ unmount()
+ })
+
+ it('returns undefined when init returns a Promise', () => {
+ let result: InitResult = { value: 0 }
+
+ function Component(): null {
+ async function asyncInit(): Promise {
+ return await Promise.resolve(42)
+ }
+ result = useLifecycle(asyncInit, rs.fn()).init()
+ return null
+ }
+
+ const { unmount } = render()
+ expect(result).toBeUndefined()
+ unmount()
+ })
+ })
+
+ describe('mount — synchronous init', () => {
+ it('calls onMount with the resolved value', () => {
+ const onMount = rs.fn()
+ const onFail = rs.fn()
+ const value = { id: 1 }
+
+ function Component(): null {
+ const lifecycle = useLifecycle(() => value, rs.fn())
+ useLayoutEffect(() => lifecycle.mount(onMount, onFail), [])
+ return null
+ }
+
+ const { unmount } = render()
+ expect(onMount).toHaveBeenCalledWith(value)
+ expect(onFail).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it('calls dispose on unmount', () => {
+ const dispose = rs.fn()
+ const value = { id: 1 }
+
+ function Component(): null {
+ const lifecycle = useLifecycle(() => value, dispose)
+ useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), [])
+ return null
+ }
+
+ const { unmount } = render()
+ expect(dispose).not.toHaveBeenCalled()
+ unmount()
+ expect(dispose).toHaveBeenCalledWith(value)
+ expect(dispose).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls onFail when init throws inside mount', () => {
+ const error = new Error('sync throw')
+ const onMount = rs.fn()
+ const onFail = rs.fn()
+
+ function Component(): null {
+ const lifecycle = useLifecycle((): never => {
+ throw error
+ }, rs.fn())
+ useLayoutEffect(() => lifecycle.mount(onMount, onFail), [])
+ return null
+ }
+
+ const { unmount } = render()
+ expect(onFail).toHaveBeenCalledWith(error)
+ expect(onMount).not.toHaveBeenCalled()
+ unmount()
+ })
+ })
+
+ describe('mount — async init', () => {
+ it('calls onMount after Promise resolves', async () => {
+ const onMount = rs.fn()
+ const onFail = rs.fn()
+ let resolve!: (v: number) => void
+ const promise = new Promise((_resolve) => {
+ resolve = _resolve
+ })
+
+ function Component(): null {
+ const lifecycle = useLifecycle(async () => await promise, rs.fn())
+ useLayoutEffect(() => lifecycle.mount(onMount, onFail), [])
+ return null
+ }
+
+ const { unmount } = render()
+ expect(onMount).not.toHaveBeenCalled()
+ await act(async () => {
+ await Promise.resolve()
+ resolve(42)
+ })
+ expect(onMount).toHaveBeenCalledWith(42)
+ expect(onFail).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it('calls onFail when Promise rejects', async () => {
+ const error = new Error('async fail')
+ const onMount = rs.fn()
+ const onFail = rs.fn()
+ let reject!: (e: unknown) => void
+ const promise = new Promise((_resolve, _reject) => {
+ reject = _reject
+ })
+
+ function Component(): null {
+ const lifecycle = useLifecycle(async () => await promise, rs.fn())
+ useLayoutEffect(() => lifecycle.mount(onMount, onFail), [])
+ return null
+ }
+
+ const { unmount } = render()
+ await act(async () => {
+ await Promise.resolve()
+ reject(error)
+ })
+ expect(onFail).toHaveBeenCalledWith(error)
+ expect(onMount).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it('disposes value that resolves after unmount', async () => {
+ const dispose = rs.fn()
+ let resolve!: (v: number) => void
+ const promise = new Promise((_resolve) => {
+ resolve = _resolve
+ })
+
+ function Component(): null {
+ const lifecycle = useLifecycle(async () => await promise, dispose)
+ useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), [])
+ return null
+ }
+
+ const { unmount } = render()
+ unmount()
+ await act(async () => {
+ await Promise.resolve()
+ resolve(99)
+ })
+ expect(dispose).toHaveBeenCalledWith(99)
+ })
+
+ it('does not call onMount after unmount', async () => {
+ const onMount = rs.fn()
+ let resolve!: (v: number) => void
+ const promise = new Promise((_resolve) => {
+ resolve = _resolve
+ })
+
+ function Component(): null {
+ const lifecycle = useLifecycle(async () => await promise, rs.fn())
+ useLayoutEffect(() => lifecycle.mount(onMount, rs.fn()), [])
+ return null
+ }
+
+ const { unmount } = render()
+ unmount()
+ await act(async () => {
+ await Promise.resolve()
+ resolve(99)
+ })
+ expect(onMount).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('StrictMode', () => {
+ it('disposes after StrictMode teardown and re-inits on remount', () => {
+ const dispose = rs.fn()
+ const onMount = rs.fn()
+ const value = { id: 1 }
+
+ function Component(): null {
+ const lifecycle = useLifecycle(() => value, dispose)
+ useLayoutEffect(() => lifecycle.mount(onMount, rs.fn()), [])
+ return null
+ }
+
+ const { unmount } = render(
+
+
+ ,
+ )
+ // StrictMode: mount → unmount → remount
+ expect(dispose).toHaveBeenCalledTimes(1)
+ expect(onMount).toHaveBeenCalledTimes(2)
+ unmount()
+ expect(dispose).toHaveBeenCalledTimes(2)
+ })
+
+ it('disposes exactly once on final unmount after StrictMode', () => {
+ const dispose = rs.fn()
+ const value = { id: 1 }
+
+ function Component(): null {
+ const lifecycle = useLifecycle(() => value, dispose)
+ useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), [])
+ return null
+ }
+
+ const { unmount } = render(
+
+
+ ,
+ )
+ dispose.mockClear()
+ unmount()
+ expect(dispose).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts
new file mode 100644
index 000000000..7582f6842
--- /dev/null
+++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts
@@ -0,0 +1,70 @@
+import { useRef } from 'react'
+
+export type InitResult = { readonly value: T } | { readonly error: unknown } | undefined
+
+export interface Lifecycle {
+ readonly init: () => InitResult
+ readonly mount: (onMount: (value: T) => void, onFail: (error: unknown) => void) => () => void
+}
+
+export function useLifecycle(
+ initialize: () => T | Promise,
+ dispose: (value: T) => void,
+): Lifecycle {
+ const ref = useRef(undefined)
+
+ function unmount(): void {
+ if (ref.current !== undefined) dispose(ref.current)
+ ref.current = undefined
+ }
+
+ return {
+ init() {
+ if (ref.current !== undefined) return { value: ref.current }
+ try {
+ const value = initialize()
+ if (!(value instanceof Promise)) {
+ ref.current = value
+ return { value }
+ }
+ } catch (error: unknown) {
+ return { error }
+ }
+ return undefined
+ },
+ mount(onMount, onFail) {
+ if (ref.current !== undefined) return unmount
+
+ let disposed = false
+
+ function commit(value: T): void {
+ if (disposed) {
+ dispose(value)
+ return
+ }
+ ref.current = value
+ onMount(value)
+ }
+
+ function fail(error: unknown): void {
+ if (!disposed) onFail(error)
+ }
+
+ try {
+ const result = initialize()
+ if (result instanceof Promise) {
+ void result.then(commit, fail)
+ } else {
+ commit(result)
+ }
+ } catch (error: unknown) {
+ fail(error)
+ }
+
+ return () => {
+ disposed = true
+ 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..a48815ce2 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', () => {
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..89478654c 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
@@ -7,6 +7,7 @@ const constructedConfigs: Array> = []
const setLocaleCalls: string[] = []
rs.mock('@contentful/optimization-web', () => ({
+ isBrowser: () => true,
default: class MockContentfulOptimization {
constructor(config: Record) {
constructedConfigs.push(config)
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..3fbde1acd 100644
--- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx
@@ -1,4 +1,4 @@
-import ContentfulOptimization from '@contentful/optimization-web'
+import ContentfulOptimization, { isBrowser } from '@contentful/optimization-web'
import type { OptimizationData } from '@contentful/optimization-web/api-schemas'
import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support'
import {
@@ -9,9 +9,10 @@ import {
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, useState, type PropsWithChildren, type ReactElement } from 'react'
import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext'
+import { useLifecycle } from '../lib/useLifecycle'
/**
* Provider-owned callback for app-level subscriptions once SDK state is ready.
@@ -78,10 +79,14 @@ function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
-function createInjectedSdkBinding(props: OptimizationProviderSdkProps): ProviderSdkBinding {
- const { sdk } = props
+const PENDING_STATE: ProviderState = { error: undefined, isReady: false, sdk: undefined }
- return createOptimizationRootSdkBinding({ sdk })
+function readyState(sdk: OptimizationSdk): ProviderState {
+ return { error: undefined, isReady: true, sdk }
+}
+
+function failedState(error: unknown): ProviderState {
+ return { error: toError(error), isReady: false, sdk: undefined }
}
function createOwnedSdkBinding(props: OptimizationProviderConfigProps): ProviderSdkBinding {
@@ -96,7 +101,10 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider
return createOptimizationRootSdkBinding({
config,
- createSdk: (sdkConfig) => new ContentfulOptimization(sdkConfig),
+ createSdk: (sdkConfig) => {
+ if (isBrowser()) window.contentfulOptimization?.destroy()
+ return new ContentfulOptimization(sdkConfig)
+ },
trackEntryInteraction,
})
}
@@ -118,26 +126,13 @@ function bindOnStatesReady(
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)
+ props.sdk === undefined
+ ? createOwnedSdkBinding(props)
+ : createOptimizationRootSdkBinding({ sdk: props.sdk })
if (props.serverOptimizationState === undefined) {
try {
@@ -148,18 +143,20 @@ function initializeProviderSdk(
}
}
- return initializeServerOptimizationState(
- sdkBinding,
- props.serverOptimizationState,
- props.onStatesReady,
- )
-}
-
-function isPromiseLike(value: T | Promise): value is Promise {
- return value instanceof Promise
+ return hydrateOptimizationData(sdkBinding.sdk, props.serverOptimizationState)
+ .then(() => bindOnStatesReady(sdkBinding, props.onStatesReady))
+ .catch((error: unknown) => {
+ disposeSdkBinding(sdkBinding)
+ throw error
+ })
}
-function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean {
+function canUseInjectedSdkDuringInitialRender(
+ props: OptimizationProviderProps,
+): props is OptimizationProviderSdkProps & {
+ readonly onStatesReady: undefined
+ readonly serverOptimizationState: undefined
+} {
return (
props.sdk !== undefined &&
props.onStatesReady === undefined &&
@@ -167,92 +164,60 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps):
)
}
-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
- }
-
- const setupState = { disposed: false }
- let sdkBinding: ProviderSdkBinding | undefined = undefined
- let disposedBinding: ProviderSdkBinding | undefined = undefined
-
- function disposeOnce(binding: ProviderSdkBinding | undefined): void {
- if (binding === undefined || binding === disposedBinding) return
-
- disposeSdkBinding(binding)
- disposedBinding = binding
- }
+function requiresAsyncSetup(props: OptimizationProviderProps): boolean {
+ return props.serverOptimizationState !== undefined || props.onStatesReady !== undefined
+}
- function setInitializedState(initializedBinding: ProviderSdkBinding): void {
- if (setupState.disposed) {
- disposeOnce(initializedBinding)
- return
- }
+export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null {
+ const lifecycle = useLifecycle(
+ (): ProviderSdkBinding | Promise => initializeProviderSdk(props),
+ disposeSdkBinding,
+ )
- sdkBinding = initializedBinding
- setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk })
+ const [state, setState] = useState(() => {
+ if (canUseInjectedSdkDuringInitialRender(props)) {
+ return readyState(props.sdk)
}
-
- function setInitializationError(error: unknown): void {
- if (!setupState.disposed) {
- setState({ error: toError(error), isReady: false, sdk: undefined })
- }
+ if (requiresAsyncSetup(props)) {
+ return PENDING_STATE
}
+ const result = lifecycle.init()
+ if (result === undefined) return PENDING_STATE
+ if ('error' in result) return failedState(result.error)
+ return readyState(result.value.sdk)
+ })
- try {
- const initializedBinding = initializeProviderSdk(initialProps)
-
- if (!isPromiseLike(initializedBinding)) {
- setInitializedState(initializedBinding)
-
- return () => {
- setupState.disposed = true
- disposeOnce(sdkBinding)
- }
- }
-
- void initializedBinding.then(setInitializedState, setInitializationError)
- } catch (error: unknown) {
- setInitializationError(error)
- return
- }
+ const liveLocale = props.sdk === undefined ? props.locale : undefined
- return () => {
- setupState.disposed = true
- disposeOnce(sdkBinding)
- }
+ useLayoutEffect(() => {
+ if (canUseInjectedSdkDuringInitialRender(props)) return
+ return lifecycle.mount(
+ (binding) => {
+ setState(readyState(binding.sdk))
+ },
+ (error) => {
+ setState(failedState(error))
+ },
+ )
}, [])
useLayoutEffect(() => {
- if (state.sdk === undefined || props.sdk !== undefined || liveLocale === undefined) {
- return
- }
+ if (state.sdk === undefined || liveLocale === undefined) return
try {
state.sdk.setLocale(liveLocale)
} catch (error: unknown) {
- setState({ error: toError(error), isReady: true, sdk: state.sdk })
+ setState({ ...readyState(state.sdk), error: toError(error) })
}
- }, [liveLocale, props.sdk, state.sdk])
-
- const shouldRenderChildren = state.isReady || state.error !== undefined
+ }, [liveLocale, state.sdk])
- if (!shouldRenderChildren) {
+ // 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 (requiresAsyncSetup(props) && !state.isReady && state.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.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts
index a00650d02..5f266f417 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'
@@ -293,7 +294,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)
diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts
index 4ecc4c344..7e42c5d5b 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 {
@@ -264,6 +269,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/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)