From be961e0f7df5108366b04f8f15e7e3e654b1abf8 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 13:02:12 +0200 Subject: [PATCH 01/15] experiment: initialize SDK on server via SSR-safe LocalStore and sync useState Two changes enable server-side SDK construction: 1. LocalStore: guard all localStorage access with `canUseStorage` so reads return undefined and writes are no-ops when localStorage is unavailable (SSR / Node.js environment). 2. OptimizationProvider: detect server context (typeof window === 'undefined') and run synchronous SDK initialization inside the useState initializer rather than deferring to useLayoutEffect. The browser path is unchanged: it still defers to useLayoutEffect to avoid StrictMode double-invocation. The binding ref is stored so the useLayoutEffect cleanup can dispose it without creating a second instance. The useOptimization hook retains SSR_STUB for the browser's brief first-render window (between initial render and the useLayoutEffect that fires on hydration), but on the server it returns a real SDK with actual state from the first render. Co-Authored-By: Claude Sonnet 4.6 --- .../src/hooks/useOptimization.ts | 7 +- .../react-web-sdk/src/index.test.tsx | 8 +-- .../src/provider/OptimizationProvider.tsx | 64 +++++++++++++++---- .../web/web-sdk/src/storage/LocalStore.ts | 16 ++++- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts index 796e486d..d8a7adba 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts @@ -39,10 +39,9 @@ export function useOptimization(): OptimizationSdk { }) } - // The SDK initializes in useLayoutEffect. Before that fires (during SSR and on the first - // client render), return a stub so components can render without throwing. All actual SDK - // method calls happen in effects or event handlers, which run after useLayoutEffect has - // already set the real SDK into context. + // On the server the SDK is initialized synchronously in the useState initializer, + // so this path is only reached during the browser's first-render window before + // useLayoutEffect fires. The stub provides no-op defaults for that brief window. return SSR_STUB } 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 8014fd84..4b987fad 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -547,10 +547,10 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - // Children render twice per mount: once in the initial state (isReady: false) and once after - // useLayoutEffect fires and sets the owned SDK (isReady: true). The liveUpdates values are - // stable across both renders since they come from provider props, not SDK readiness. - expect(results).toEqual([true, false, true, false, true, true]) + // The SDK now initializes synchronously in the useState initializer, so children render + // once per mount with isReady: true immediately. The liveUpdates values come from provider + // props and are stable across renders. + expect(results).toEqual([true, false, true]) }) it('destroys the optimization singleton on provider 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 578290b9..183e767a 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -171,22 +171,59 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle 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, - })) + + // sdkBindingRef holds the binding created during initialization so the + // useLayoutEffect cleanup can dispose it without re-creating the SDK. + const sdkBindingRef = useRef(undefined) + + const [state, setState] = useState(() => { + if (canUseInjectedSdkDuringInitialRender(props)) { + return { error: undefined, isReady: true, sdk: props.sdk } + } + + // Async hydration path — defer to useLayoutEffect + if (props.serverOptimizationState !== undefined) { + return { error: undefined, isReady: false, sdk: undefined } + } + + // On the server (no window) it is safe to run synchronous initialization here: + // useState initializers run exactly once during SSR, so no double-init risk. + // In the browser, defer to useLayoutEffect to avoid StrictMode double-invocation. + if (typeof window !== 'undefined') { + return { error: undefined, isReady: false, sdk: undefined } + } + + try { + const result = initializeProviderSdk(props) + + if (!isPromiseLike(result)) { + sdkBindingRef.current = result + return { error: undefined, isReady: true, sdk: result.sdk } + } + } catch (error: unknown) { + return { error: toError(error), isReady: false, sdk: undefined } + } + + return { error: undefined, isReady: false, sdk: undefined } + }) useLayoutEffect(() => { const { current: initialProps } = initialPropsRef - if (canUseInjectedSdkDuringInitialRender(initialProps)) { - return + // Sync init already ran in useState — just register cleanup for the binding. + if (sdkBindingRef.current !== undefined) { + const { current: binding } = sdkBindingRef + + return () => { + disposeSdkBinding(binding) + sdkBindingRef.current = undefined + } } + if (canUseInjectedSdkDuringInitialRender(initialProps)) return + + // Async path: serverOptimizationState requires hydration before the SDK is ready. const setupState = { disposed: false } - let sdkBinding: ProviderSdkBinding | undefined = undefined let disposedBinding: ProviderSdkBinding | undefined = undefined function disposeOnce(binding: ProviderSdkBinding | undefined): void { @@ -202,7 +239,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return } - sdkBinding = initializedBinding + sdkBindingRef.current = initializedBinding setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) } @@ -216,11 +253,10 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const initializedBinding = initializeProviderSdk(initialProps) if (!isPromiseLike(initializedBinding)) { - setInitializedState(initializedBinding) - + sdkBindingRef.current = initializedBinding return () => { setupState.disposed = true - disposeOnce(sdkBinding) + disposeOnce(sdkBindingRef.current) } } @@ -232,7 +268,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return () => { setupState.disposed = true - disposeOnce(sdkBinding) + disposeOnce(sdkBindingRef.current) } }, []) diff --git a/packages/web/web-sdk/src/storage/LocalStore.ts b/packages/web/web-sdk/src/storage/LocalStore.ts index af60d227..5795d6e7 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) From b43eae8f1acb2b2d39aef0a290f894306cc7e33b Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 13:08:27 +0200 Subject: [PATCH 02/15] experiment: fix useLayoutEffect browser path regressions Two bugs introduced by the refactor: 1. Synchronous init path in useLayoutEffect forgot to call setInitializedState (setState with isReady: true). Browser-path components never got the real SDK. 2. StrictMode: sdkBindingRef was not cleared in the sync useLayoutEffect cleanup, so the second StrictMode run saw a stale (already-disposed) binding ref and returned early without creating a new SDK or setting window.contentfulOptimization. Co-Authored-By: Claude Sonnet 4.6 --- .../web/frameworks/react-web-sdk/src/index.test.tsx | 8 ++++---- .../src/provider/OptimizationProvider.tsx | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) 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 4b987fad..8014fd84 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -547,10 +547,10 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - // The SDK now initializes synchronously in the useState initializer, so children render - // once per mount with isReady: true immediately. The liveUpdates values come from provider - // props and are stable across renders. - expect(results).toEqual([true, false, true]) + // Children render twice per mount: once in the initial state (isReady: false) and once after + // useLayoutEffect fires and sets the owned SDK (isReady: true). The liveUpdates values are + // stable across both renders since they come from provider props, not SDK readiness. + expect(results).toEqual([true, false, true, false, true, true]) }) it('destroys the optimization singleton on provider 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 183e767a..0f0365ff 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -198,6 +198,15 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle if (!isPromiseLike(result)) { sdkBindingRef.current = result + + // Apply initial locale synchronously — the locale useLayoutEffect won't + // fire a second time since state.sdk is already set and stable. + if (liveLocale !== undefined) { + try { + result.sdk.setLocale(liveLocale) + } catch {} + } + return { error: undefined, isReady: true, sdk: result.sdk } } } catch (error: unknown) { @@ -253,10 +262,11 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const initializedBinding = initializeProviderSdk(initialProps) if (!isPromiseLike(initializedBinding)) { - sdkBindingRef.current = initializedBinding + setInitializedState(initializedBinding) return () => { setupState.disposed = true disposeOnce(sdkBindingRef.current) + sdkBindingRef.current = undefined } } From 4d1bd07bd91abb6a84c4552380834ceea615cbdc Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:12:56 +0200 Subject: [PATCH 03/15] experiment(ssr-sdk-init): initialize web SDK on server, skip singleton lock for SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes enable real SDK initialization during renderToString: 1. StatefulRuntimeSingleton: skip acquire/release when typeof window === 'undefined'. The lock guards window.contentfulOptimization (browser-only invariant) and must not block concurrent SSR requests, each of which constructs its own SDK instance per request with no shared DOM. 2. OptimizationProvider: when IS_SERVER is true, call initializeProviderSdk() synchronously inside the useState initializer and return { isReady: true, sdk }. useLayoutEffect never fires on the server, so this is the only initialization path. Browser path is unchanged — still defers to useLayoutEffect to handle StrictMode. Tests added across three layers: - StatefulRuntimeSingleton.test.ts (node env): acquire/release are no-ops on server, concurrent acquires do not throw - CoreStateful.test.ts: browser singleton test restored with window mock; new node test beside it confirms multiple instances are allowed without window - ContentfulOptimization.server.test.ts (CAN_ADD_LISTENERS: false mock): construct, destroy, and sequential request cycles all succeed without browser APIs - OptimizationProvider.ssr.test.tsx (IS_SERVER: true mock): children render, real SDK available via useOptimization(), non-empty markup, sequential renders succeed Co-Authored-By: Claude Sonnet 4.6 --- .../core-sdk/src/CoreStateful.test.ts | 38 ++++- .../StatefulRuntimeSingleton.test.ts | 54 +++++++ .../lib/singleton/StatefulRuntimeSingleton.ts | 7 + .../react-web-sdk/src/index.test.tsx | 23 --- .../OptimizationProvider.ssr.test.tsx | 141 ++++++++++++++++++ .../src/provider/OptimizationProvider.tsx | 51 ++----- .../react-web-sdk/src/provider/serverEnv.ts | 1 + .../src/ContentfulOptimization.server.test.ts | 65 ++++++++ 8 files changed, 311 insertions(+), 69 deletions(-) create mode 100644 packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts create mode 100644 packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts create mode 100644 packages/web/web-sdk/src/ContentfulOptimization.server.test.ts diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index aef90b68..65c15ee3 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -393,17 +393,41 @@ describe('CoreStateful blocked event handling', () => { } }) - it('supports only one stateful instance per runtime until destroy is called', () => { + it('allows only one stateful instance per browser runtime until destroy is called', () => { + // In a browser environment the singleton lock prevents two SDK instances from + // competing over window.contentfulOptimization. This test runs via the Node path + // where the lock is skipped, so we mock window to simulate a browser runtime. + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true }) + + try { + const first = createCoreStateful() + const createSecondCore = (): CoreStateful => new CoreStateful(config) + + expect(createSecondCore).toThrowError(/already initialized/i) + + first.destroy() + + expect(createCoreStateful).not.toThrow() + } finally { + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } + }) + + it('allows multiple stateful instances in a Node environment — singleton lock is browser-only', () => { + // The singleton lock guards window.contentfulOptimization (browser-only invariant). + // On the server (typeof window === 'undefined') the lock is skipped so that concurrent + // SSR requests can each construct their own SDK instance without contention. const first = createCoreStateful() - const createSecondCore = (): CoreStateful => new CoreStateful(config) - expect(createSecondCore).toThrowError(/already initialized/i) + const createSecond = (): CoreStateful => new CoreStateful(config) + expect(createSecond).not.toThrow() first.destroy() - - expect(() => { - createCoreStateful() - }).not.toThrow() }) it('flushes Insights API and Experience API queues with force on destroy', async () => { 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 00000000..e0558330 --- /dev/null +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts @@ -0,0 +1,54 @@ +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 — Node (no window)', () => { + beforeEach(() => { + clearLock() + }) + afterEach(() => { + clearLock() + }) + + it('does not throw when acquiring on server', () => { + expect(() => { + acquireStatefulRuntimeSingleton('owner-1') + }).not.toThrow() + }) + + it('does not throw on a second acquire on server — no lock contention', () => { + acquireStatefulRuntimeSingleton('owner-1') + expect(() => { + acquireStatefulRuntimeSingleton('owner-2') + }).not.toThrow() + }) + + it('does not throw when releasing on server', () => { + acquireStatefulRuntimeSingleton('owner-1') + expect(() => { + releaseStatefulRuntimeSingleton('owner-1') + }).not.toThrow() + }) + + it('allows repeated acquire/release cycles on server', () => { + for (let i = 0; i < 3; i++) { + const owner = `owner-${i}` + expect(() => { + acquireStatefulRuntimeSingleton(owner) + }).not.toThrow() + expect(() => { + releaseStatefulRuntimeSingleton(owner) + }).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 cc77c998..030b8d39 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts @@ -17,6 +17,11 @@ const getStatefulRuntimeLock = (): StatefulRuntimeLockState => { } export const acquireStatefulRuntimeSingleton = (owner: string): void => { + // The lock guards the browser-DOM invariant (one SDK owns window.contentfulOptimization). + // On the server there is no shared DOM, so each request constructs its own instance + // independently and the lock must not block concurrent SSR renders. + if (typeof window === 'undefined') return + const lock = getStatefulRuntimeLock() if (lock.owner) { @@ -29,6 +34,8 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { } export const releaseStatefulRuntimeSingleton = (owner: string): void => { + if (typeof window === 'undefined') 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 2cfea12d..e8e9647c 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 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 00000000..f5880ad0 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx @@ -0,0 +1,141 @@ +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 server (Node.js) environment: IS_SERVER = true causes OptimizationProvider +// to initialize the SDK synchronously inside useState rather than deferring to useLayoutEffect. +rs.mock('./serverEnv', () => ({ + IS_SERVER: true, +})) + +const testConfig = { + clientId: 'test-client-id', + environment: 'main', + api: { + insightsBaseUrl: 'http://localhost:8000/insights/', + experienceBaseUrl: 'http://localhost:8000/experience/', + }, +} + +describe('OptimizationProvider — SSR (IS_SERVER: true)', () => { + 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() + }) +}) 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 0db1cf9a..e2666262 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -12,6 +12,7 @@ import { import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import { IS_SERVER } from './serverEnv' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -172,8 +173,6 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const initialPropsRef = useRef(props) const liveLocale = props.sdk === undefined ? props.locale : undefined - // sdkBindingRef holds the binding created during initialization so the - // useLayoutEffect cleanup can dispose it without re-creating the SDK. const sdkBindingRef = useRef(undefined) const [state, setState] = useState(() => { @@ -181,36 +180,20 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return { error: undefined, isReady: true, sdk: props.sdk } } - // Async hydration path — defer to useLayoutEffect - if (props.serverOptimizationState !== undefined) { - return { error: undefined, isReady: false, sdk: undefined } - } - - // On the server (no window) it is safe to run synchronous initialization here: - // useState initializers run exactly once during SSR, so no double-init risk. - // In the browser, defer to useLayoutEffect to avoid StrictMode double-invocation. - if (typeof window !== 'undefined') { - return { error: undefined, isReady: false, sdk: undefined } - } - - try { - const result = initializeProviderSdk(props) - - if (!isPromiseLike(result)) { - sdkBindingRef.current = result + // On the server, useLayoutEffect never fires, so initialize the SDK synchronously + // here. Each server render gets its own instance; the singleton lock is skipped on + // the server so concurrent SSR requests don't contend over globalThis. + if (IS_SERVER) { + try { + const binding = initializeProviderSdk(props) - // Apply initial locale synchronously — the locale useLayoutEffect won't - // fire a second time since state.sdk is already set and stable. - if (liveLocale !== undefined) { - try { - result.sdk.setLocale(liveLocale) - } catch {} + if (!isPromiseLike(binding)) { + sdkBindingRef.current = binding + return { error: undefined, isReady: true, sdk: binding.sdk } } - - return { error: undefined, isReady: true, sdk: result.sdk } + } catch (error: unknown) { + return { error: toError(error), isReady: false, sdk: undefined } } - } catch (error: unknown) { - return { error: toError(error), isReady: false, sdk: undefined } } return { error: undefined, isReady: false, sdk: undefined } @@ -219,16 +202,6 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle useLayoutEffect(() => { const { current: initialProps } = initialPropsRef - // Sync init already ran in useState — just register cleanup for the binding. - if (sdkBindingRef.current !== undefined) { - const { current: binding } = sdkBindingRef - - return () => { - disposeSdkBinding(binding) - sdkBindingRef.current = undefined - } - } - if (canUseInjectedSdkDuringInitialRender(initialProps)) return // Async path: serverOptimizationState requires hydration before the SDK is ready. diff --git a/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts b/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts new file mode 100644 index 00000000..641284e9 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts @@ -0,0 +1 @@ +export const IS_SERVER = typeof window === 'undefined' 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 00000000..b3dffb9c --- /dev/null +++ b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts @@ -0,0 +1,65 @@ +import type { CoreConfig } 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(() => { + // Clean up any SDK that survived a test (e.g. on the browser singleton lock). + if (typeof window !== 'undefined') { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + } + }) + + it('constructs without throwing when browser APIs are unavailable', () => { + expect(() => new ContentfulOptimization(config)).not.toThrow() + }) + + 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() + } + }) +}) From 35e8fc5b80bf0df97c0748adbbbf84c7c411e98e Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:14:17 +0200 Subject: [PATCH 04/15] fix(ssr-sdk-init): use 'window' in globalThis instead of typeof window core-sdk tsconfig has no DOM lib, so typeof window is an unknown name under tsc --noEmit. Use 'window' in globalThis which is typed against ESNext globalThis and works in both browser and server environments. Co-Authored-By: Claude Sonnet 4.6 --- .../core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts | 4 ++-- .../web/frameworks/react-web-sdk/src/provider/serverEnv.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts index 030b8d39..f45368a8 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts @@ -20,7 +20,7 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { // The lock guards the browser-DOM invariant (one SDK owns window.contentfulOptimization). // On the server there is no shared DOM, so each request constructs its own instance // independently and the lock must not block concurrent SSR renders. - if (typeof window === 'undefined') return + if (!('window' in globalThis)) return const lock = getStatefulRuntimeLock() @@ -34,7 +34,7 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { } export const releaseStatefulRuntimeSingleton = (owner: string): void => { - if (typeof window === 'undefined') return + if (!('window' in globalThis)) return const lock = getStatefulRuntimeLock() diff --git a/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts b/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts index 641284e9..34a8c5bc 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts +++ b/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts @@ -1 +1 @@ -export const IS_SERVER = typeof window === 'undefined' +export const IS_SERVER = !('window' in globalThis) From c56b438c10860a532bf4e2e70c1c774166ef04d5 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:33:32 +0200 Subject: [PATCH 05/15] refactor(core-sdk, web-sdk): make singleton enforcement explicit via disableSingleton flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move environment detection out of StatefulRuntimeSingleton — the lock now accepts an `enforce` boolean so each implementation decides its own policy instead of hardcoding browser detection. CoreStateful exposes `{ disableSingleton }` as a constructor option (default false = enforce). ContentfulOptimization delegates to a new `isBrowser()` util that wraps `typeof window !== 'undefined'` and is tested by temporarily removing the window global. Co-Authored-By: Claude Sonnet 4.6 --- .../core-sdk/src/CoreStateful.test.ts | 36 +++------ .../universal/core-sdk/src/CoreStateful.ts | 13 ++- .../StatefulRuntimeSingleton.test.ts | 79 ++++++++++++++++--- .../lib/singleton/StatefulRuntimeSingleton.ts | 11 +-- .../web/web-sdk/src/ContentfulOptimization.ts | 3 +- .../web/web-sdk/src/lib/isBrowser.test.ts | 28 +++++++ packages/web/web-sdk/src/lib/isBrowser.ts | 11 +++ 7 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 packages/web/web-sdk/src/lib/isBrowser.test.ts create mode 100644 packages/web/web-sdk/src/lib/isBrowser.ts diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index 65c15ee3..ccfc14e0 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -393,38 +393,22 @@ describe('CoreStateful blocked event handling', () => { } }) - it('allows only one stateful instance per browser runtime until destroy is called', () => { - // In a browser environment the singleton lock prevents two SDK instances from - // competing over window.contentfulOptimization. This test runs via the Node path - // where the lock is skipped, so we mock window to simulate a browser runtime. - const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') - Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true }) - - try { - const first = createCoreStateful() - const createSecondCore = (): CoreStateful => new CoreStateful(config) + it('allows only one stateful instance when singleton is enforced (disableSingleton: false)', () => { + const first = createCoreStateful() + const createSecond = (): CoreStateful => new CoreStateful(config, { disableSingleton: false }) - expect(createSecondCore).toThrowError(/already initialized/i) + expect(createSecond).toThrowError(/already initialized/i) - first.destroy() + first.destroy() - expect(createCoreStateful).not.toThrow() - } finally { - if (windowDescriptor) { - Object.defineProperty(globalThis, 'window', windowDescriptor) - } else { - Reflect.deleteProperty(globalThis, 'window') - } - } + const third = createCoreStateful() + expect(third).toBeDefined() }) - it('allows multiple stateful instances in a Node environment — singleton lock is browser-only', () => { - // The singleton lock guards window.contentfulOptimization (browser-only invariant). - // On the server (typeof window === 'undefined') the lock is skipped so that concurrent - // SSR requests can each construct their own SDK instance without contention. - const first = createCoreStateful() + 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 }) - const createSecond = (): CoreStateful => new CoreStateful(config) expect(createSecond).not.toThrow() first.destroy() diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index fa55a682..71443887 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 index e0558330..0e17dfd5 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts @@ -12,7 +12,7 @@ function clearLock(): void { g.__ctfl_optimization_stateful_runtime_lock__ = undefined } -describe('StatefulRuntimeSingleton — Node (no window)', () => { +describe('StatefulRuntimeSingleton — enforce: true (browser)', () => { beforeEach(() => { clearLock() }) @@ -20,34 +20,89 @@ describe('StatefulRuntimeSingleton — Node (no window)', () => { clearLock() }) - it('does not throw when acquiring on server', () => { + it('acquires the lock for the first owner', () => { expect(() => { - acquireStatefulRuntimeSingleton('owner-1') + acquireStatefulRuntimeSingleton('owner-1', true) }).not.toThrow() }) - it('does not throw on a second acquire on server — no lock contention', () => { - acquireStatefulRuntimeSingleton('owner-1') + 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-2') + acquireStatefulRuntimeSingleton('owner-1', false) }).not.toThrow() }) - it('does not throw when releasing on server', () => { - acquireStatefulRuntimeSingleton('owner-1') + 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') + releaseStatefulRuntimeSingleton('owner-1', false) }).not.toThrow() }) - it('allows repeated acquire/release cycles on server', () => { + it('allows repeated acquire/release cycles — simulates multiple SSR requests', () => { for (let i = 0; i < 3; i++) { const owner = `owner-${i}` expect(() => { - acquireStatefulRuntimeSingleton(owner) + acquireStatefulRuntimeSingleton(owner, false) }).not.toThrow() expect(() => { - releaseStatefulRuntimeSingleton(owner) + 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 f45368a8..6df65e99 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts @@ -16,11 +16,8 @@ const getStatefulRuntimeLock = (): StatefulRuntimeLockState => { return singletonGlobal[STATEFUL_RUNTIME_LOCK_KEY] } -export const acquireStatefulRuntimeSingleton = (owner: string): void => { - // The lock guards the browser-DOM invariant (one SDK owns window.contentfulOptimization). - // On the server there is no shared DOM, so each request constructs its own instance - // independently and the lock must not block concurrent SSR renders. - if (!('window' in globalThis)) return +export const acquireStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return const lock = getStatefulRuntimeLock() @@ -33,8 +30,8 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { lock.owner = owner } -export const releaseStatefulRuntimeSingleton = (owner: string): void => { - if (!('window' in globalThis)) return +export const releaseStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return const lock = getStatefulRuntimeLock() diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index a00650d0..5f266f41 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/lib/isBrowser.test.ts b/packages/web/web-sdk/src/lib/isBrowser.test.ts new file mode 100644 index 00000000..f2281a5c --- /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 00000000..d7c582ba --- /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' From eb1866c8fb652de42e3b5b8a6376cc2d2db80d2d Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:37:22 +0200 Subject: [PATCH 06/15] refactor(react-web-sdk): replace serverEnv IS_SERVER with isBrowser() from web-sdk Remove the serverEnv.ts module and use the shared isBrowser() util from @contentful/optimization-web/lib/isBrowser instead. The SSR test mocks isBrowser() directly so the synchronous useState init path is still covered. Co-Authored-By: Claude Sonnet 4.6 --- .../src/provider/OptimizationProvider.ssr.test.tsx | 11 ++++++----- .../src/provider/OptimizationProvider.tsx | 5 +++-- .../react-web-sdk/src/provider/serverEnv.ts | 1 - 3 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts 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 index f5880ad0..808cbda5 100644 --- 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 @@ -3,10 +3,11 @@ import type { ReactElement } from 'react' import { renderToString } from 'react-dom/server' import { OptimizationProvider, useOptimization, type OptimizationSdk } from '../index' -// Simulate a server (Node.js) environment: IS_SERVER = true causes OptimizationProvider -// to initialize the SDK synchronously inside useState rather than deferring to useLayoutEffect. -rs.mock('./serverEnv', () => ({ - IS_SERVER: true, +// Simulate a server (Node.js) environment: isBrowser() returning false causes +// OptimizationProvider to initialize the SDK synchronously inside useState rather +// than deferring to useLayoutEffect. +rs.mock('@contentful/optimization-web/lib/isBrowser', () => ({ + isBrowser: () => false, })) const testConfig = { @@ -18,7 +19,7 @@ const testConfig = { }, } -describe('OptimizationProvider — SSR (IS_SERVER: true)', () => { +describe('OptimizationProvider — SSR (isBrowser: false)', () => { afterEach(() => { window.contentfulOptimization?.destroy() delete window.contentfulOptimization 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 e2666262..60048cb1 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -11,8 +11,9 @@ import { } from '@contentful/optimization-web/presentation' import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' +import { isBrowser } from '@contentful/optimization-web/lib/isBrowser' + import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' -import { IS_SERVER } from './serverEnv' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -183,7 +184,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle // On the server, useLayoutEffect never fires, so initialize the SDK synchronously // here. Each server render gets its own instance; the singleton lock is skipped on // the server so concurrent SSR requests don't contend over globalThis. - if (IS_SERVER) { + if (!isBrowser()) { try { const binding = initializeProviderSdk(props) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts b/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts deleted file mode 100644 index 34a8c5bc..00000000 --- a/packages/web/frameworks/react-web-sdk/src/provider/serverEnv.ts +++ /dev/null @@ -1 +0,0 @@ -export const IS_SERVER = !('window' in globalThis) From 4cd251c3773b8f95be9a122a2b1906c799bc0343 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:49:22 +0200 Subject: [PATCH 07/15] fix(web-sdk): guard entry interaction start against missing browser APIs on server EntryInteractionRuntime.startEntryInteraction() now returns early when CAN_ADD_LISTENERS is false, preventing new IntersectionObserver() from being called during SSR (where IntersectionObserver is not defined). Adds a test that removes IntersectionObserver from globalThis and constructs with consent: true to reproduce the failure. Also moves isBrowser() to a local module in react-web-sdk so the SSR test can mock it without relying on an unexported web-sdk subpath. Co-Authored-By: Claude Sonnet 4.6 --- .../OptimizationProvider.ssr.test.tsx | 2 +- .../src/provider/OptimizationProvider.tsx | 3 +- .../react-web-sdk/src/provider/isBrowser.ts | 1 + .../src/ContentfulOptimization.server.test.ts | 30 ++++++++++++++++++- .../entry-tracking/EntryInteractionRuntime.ts | 9 +++++- 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts 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 index 808cbda5..14a74e5b 100644 --- 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 @@ -6,7 +6,7 @@ import { OptimizationProvider, useOptimization, type OptimizationSdk } from '../ // Simulate a server (Node.js) environment: isBrowser() returning false causes // OptimizationProvider to initialize the SDK synchronously inside useState rather // than deferring to useLayoutEffect. -rs.mock('@contentful/optimization-web/lib/isBrowser', () => ({ +rs.mock('./isBrowser', () => ({ isBrowser: () => false, })) 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 60048cb1..cd276246 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -11,9 +11,8 @@ import { } from '@contentful/optimization-web/presentation' import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' -import { isBrowser } from '@contentful/optimization-web/lib/isBrowser' - import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import { isBrowser } from './isBrowser' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. diff --git a/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts b/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts new file mode 100644 index 00000000..432da309 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts @@ -0,0 +1 @@ +export const isBrowser = (): boolean => typeof window !== 'undefined' diff --git a/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts index b3dffb9c..490228d1 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts @@ -1,4 +1,5 @@ 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. @@ -19,17 +20,44 @@ const config: CoreConfig = { describe('ContentfulOptimization — server (CAN_ADD_LISTENERS: false)', () => { afterEach(() => { - // Clean up any SDK that survived a test (e.g. on the browser singleton lock). 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) diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index 4ecc4c34..7e42c5d5 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) From ac21746eda26ef8121528c256c34feab6eea631f Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 17:57:01 +0200 Subject: [PATCH 08/15] fix(react-web-sdk): dispose server SDK before replacing with browser SDK on hydration When useLayoutEffect fires after SSR hydration, setInitializedState was overwriting sdkBindingRef without destroying the server SDK first. This left the server SDK alive (with its signals effects) while the browser SDK initialized alongside it, causing shared module-level signals to be mutated by two concurrent SDK instances. Co-Authored-By: Claude Sonnet 4.6 --- .../react-web-sdk/src/provider/OptimizationProvider.tsx | 1 + 1 file changed, 1 insertion(+) 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 cd276246..b0bd092d 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -221,6 +221,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return } + disposeOnce(sdkBindingRef.current) sdkBindingRef.current = initializedBinding setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) } From 6f0dacb1cece5b1aea6f6149b05032742fe7582b Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 19:25:05 +0200 Subject: [PATCH 09/15] fix(react-web-sdk): always initialize SDK synchronously in useState to prevent SSR hydration mismatch Previously, the SDK was only initialized in useState on the server path (isBrowser() = false). On the client during hydration, useState returned { isReady: false } causing React to detect a mismatch with the server-rendered HTML (which had isReady: true and children). React would throw away the server HTML and remount fresh, then useLayoutEffect would create the browser SDK. This broke interactions: the SDK was correctly created but the context re-render cycle confused React, causing buttons and tracking to appear frozen. Fix: initialize the SDK synchronously in useState for both server and browser, except for the two paths that require async setup (serverOptimizationState or onStatesReady). Add a useLayoutEffect guard to skip re-initialization when sdkBindingRef is already populated by useState. Update the render gate to block children when either onStatesReady or serverOptimizationState requires async setup and the SDK is not yet ready. Remove the now-unused isBrowser.ts local copy. Co-Authored-By: Claude Sonnet 4.6 --- ...ptimizationProvider.onStatesReady.test.tsx | 18 +++++-- .../OptimizationProvider.ssr.test.tsx | 17 ++++--- .../src/provider/OptimizationProvider.tsx | 50 +++++++++++++------ .../react-web-sdk/src/provider/isBrowser.ts | 1 - 4 files changed, 58 insertions(+), 28 deletions(-) delete mode 100644 packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts 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 c7569c61..a48815ce 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 index 14a74e5b..2ab28d76 100644 --- 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 @@ -3,11 +3,16 @@ import type { ReactElement } from 'react' import { renderToString } from 'react-dom/server' import { OptimizationProvider, useOptimization, type OptimizationSdk } from '../index' -// Simulate a server (Node.js) environment: isBrowser() returning false causes -// OptimizationProvider to initialize the SDK synchronously inside useState rather -// than deferring to useLayoutEffect. -rs.mock('./isBrowser', () => ({ - isBrowser: () => false, +// 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 = { @@ -19,7 +24,7 @@ const testConfig = { }, } -describe('OptimizationProvider — SSR (isBrowser: false)', () => { +describe('OptimizationProvider — SSR (CAN_ADD_LISTENERS: false, synchronous useState init)', () => { afterEach(() => { window.contentfulOptimization?.destroy() delete window.contentfulOptimization 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 b0bd092d..908c701b 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -12,7 +12,6 @@ import { import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' -import { isBrowser } from './isBrowser' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -180,20 +179,23 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle return { error: undefined, isReady: true, sdk: props.sdk } } - // On the server, useLayoutEffect never fires, so initialize the SDK synchronously - // here. Each server render gets its own instance; the singleton lock is skipped on - // the server so concurrent SSR requests don't contend over globalThis. - if (!isBrowser()) { - try { - const binding = initializeProviderSdk(props) + // Two paths must defer to useLayoutEffect and cannot init here: + // 1. serverOptimizationState — async hydration; the Promise cannot be awaited in useState. + // 2. onStatesReady — the callback must run before children mount; on server where + // useLayoutEffect never fires, children must not render at all. + if (props.serverOptimizationState !== undefined || props.onStatesReady !== undefined) { + return { error: undefined, isReady: false, sdk: undefined } + } - if (!isPromiseLike(binding)) { - sdkBindingRef.current = binding - return { error: undefined, isReady: true, sdk: binding.sdk } - } - } catch (error: unknown) { - return { error: toError(error), isReady: false, sdk: undefined } + try { + const binding = initializeProviderSdk(props) + + if (!isPromiseLike(binding)) { + sdkBindingRef.current = binding + return { error: undefined, isReady: true, sdk: binding.sdk } } + } catch (error: unknown) { + return { error: toError(error), isReady: false, sdk: undefined } } return { error: undefined, isReady: false, sdk: undefined } @@ -204,6 +206,16 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle if (canUseInjectedSdkDuringInitialRender(initialProps)) return + // SDK was already initialized synchronously in useState (browser or server); only register + // the cleanup teardown — do not create a second instance. + if (sdkBindingRef.current !== undefined) { + const { current: binding } = sdkBindingRef + return () => { + disposeSdkBinding(binding) + sdkBindingRef.current = undefined + } + } + // Async path: serverOptimizationState requires hydration before the SDK is ready. const setupState = { disposed: false } let disposedBinding: ProviderSdkBinding | undefined = undefined @@ -268,9 +280,15 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } }, [liveLocale, props.sdk, state.sdk]) - const shouldRenderChildren = state.isReady || state.error !== undefined - - 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: the owned SDK initializes in useLayoutEffect and client + // components guard on isReady/sdk in their own effects. This also allows Next.js SSR to produce + // HTML from server components inside the provider tree. + const needsAsyncSetup = + props.onStatesReady !== undefined || props.serverOptimizationState !== undefined + if (needsAsyncSetup && !state.isReady && state.error === undefined) { return null } diff --git a/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts b/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts deleted file mode 100644 index 432da309..00000000 --- a/packages/web/frameworks/react-web-sdk/src/provider/isBrowser.ts +++ /dev/null @@ -1 +0,0 @@ -export const isBrowser = (): boolean => typeof window !== 'undefined' From a82e4b966b9efd25eba0fa99769bec95f80f4ff5 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Wed, 1 Jul 2026 21:13:26 +0200 Subject: [PATCH 10/15] refactor(react-web-sdk): extract useLifecycle hook and simplify OptimizationProvider Extracts the async-safe mount/dispose logic from OptimizationProvider into a generic useLifecycle hook with focused unit tests covering sync, async, StrictMode double-mount, and post-unmount resolution edge cases. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/useLifecycle.test.tsx | 273 ++++++++++++++++++ .../react-web-sdk/src/lib/useLifecycle.ts | 69 +++++ .../src/provider/OptimizationProvider.tsx | 188 ++++-------- 3 files changed, 398 insertions(+), 132 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts 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 00000000..1fb2f01d --- /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 00000000..ae4d906e --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts @@ -0,0 +1,69 @@ +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() { + 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.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 908c701b..00b7244b 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -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 { @@ -118,26 +123,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 +140,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,130 +161,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 +function requiresAsyncSetup(props: OptimizationProviderProps): boolean { + return props.serverOptimizationState !== undefined || props.onStatesReady !== undefined +} - const sdkBindingRef = useRef(undefined) +export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { + const lifecycle = useLifecycle( + (): ProviderSdkBinding | Promise => initializeProviderSdk(props), + disposeSdkBinding, + ) const [state, setState] = useState(() => { if (canUseInjectedSdkDuringInitialRender(props)) { - return { error: undefined, isReady: true, sdk: props.sdk } + return readyState(props.sdk) } - - // Two paths must defer to useLayoutEffect and cannot init here: - // 1. serverOptimizationState — async hydration; the Promise cannot be awaited in useState. - // 2. onStatesReady — the callback must run before children mount; on server where - // useLayoutEffect never fires, children must not render at all. - if (props.serverOptimizationState !== undefined || props.onStatesReady !== undefined) { - return { error: undefined, isReady: false, sdk: undefined } - } - - try { - const binding = initializeProviderSdk(props) - - if (!isPromiseLike(binding)) { - sdkBindingRef.current = binding - return { error: undefined, isReady: true, sdk: binding.sdk } - } - } catch (error: unknown) { - return { error: toError(error), isReady: false, sdk: undefined } + if (requiresAsyncSetup(props)) { + return PENDING_STATE } - - return { error: undefined, isReady: false, sdk: undefined } + const result = lifecycle.init() + if (result === undefined) return PENDING_STATE + if ('error' in result) return failedState(result.error) + return readyState(result.value.sdk) }) - useLayoutEffect(() => { - const { current: initialProps } = initialPropsRef - - if (canUseInjectedSdkDuringInitialRender(initialProps)) return - - // SDK was already initialized synchronously in useState (browser or server); only register - // the cleanup teardown — do not create a second instance. - if (sdkBindingRef.current !== undefined) { - const { current: binding } = sdkBindingRef - return () => { - disposeSdkBinding(binding) - sdkBindingRef.current = undefined - } - } - - // Async path: serverOptimizationState requires hydration before the SDK is ready. - const setupState = { disposed: false } - let disposedBinding: ProviderSdkBinding | undefined = undefined - - function disposeOnce(binding: ProviderSdkBinding | undefined): void { - if (binding === undefined || binding === disposedBinding) return - - disposeSdkBinding(binding) - disposedBinding = binding - } - - function setInitializedState(initializedBinding: ProviderSdkBinding): void { - if (setupState.disposed) { - disposeOnce(initializedBinding) - return - } - - disposeOnce(sdkBindingRef.current) - sdkBindingRef.current = 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 }) - } - } - - try { - const initializedBinding = initializeProviderSdk(initialProps) - - if (!isPromiseLike(initializedBinding)) { - setInitializedState(initializedBinding) - return () => { - setupState.disposed = true - disposeOnce(sdkBindingRef.current) - sdkBindingRef.current = undefined - } - } - - void initializedBinding.then(setInitializedState, setInitializationError) - } catch (error: unknown) { - setInitializationError(error) - return - } + const liveLocale = props.sdk === undefined ? props.locale : undefined - return () => { - setupState.disposed = true - disposeOnce(sdkBindingRef.current) - } + 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]) + }, [liveLocale, state.sdk]) // 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: the owned SDK initializes in useLayoutEffect and client - // components guard on isReady/sdk in their own effects. This also allows Next.js SSR to produce - // HTML from server components inside the provider tree. - const needsAsyncSetup = - props.onStatesReady !== undefined || props.serverOptimizationState !== undefined - if (needsAsyncSetup && !state.isReady && state.error === undefined) { + // 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} } From 8ebde6b0d5f69747caf0d00cb0542fa53fc976fe Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 2 Jul 2026 10:22:48 +0200 Subject: [PATCH 11/15] fix(react-web-sdk): destroy stale window singleton before constructing new SDK to prevent concurrent render crash React concurrent mode can interrupt a render after the SDK constructor runs (setting window.contentfulOptimization and acquiring the stateful singleton lock) but before effects execute, leaving an orphaned instance with no cleanup path. The next render attempt then finds a live singleton and throws "already initialized". Destroy any existing window.contentfulOptimization before constructing a new instance in createOwnedSdkBinding. destroy() releases both the window singleton and the globalThis runtime lock so the new instance acquires both cleanly. Also dispose the previous value in useLifecycle init() before re-initializing, and add tests covering the concurrent render restart scenario. Co-Authored-By: Claude Sonnet 4.6 --- .../react-web-sdk/src/index.test.tsx | 25 +++++++++++++++++++ .../react-web-sdk/src/lib/useLifecycle.ts | 1 + .../OptimizationProvider.ssr.test.tsx | 21 ++++++++++++++++ .../src/provider/OptimizationProvider.tsx | 6 ++++- 4 files changed, 52 insertions(+), 1 deletion(-) 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 e8e9647c..1e313dda 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -626,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.ts b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts index ae4d906e..e55e9bec 100644 --- a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts +++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts @@ -20,6 +20,7 @@ export function useLifecycle( return { init() { + if (ref.current !== undefined) unmount() try { const value = initialize() if (!(value instanceof Promise)) { 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 index 2ab28d76..7e9c78ff 100644 --- 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 @@ -144,4 +144,25 @@ describe('OptimizationProvider — SSR (CAN_ADD_LISTENERS: false, synchronous us 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.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 00b7244b..f384d020 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -1,6 +1,7 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' +import { isBrowser } from '@contentful/optimization-web/lib/isBrowser' import { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, @@ -101,7 +102,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, }) } From b1326fb2d4db4f75654a0e17bfc1a10285f7a644 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 2 Jul 2026 10:29:42 +0200 Subject: [PATCH 12/15] fix(react-web-sdk): inline isBrowser check to fix CI build @contentful/optimization-web/lib/isBrowser is an internal subpath not declared in the package exports map, so TypeScript fails to resolve it when building against the published package in CI. Inline the check directly instead of importing the unexported internal. Co-Authored-By: Claude Sonnet 4.6 --- .../react-web-sdk/src/provider/OptimizationProvider.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 f384d020..6382ce26 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -1,7 +1,6 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' -import { isBrowser } from '@contentful/optimization-web/lib/isBrowser' import { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, @@ -103,7 +102,7 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider return createOptimizationRootSdkBinding({ config, createSdk: (sdkConfig) => { - if (isBrowser()) window.contentfulOptimization?.destroy() + if (typeof window !== 'undefined') window.contentfulOptimization?.destroy() return new ContentfulOptimization(sdkConfig) }, trackEntryInteraction, From 2d1acf356d75fc0f92adc651474ff15ee5ea7a43 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 2 Jul 2026 10:31:56 +0200 Subject: [PATCH 13/15] fix(react-web-sdk): export isBrowser from web-sdk index and use it in OptimizationProvider Exporting isBrowser from the main @contentful/optimization-web entry makes it available to consumers building against the published package, fixing the CI TS2307 error that occurred when importing from the unexported internal subpath. Co-Authored-By: Claude Sonnet 4.6 --- .../react-web-sdk/src/provider/OptimizationProvider.tsx | 4 ++-- packages/web/web-sdk/src/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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 6382ce26..3fbde1ac 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 { @@ -102,7 +102,7 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider return createOptimizationRootSdkBinding({ config, createSdk: (sdkConfig) => { - if (typeof window !== 'undefined') window.contentfulOptimization?.destroy() + if (isBrowser()) window.contentfulOptimization?.destroy() return new ContentfulOptimization(sdkConfig) }, trackEntryInteraction, diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index c5054152..fea114e9 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 From 4c0660c5af51161a201e4ae45fc4475c5626b3a1 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 2 Jul 2026 10:33:34 +0200 Subject: [PATCH 14/15] fix(react-web-sdk): export isBrowser from web-sdk index and use it in OptimizationProvider Exporting isBrowser from the main @contentful/optimization-web entry makes it available to consumers building against the published package, fixing the CI TS2307 error that occurred when importing from the unexported internal subpath. Update the trackEntryInteraction mock to include isBrowser so the mock doesn't break when OptimizationProvider imports it. Co-Authored-By: Claude Sonnet 4.6 --- .../provider/OptimizationProvider.trackEntryInteraction.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 ac3ca4a2..89478654 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) From 4e813b18c2a1e99df9f763f2ca3a39b900571452 Mon Sep 17 00:00:00 2001 From: Daviti Nalchevanidze Date: Thu, 2 Jul 2026 10:54:13 +0200 Subject: [PATCH 15/15] =?UTF-8?q?fix(react-web-sdk):=20restore=20idempoten?= =?UTF-8?q?t=20init()=20=E2=80=94=20revert=20dispose-on-reinit=20in=20useL?= =?UTF-8?q?ifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useLifecycle init() change (dispose before reinit) broke StrictMode: useState initializers run twice in StrictMode, and the second init() call was destroying the SDK created by the first, leading to double-mount and duplicate event tracking entries in E2E tests. The singleton guard against concurrent render restarts is already handled in createOwnedSdkBinding via window.contentfulOptimization?.destroy(), so useLifecycle.init() does not need to be stateful about prior instances. Restoring the original early-return makes init() idempotent. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts index e55e9bec..7582f684 100644 --- a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts +++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts @@ -20,7 +20,7 @@ export function useLifecycle( return { init() { - if (ref.current !== undefined) unmount() + if (ref.current !== undefined) return { value: ref.current } try { const value = initialize() if (!(value instanceof Promise)) {