diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index aef90b68b..ccfc14e01 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -393,17 +393,25 @@ describe('CoreStateful blocked event handling', () => { } }) - it('supports only one stateful instance per runtime until destroy is called', () => { + it('allows only one stateful instance when singleton is enforced (disableSingleton: false)', () => { const first = createCoreStateful() - const createSecondCore = (): CoreStateful => new CoreStateful(config) + const createSecond = (): CoreStateful => new CoreStateful(config, { disableSingleton: false }) - expect(createSecondCore).toThrowError(/already initialized/i) + expect(createSecond).toThrowError(/already initialized/i) first.destroy() - expect(() => { - createCoreStateful() - }).not.toThrow() + const third = createCoreStateful() + expect(third).toBeDefined() + }) + + it('allows multiple stateful instances when singleton is disabled (disableSingleton: true) — simulates SSR', () => { + const first = new CoreStateful(config, { disableSingleton: true }) + const createSecond = (): CoreStateful => new CoreStateful(config, { disableSingleton: true }) + + expect(createSecond).not.toThrow() + + first.destroy() }) it('flushes Insights API and Experience API queues with force on destroy', async () => { diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index fa55a6824..71443887f 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -217,6 +217,7 @@ const OPTIMIZATION_UNLOCKING_EVENT_TYPES: readonly EventType[] = [ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController, ConsentGuard { private readonly singletonOwner: string + private readonly singletonEnforced: boolean private destroyed = false protected readonly allowedEventTypes: AllowedEventType[] protected readonly experienceQueue: ExperienceQueue @@ -248,7 +249,10 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController profile: toObservable(profileSignal), } - constructor(config: CoreStatefulConfig) { + constructor( + config: CoreStatefulConfig, + { disableSingleton = false }: { disableSingleton?: boolean } = {}, + ) { const locale = normalizeExplicitLocale(config.locale) super( @@ -262,7 +266,8 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.eventBuilder.getConsent = () => consentSignal.value this.singletonOwner = `CoreStateful#${++statefulInstanceCounter}` - acquireStatefulRuntimeSingleton(this.singletonOwner) + this.singletonEnforced = !disableSingleton + acquireStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) try { const { allowedEventTypes, defaults, getAnonymousId, onEventBlocked, queuePolicy } = config @@ -305,7 +310,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.initializeEffects() } catch (error) { - releaseStatefulRuntimeSingleton(this.singletonOwner) + releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) throw error } } @@ -454,7 +459,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController }) this.insightsQueue.clearPeriodicFlushTimer() - releaseStatefulRuntimeSingleton(this.singletonOwner) + releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced) } reset(): void { diff --git a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts new file mode 100644 index 000000000..0e17dfd5f --- /dev/null +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.test.ts @@ -0,0 +1,109 @@ +import { + acquireStatefulRuntimeSingleton, + releaseStatefulRuntimeSingleton, +} from './StatefulRuntimeSingleton' + +type SingletonGlobal = typeof globalThis & { + __ctfl_optimization_stateful_runtime_lock__?: unknown +} + +function clearLock(): void { + const g = globalThis as SingletonGlobal + g.__ctfl_optimization_stateful_runtime_lock__ = undefined +} + +describe('StatefulRuntimeSingleton — enforce: true (browser)', () => { + beforeEach(() => { + clearLock() + }) + afterEach(() => { + clearLock() + }) + + it('acquires the lock for the first owner', () => { + expect(() => { + acquireStatefulRuntimeSingleton('owner-1', true) + }).not.toThrow() + }) + + it('throws when a second owner tries to acquire a held lock', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', true) + }).toThrowError(/already initialized/i) + }) + + it('releases the lock so a new owner can acquire it', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + releaseStatefulRuntimeSingleton('owner-1', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', true) + }).not.toThrow() + }) + + it('does not release the lock when a different owner calls release', () => { + acquireStatefulRuntimeSingleton('owner-1', true) + releaseStatefulRuntimeSingleton('owner-2', true) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-3', true) + }).toThrowError(/already initialized/i) + }) + + it('allows repeated acquire/release cycles', () => { + for (let i = 0; i < 3; i++) { + const owner = `owner-${i}` + expect(() => { + acquireStatefulRuntimeSingleton(owner, true) + }).not.toThrow() + expect(() => { + releaseStatefulRuntimeSingleton(owner, true) + }).not.toThrow() + } + }) +}) + +describe('StatefulRuntimeSingleton — enforce: false (server / SSR)', () => { + beforeEach(() => { + clearLock() + }) + afterEach(() => { + clearLock() + }) + + it('does not throw when acquiring', () => { + expect(() => { + acquireStatefulRuntimeSingleton('owner-1', false) + }).not.toThrow() + }) + + it('does not throw on a second acquire — no lock contention', () => { + acquireStatefulRuntimeSingleton('owner-1', false) + + expect(() => { + acquireStatefulRuntimeSingleton('owner-2', false) + }).not.toThrow() + }) + + it('does not throw when releasing', () => { + acquireStatefulRuntimeSingleton('owner-1', false) + + expect(() => { + releaseStatefulRuntimeSingleton('owner-1', false) + }).not.toThrow() + }) + + it('allows repeated acquire/release cycles — simulates multiple SSR requests', () => { + for (let i = 0; i < 3; i++) { + const owner = `owner-${i}` + expect(() => { + acquireStatefulRuntimeSingleton(owner, false) + }).not.toThrow() + expect(() => { + releaseStatefulRuntimeSingleton(owner, false) + }).not.toThrow() + } + }) +}) diff --git a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts index cc77c9987..6df65e994 100644 --- a/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts +++ b/packages/universal/core-sdk/src/lib/singleton/StatefulRuntimeSingleton.ts @@ -16,7 +16,9 @@ const getStatefulRuntimeLock = (): StatefulRuntimeLockState => { return singletonGlobal[STATEFUL_RUNTIME_LOCK_KEY] } -export const acquireStatefulRuntimeSingleton = (owner: string): void => { +export const acquireStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return + const lock = getStatefulRuntimeLock() if (lock.owner) { @@ -28,7 +30,9 @@ export const acquireStatefulRuntimeSingleton = (owner: string): void => { lock.owner = owner } -export const releaseStatefulRuntimeSingleton = (owner: string): void => { +export const releaseStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { + if (!enforce) return + const lock = getStatefulRuntimeLock() if (lock.owner === owner) { diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 2cfea12d8..1e313dda9 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -141,29 +141,6 @@ describe('@contentful/optimization-react-web core providers', () => { withoutLocale.unmount() }) - it('does not create an owned optimization instance during server render', () => { - let renderedChild = false - - function Probe(): null { - renderedChild = true - return null - } - - const markup = renderToString( - - - , - ) - - expect(markup).toBe('') - expect(renderedChild).toBe(false) - expect(window.contentfulOptimization).toBeUndefined() - }) - it('provides optimization and live updates from OptimizationRoot', () => { let capturedOptimization: OptimizationSdk | undefined = undefined let capturedGlobalLiveUpdates: boolean | null = null @@ -649,4 +626,29 @@ describe('@contentful/optimization-react-web core providers', () => { expect(destroySpy).not.toHaveBeenCalled() container.remove() }) + + it('destroys a stale window singleton before constructing a new SDK instance', () => { + // Simulate a concurrent-mode render that was interrupted before effects ran: + // the SDK constructor set window.contentfulOptimization but the component was + // discarded without cleanup. + const stale = new ContentfulOptimization(testConfig) + const destroySpy = rs.spyOn(stale, 'destroy') + window.contentfulOptimization = stale + + const rendered = renderClient( + +
+ , + ) + + expect(destroySpy).toHaveBeenCalledOnce() + expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization) + expect(window.contentfulOptimization).not.toBe(stale) + + rendered.unmount() + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx new file mode 100644 index 000000000..1fb2f01de --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.test.tsx @@ -0,0 +1,273 @@ +import { describe, expect, it, rs } from '@rstest/core' +import { act, StrictMode, useLayoutEffect, type ReactElement } from 'react' +import { createRoot } from 'react-dom/client' +import { useLifecycle, type InitResult } from './useLifecycle' + +function createContainer(): { root: ReturnType; unmount: () => void } { + const el = document.createElement('div') + document.body.appendChild(el) + const root = createRoot(el) + return { + root, + unmount() { + act(() => { + root.unmount() + }) + el.remove() + }, + } +} + +function render(element: ReactElement): { unmount: () => void } { + const { root, unmount } = createContainer() + act(() => { + root.render(element) + }) + return { unmount } +} + +describe('useLifecycle', () => { + describe('init', () => { + it('returns { value } when init resolves synchronously', () => { + const value = { id: 1 } + let result: InitResult = undefined + + function Component(): null { + result = useLifecycle(() => value, rs.fn()).init() + return null + } + + const { unmount } = render() + expect(result).toEqual({ value }) + unmount() + }) + + it('returns { error } when init throws', () => { + const error = new Error('init failed') + let result: InitResult = undefined + + function Component(): null { + result = useLifecycle((): unknown => { + throw error + }, rs.fn<(v: unknown) => void>()).init() + return null + } + + const { unmount } = render() + expect(result).toEqual({ error }) + unmount() + }) + + it('returns undefined when init returns a Promise', () => { + let result: InitResult = { value: 0 } + + function Component(): null { + async function asyncInit(): Promise { + return await Promise.resolve(42) + } + result = useLifecycle(asyncInit, rs.fn()).init() + return null + } + + const { unmount } = render() + expect(result).toBeUndefined() + unmount() + }) + }) + + describe('mount — synchronous init', () => { + it('calls onMount with the resolved value', () => { + const onMount = rs.fn() + const onFail = rs.fn() + const value = { id: 1 } + + function Component(): null { + const lifecycle = useLifecycle(() => value, rs.fn()) + useLayoutEffect(() => lifecycle.mount(onMount, onFail), []) + return null + } + + const { unmount } = render() + expect(onMount).toHaveBeenCalledWith(value) + expect(onFail).not.toHaveBeenCalled() + unmount() + }) + + it('calls dispose on unmount', () => { + const dispose = rs.fn() + const value = { id: 1 } + + function Component(): null { + const lifecycle = useLifecycle(() => value, dispose) + useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), []) + return null + } + + const { unmount } = render() + expect(dispose).not.toHaveBeenCalled() + unmount() + expect(dispose).toHaveBeenCalledWith(value) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('calls onFail when init throws inside mount', () => { + const error = new Error('sync throw') + const onMount = rs.fn() + const onFail = rs.fn() + + function Component(): null { + const lifecycle = useLifecycle((): never => { + throw error + }, rs.fn()) + useLayoutEffect(() => lifecycle.mount(onMount, onFail), []) + return null + } + + const { unmount } = render() + expect(onFail).toHaveBeenCalledWith(error) + expect(onMount).not.toHaveBeenCalled() + unmount() + }) + }) + + describe('mount — async init', () => { + it('calls onMount after Promise resolves', async () => { + const onMount = rs.fn() + const onFail = rs.fn() + let resolve!: (v: number) => void + const promise = new Promise((_resolve) => { + resolve = _resolve + }) + + function Component(): null { + const lifecycle = useLifecycle(async () => await promise, rs.fn()) + useLayoutEffect(() => lifecycle.mount(onMount, onFail), []) + return null + } + + const { unmount } = render() + expect(onMount).not.toHaveBeenCalled() + await act(async () => { + await Promise.resolve() + resolve(42) + }) + expect(onMount).toHaveBeenCalledWith(42) + expect(onFail).not.toHaveBeenCalled() + unmount() + }) + + it('calls onFail when Promise rejects', async () => { + const error = new Error('async fail') + const onMount = rs.fn() + const onFail = rs.fn() + let reject!: (e: unknown) => void + const promise = new Promise((_resolve, _reject) => { + reject = _reject + }) + + function Component(): null { + const lifecycle = useLifecycle(async () => await promise, rs.fn()) + useLayoutEffect(() => lifecycle.mount(onMount, onFail), []) + return null + } + + const { unmount } = render() + await act(async () => { + await Promise.resolve() + reject(error) + }) + expect(onFail).toHaveBeenCalledWith(error) + expect(onMount).not.toHaveBeenCalled() + unmount() + }) + + it('disposes value that resolves after unmount', async () => { + const dispose = rs.fn() + let resolve!: (v: number) => void + const promise = new Promise((_resolve) => { + resolve = _resolve + }) + + function Component(): null { + const lifecycle = useLifecycle(async () => await promise, dispose) + useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), []) + return null + } + + const { unmount } = render() + unmount() + await act(async () => { + await Promise.resolve() + resolve(99) + }) + expect(dispose).toHaveBeenCalledWith(99) + }) + + it('does not call onMount after unmount', async () => { + const onMount = rs.fn() + let resolve!: (v: number) => void + const promise = new Promise((_resolve) => { + resolve = _resolve + }) + + function Component(): null { + const lifecycle = useLifecycle(async () => await promise, rs.fn()) + useLayoutEffect(() => lifecycle.mount(onMount, rs.fn()), []) + return null + } + + const { unmount } = render() + unmount() + await act(async () => { + await Promise.resolve() + resolve(99) + }) + expect(onMount).not.toHaveBeenCalled() + }) + }) + + describe('StrictMode', () => { + it('disposes after StrictMode teardown and re-inits on remount', () => { + const dispose = rs.fn() + const onMount = rs.fn() + const value = { id: 1 } + + function Component(): null { + const lifecycle = useLifecycle(() => value, dispose) + useLayoutEffect(() => lifecycle.mount(onMount, rs.fn()), []) + return null + } + + const { unmount } = render( + + + , + ) + // StrictMode: mount → unmount → remount + expect(dispose).toHaveBeenCalledTimes(1) + expect(onMount).toHaveBeenCalledTimes(2) + unmount() + expect(dispose).toHaveBeenCalledTimes(2) + }) + + it('disposes exactly once on final unmount after StrictMode', () => { + const dispose = rs.fn() + const value = { id: 1 } + + function Component(): null { + const lifecycle = useLifecycle(() => value, dispose) + useLayoutEffect(() => lifecycle.mount(rs.fn(), rs.fn()), []) + return null + } + + const { unmount } = render( + + + , + ) + dispose.mockClear() + unmount() + expect(dispose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts new file mode 100644 index 000000000..7582f6842 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/lib/useLifecycle.ts @@ -0,0 +1,70 @@ +import { useRef } from 'react' + +export type InitResult = { readonly value: T } | { readonly error: unknown } | undefined + +export interface Lifecycle { + readonly init: () => InitResult + readonly mount: (onMount: (value: T) => void, onFail: (error: unknown) => void) => () => void +} + +export function useLifecycle( + initialize: () => T | Promise, + dispose: (value: T) => void, +): Lifecycle { + const ref = useRef(undefined) + + function unmount(): void { + if (ref.current !== undefined) dispose(ref.current) + ref.current = undefined + } + + return { + init() { + if (ref.current !== undefined) return { value: ref.current } + try { + const value = initialize() + if (!(value instanceof Promise)) { + ref.current = value + return { value } + } + } catch (error: unknown) { + return { error } + } + return undefined + }, + mount(onMount, onFail) { + if (ref.current !== undefined) return unmount + + let disposed = false + + function commit(value: T): void { + if (disposed) { + dispose(value) + return + } + ref.current = value + onMount(value) + } + + function fail(error: unknown): void { + if (!disposed) onFail(error) + } + + try { + const result = initialize() + if (result instanceof Promise) { + void result.then(commit, fail) + } else { + commit(result) + } + } catch (error: unknown) { + fail(error) + } + + return () => { + disposed = true + unmount() + } + }, + } +} diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c7569c612..a48815ce2 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -154,6 +154,11 @@ describe('OptimizationProvider onStatesReady', () => { resetAutoPageEmitterState() }) + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + }) + it('accepts onStatesReady on OptimizationProvider and OptimizationRoot props', () => { const onStatesReady = rs.fn() const providerProps: OptimizationProviderProps = { @@ -334,7 +339,7 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not construct owned sdk instances during server render', () => { + it('renders children and initializes sdk synchronously during server render', () => { let childRendered = false function Probe(): null { @@ -342,7 +347,7 @@ describe('OptimizationProvider onStatesReady', () => { return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) - expect(window.contentfulOptimization).toBeUndefined() + // The SDK is always initialized synchronously in useState so children render during SSR. + // The singleton is registered on window.contentfulOptimization in happy-dom (which has window); + // on a real Node.js server, window is undefined so window.contentfulOptimization is never set. + expect(childRendered).toBe(true) + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization }) it('renders injected sdk children during initial render when no state setup is needed', () => { diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx new file mode 100644 index 000000000..7e9c78ff0 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx @@ -0,0 +1,168 @@ +import ContentfulOptimization from '@contentful/optimization-web' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { OptimizationProvider, useOptimization, type OptimizationSdk } from '../index' + +// Simulate a Node.js / SSR environment: disable browser APIs so the SDK behaves +// as it would on the server — no IntersectionObserver, MutationObserver, or DOM listeners. +rs.mock('@contentful/optimization-web/constants', () => ({ + CAN_ADD_LISTENERS: false, + HAS_MUTATION_OBSERVER: false, + OPTIMIZATION_WEB_SDK_NAME: '@contentful/optimization-web', + OPTIMIZATION_WEB_SDK_VERSION: '0.0.0', + ANONYMOUS_ID_COOKIE: '__ctfl_anon', + ENTRY_ID_ATTRIBUTE: 'data-ctfl-entry-id', + ENTRY_SELECTOR: '[data-ctfl-entry-id]', +})) + +const testConfig = { + clientId: 'test-client-id', + environment: 'main', + api: { + insightsBaseUrl: 'http://localhost:8000/insights/', + experienceBaseUrl: 'http://localhost:8000/experience/', + }, +} + +describe('OptimizationProvider — SSR (CAN_ADD_LISTENERS: false, synchronous useState init)', () => { + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + }) + + it('renders children during renderToString — provider does not return null on server', () => { + let renderedChild = false + + function Probe(): null { + renderedChild = true + return null + } + + renderToString( + + + , + ) + + expect(renderedChild).toBe(true) + }) + + it('useOptimization returns a real ContentfulOptimization SDK during renderToString', () => { + let capturedSdk: OptimizationSdk | undefined = undefined + + function Probe(): null { + capturedSdk = useOptimization() + return null + } + + renderToString( + + + , + ) + + expect(capturedSdk).toBeInstanceOf(ContentfulOptimization) + }) + + it('useOptimization returns a real SDK to deeply nested children during renderToString', () => { + const capturedSdks: OptimizationSdk[] = [] + + function DeepChild(): null { + capturedSdks.push(useOptimization()) + return null + } + + function MiddleComponent(): ReactElement { + return + } + + renderToString( + + + , + ) + + expect(capturedSdks).toHaveLength(1) + expect(capturedSdks[0]).toBeInstanceOf(ContentfulOptimization) + }) + + it('server render produces non-empty HTML markup', () => { + function Content(): ReactElement { + return
hello
+ } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('hello') + }) + + it('allows multiple sequential renderToString calls — no singleton lock contention across requests', () => { + function Probe(): null { + useOptimization() + return null + } + + const render = (): void => { + renderToString( + + + , + ) + // In a real Node.js environment (no window), each render creates a fresh SDK that is + // garbage-collected after renderToString without cleanup. In happy-dom, window is present + // so window.contentfulOptimization must be cleared between renders to avoid the browser + // singleton guard from triggering — simulating the per-request isolation of a real server. + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + } + + expect(render).not.toThrow() + expect(render).not.toThrow() + expect(render).not.toThrow() + }) + + it('does not throw when window has a stale singleton from an interrupted render — simulates concurrent render restart', () => { + // Concurrent React can run the render phase (which calls init() and constructs + // an SDK) and then discard the work-in-progress tree before effects execute. + // The SDK constructor sets window.contentfulOptimization but no cleanup runs, + // so the next render attempt finds a stale singleton and must destroy it first. + const stale = new ContentfulOptimization(testConfig) + window.contentfulOptimization = stale + + expect(() => { + renderToString( + +
+ , + ) + }).not.toThrow() + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx index ac3ca4a28..89478654c 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.trackEntryInteraction.test.tsx @@ -7,6 +7,7 @@ const constructedConfigs: Array> = [] const setLocaleCalls: string[] = [] rs.mock('@contentful/optimization-web', () => ({ + isBrowser: () => true, default: class MockContentfulOptimization { constructor(config: Record) { constructedConfigs.push(config) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index eaa002daa..3fbde1acd 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -1,4 +1,4 @@ -import ContentfulOptimization from '@contentful/optimization-web' +import ContentfulOptimization, { isBrowser } from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' import { @@ -9,9 +9,10 @@ import { type OnStatesReady as SharedOnStatesReady, type TrackEntryInteractionOptions as SharedTrackEntryInteractionOptions, } from '@contentful/optimization-web/presentation' -import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' +import { useLayoutEffect, useState, type PropsWithChildren, type ReactElement } from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import { useLifecycle } from '../lib/useLifecycle' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -78,10 +79,14 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -function createInjectedSdkBinding(props: OptimizationProviderSdkProps): ProviderSdkBinding { - const { sdk } = props +const PENDING_STATE: ProviderState = { error: undefined, isReady: false, sdk: undefined } - return createOptimizationRootSdkBinding({ sdk }) +function readyState(sdk: OptimizationSdk): ProviderState { + return { error: undefined, isReady: true, sdk } +} + +function failedState(error: unknown): ProviderState { + return { error: toError(error), isReady: false, sdk: undefined } } function createOwnedSdkBinding(props: OptimizationProviderConfigProps): ProviderSdkBinding { @@ -96,7 +101,10 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider return createOptimizationRootSdkBinding({ config, - createSdk: (sdkConfig) => new ContentfulOptimization(sdkConfig), + createSdk: (sdkConfig) => { + if (isBrowser()) window.contentfulOptimization?.destroy() + return new ContentfulOptimization(sdkConfig) + }, trackEntryInteraction, }) } @@ -118,26 +126,13 @@ function bindOnStatesReady( return { ...sdkBinding, cleanup } } -async function initializeServerOptimizationState( - sdkBinding: ProviderSdkBinding, - serverOptimizationState: OptimizationData, - onStatesReady: OnStatesReady | undefined, -): Promise { - try { - await hydrateOptimizationData(sdkBinding.sdk, serverOptimizationState) - - return bindOnStatesReady(sdkBinding, onStatesReady) - } catch (error: unknown) { - disposeSdkBinding(sdkBinding) - throw error - } -} - function initializeProviderSdk( props: OptimizationProviderProps, ): ProviderSdkBinding | Promise { const sdkBinding = - props.sdk === undefined ? createOwnedSdkBinding(props) : createInjectedSdkBinding(props) + props.sdk === undefined + ? createOwnedSdkBinding(props) + : createOptimizationRootSdkBinding({ sdk: props.sdk }) if (props.serverOptimizationState === undefined) { try { @@ -148,18 +143,20 @@ function initializeProviderSdk( } } - return initializeServerOptimizationState( - sdkBinding, - props.serverOptimizationState, - props.onStatesReady, - ) -} - -function isPromiseLike(value: T | Promise): value is Promise { - return value instanceof Promise + return hydrateOptimizationData(sdkBinding.sdk, props.serverOptimizationState) + .then(() => bindOnStatesReady(sdkBinding, props.onStatesReady)) + .catch((error: unknown) => { + disposeSdkBinding(sdkBinding) + throw error + }) } -function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean { +function canUseInjectedSdkDuringInitialRender( + props: OptimizationProviderProps, +): props is OptimizationProviderSdkProps & { + readonly onStatesReady: undefined + readonly serverOptimizationState: undefined +} { return ( props.sdk !== undefined && props.onStatesReady === undefined && @@ -167,92 +164,60 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): ) } -export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { - const { children } = props - const initialPropsRef = useRef(props) - const liveLocale = props.sdk === undefined ? props.locale : undefined - const canRenderInjectedSdk = canUseInjectedSdkDuringInitialRender(props) - const [state, setState] = useState(() => ({ - error: undefined, - isReady: canRenderInjectedSdk, - sdk: canRenderInjectedSdk ? props.sdk : undefined, - })) - - useLayoutEffect(() => { - const { current: initialProps } = initialPropsRef - - if (canUseInjectedSdkDuringInitialRender(initialProps)) { - return - } - - const setupState = { disposed: false } - let sdkBinding: ProviderSdkBinding | undefined = undefined - let disposedBinding: ProviderSdkBinding | undefined = undefined - - function disposeOnce(binding: ProviderSdkBinding | undefined): void { - if (binding === undefined || binding === disposedBinding) return - - disposeSdkBinding(binding) - disposedBinding = binding - } +function requiresAsyncSetup(props: OptimizationProviderProps): boolean { + return props.serverOptimizationState !== undefined || props.onStatesReady !== undefined +} - function setInitializedState(initializedBinding: ProviderSdkBinding): void { - if (setupState.disposed) { - disposeOnce(initializedBinding) - return - } +export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { + const lifecycle = useLifecycle( + (): ProviderSdkBinding | Promise => initializeProviderSdk(props), + disposeSdkBinding, + ) - sdkBinding = initializedBinding - setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) + const [state, setState] = useState(() => { + if (canUseInjectedSdkDuringInitialRender(props)) { + return readyState(props.sdk) } - - function setInitializationError(error: unknown): void { - if (!setupState.disposed) { - setState({ error: toError(error), isReady: false, sdk: undefined }) - } + if (requiresAsyncSetup(props)) { + return PENDING_STATE } + const result = lifecycle.init() + if (result === undefined) return PENDING_STATE + if ('error' in result) return failedState(result.error) + return readyState(result.value.sdk) + }) - try { - const initializedBinding = initializeProviderSdk(initialProps) - - if (!isPromiseLike(initializedBinding)) { - setInitializedState(initializedBinding) - - return () => { - setupState.disposed = true - disposeOnce(sdkBinding) - } - } - - void initializedBinding.then(setInitializedState, setInitializationError) - } catch (error: unknown) { - setInitializationError(error) - return - } + const liveLocale = props.sdk === undefined ? props.locale : undefined - return () => { - setupState.disposed = true - disposeOnce(sdkBinding) - } + useLayoutEffect(() => { + if (canUseInjectedSdkDuringInitialRender(props)) return + return lifecycle.mount( + (binding) => { + setState(readyState(binding.sdk)) + }, + (error) => { + setState(failedState(error)) + }, + ) }, []) useLayoutEffect(() => { - if (state.sdk === undefined || props.sdk !== undefined || liveLocale === undefined) { - return - } + if (state.sdk === undefined || liveLocale === undefined) return try { state.sdk.setLocale(liveLocale) } catch (error: unknown) { - setState({ error: toError(error), isReady: true, sdk: state.sdk }) + setState({ ...readyState(state.sdk), error: toError(error) }) } - }, [liveLocale, props.sdk, state.sdk]) - - const shouldRenderChildren = state.isReady || state.error !== undefined + }, [liveLocale, state.sdk]) - if (!shouldRenderChildren) { + // Gate rendering when async setup must complete first: + // - onStatesReady: the callback subscribes to SDK state and must run before children mount. + // - serverOptimizationState: async hydration must finish before children see SDK-resolved data. + // In all other cases, always render so Next.js SSR produces HTML and client hydration matches. + if (requiresAsyncSetup(props) && !state.isReady && state.error === undefined) { return null } - return {children} + return {props.children} } diff --git a/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts new file mode 100644 index 000000000..490228d1e --- /dev/null +++ b/packages/web/web-sdk/src/ContentfulOptimization.server.test.ts @@ -0,0 +1,93 @@ +import type { CoreConfig } from '@contentful/optimization-core' +import { batch, signals } from '@contentful/optimization-core' +import ContentfulOptimization from './ContentfulOptimization' + +// Simulate a Node.js / SSR environment: no browser APIs available. +// This must appear before the module under test is imported so that +// CAN_ADD_LISTENERS resolves to false throughout the module graph. +rs.mock('./constants', () => ({ + CAN_ADD_LISTENERS: false, + HAS_MUTATION_OBSERVER: false, + OPTIMIZATION_WEB_SDK_NAME: 'optimization-web', + OPTIMIZATION_WEB_SDK_VERSION: '0.0.0', + ANONYMOUS_ID_COOKIE: '__ctfl_anon', +})) + +const config: CoreConfig = { + clientId: 'key_123', + environment: 'main', +} + +describe('ContentfulOptimization — server (CAN_ADD_LISTENERS: false)', () => { + afterEach(() => { + if (typeof window !== 'undefined') { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + } + + batch(() => { + signals.consent.value = undefined + signals.persistenceConsent.value = undefined + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + signals.changes.value = undefined + }) + }) + + it('constructs without throwing when browser APIs are unavailable', () => { + expect(() => new ContentfulOptimization(config)).not.toThrow() + }) + + it('constructs without throwing when IntersectionObserver is not defined and consent is granted — simulates Node.js / SSR', () => { + const originalIO = (globalThis as Record).IntersectionObserver + delete (globalThis as Record).IntersectionObserver + + let sdk: ContentfulOptimization | undefined + + try { + // consent: true triggers syncAutoTrackedEntryInteractions → views.start() → new ElementViewObserver + // which calls new IntersectionObserver — must not throw on the server + expect(() => { + sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + }).not.toThrow() + } finally { + sdk?.destroy() + if (originalIO !== undefined) { + ;(globalThis as Record).IntersectionObserver = originalIO + } + } + }) + + it('exposes states with no browser-sourced values', () => { + const sdk = new ContentfulOptimization(config) + + expect(sdk.states.consent.current).toBeUndefined() + expect(sdk.states.profile.current).toBeUndefined() + expect(sdk.states.selectedOptimizations.current).toBeUndefined() + }) + + it('destroys without throwing when browser APIs are unavailable', () => { + const sdk = new ContentfulOptimization(config) + + expect(() => { + sdk.destroy() + }).not.toThrow() + }) + + it('allows a second construction after destroy — no singleton lock contention across SSR requests', () => { + const first = new ContentfulOptimization(config) + first.destroy() + + expect(() => new ContentfulOptimization(config)).not.toThrow() + }) + + it('allows multiple sequential construct/destroy cycles — simulates multiple SSR requests in the same process', () => { + for (let i = 0; i < 3; i++) { + const sdk = new ContentfulOptimization(config) + expect(sdk.states).toBeDefined() + expect(() => { + sdk.destroy() + }).not.toThrow() + } + }) +}) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index a00650d02..5f266f417 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -36,6 +36,7 @@ import { createVisibilityChangeListener, } from './handlers' import { getCookie, removeCookie, setCookie, type CookieAttributes } from './lib/cookies' +import { isBrowser } from './lib/isBrowser' import LocalStore from './storage/LocalStore' export type { CookieAttributes } from './lib/cookies' @@ -293,7 +294,7 @@ class ContentfulOptimization extends CoreStateful { const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) - super(mergedConfig) + super(mergedConfig, { disableSingleton: !isBrowser() }) const canLoadPersistedContinuity = mergedConfig.defaults?.persistenceConsent === true const { cookieValue, legacyCookieValue } = readInitialCookieValues(canLoadPersistedContinuity) diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index 4ecc4c344..7e42c5d5b 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts @@ -1,4 +1,9 @@ -import { ENTRY_ID_ATTRIBUTE, ENTRY_SELECTOR, HAS_MUTATION_OBSERVER } from '../constants' +import { + CAN_ADD_LISTENERS, + ENTRY_ID_ATTRIBUTE, + ENTRY_SELECTOR, + HAS_MUTATION_OBSERVER, +} from '../constants' import { safeCall } from '../lib/safeCall' import type { EntryInteractionDetector } from './EntryInteractionDetector' import { @@ -264,6 +269,8 @@ export class EntryInteractionRuntime { } private startEntryInteraction(interaction: EntryInteraction, autoTrackingEnabled: boolean): void { + if (!CAN_ADD_LISTENERS) return + const detector = this.getDetector(interaction) detector.setAuto?.(autoTrackingEnabled) diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index c50541522..fea114e9a 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -43,6 +43,7 @@ export type { EntryViewInteractionStartOptions, } from './entry-tracking' export * from './handlers/beaconHandler' +export { isBrowser } from './lib/isBrowser' export * from './storage/LocalStore' export default ContentfulOptimization diff --git a/packages/web/web-sdk/src/lib/isBrowser.test.ts b/packages/web/web-sdk/src/lib/isBrowser.test.ts new file mode 100644 index 000000000..f2281a5c4 --- /dev/null +++ b/packages/web/web-sdk/src/lib/isBrowser.test.ts @@ -0,0 +1,28 @@ +import { isBrowser } from './isBrowser' + +describe('isBrowser', () => { + it('returns true when window is defined', () => { + expect(typeof window).toBe('object') + expect(isBrowser()).toBe(true) + }) + + it('returns false when window is undefined', () => { + const original = globalThis.window + + Object.defineProperty(globalThis, 'window', { + value: undefined, + configurable: true, + writable: true, + }) + + try { + expect(isBrowser()).toBe(false) + } finally { + Object.defineProperty(globalThis, 'window', { + value: original, + configurable: true, + writable: true, + }) + } + }) +}) diff --git a/packages/web/web-sdk/src/lib/isBrowser.ts b/packages/web/web-sdk/src/lib/isBrowser.ts new file mode 100644 index 000000000..d7c582ba1 --- /dev/null +++ b/packages/web/web-sdk/src/lib/isBrowser.ts @@ -0,0 +1,11 @@ +/** + * Returns `true` when the current environment has a browser `window` global. + * + * @remarks + * Used to decide whether browser-only features (singleton enforcement, + * DOM listeners, cookies) should be activated. Extracted as a named + * function so tests can mock the result without manipulating globals. + * + * @internal + */ +export const isBrowser = (): boolean => typeof window !== 'undefined' diff --git a/packages/web/web-sdk/src/storage/LocalStore.ts b/packages/web/web-sdk/src/storage/LocalStore.ts index af60d227f..5795d6e7d 100644 --- a/packages/web/web-sdk/src/storage/LocalStore.ts +++ b/packages/web/web-sdk/src/storage/LocalStore.ts @@ -23,6 +23,8 @@ import type { z } from 'zod/mini' const logger = createScopedLogger('Web:LocalStore') +const canUseStorage = typeof localStorage !== 'undefined' + /** * Local storage abstraction used by the Web SDK to persist optimization state. * @@ -30,7 +32,7 @@ const logger = createScopedLogger('Web:LocalStore') * @remarks * Wraps browser `localStorage` access and uses zod parsers to safely read and * write typed values. All getters return `undefined` when no valid data is - * present. + * present. All operations are no-ops on the server (SSR). */ const LocalStore = { /** @@ -67,6 +69,8 @@ const LocalStore = { * @returns The stored anonymous ID string, or `undefined` when absent. */ get anonymousId(): string | undefined { + if (!canUseStorage) return undefined + const legacyAnonymousIdValue = localStorage.getItem(ANONYMOUS_ID_KEY_LEGACY) if (legacyAnonymousIdValue) LocalStore.setCache(ANONYMOUS_ID_KEY_LEGACY, undefined) @@ -90,6 +94,8 @@ const LocalStore = { * `denied`, or `undefined` when no value is stored. */ get consent(): boolean | undefined { + if (!canUseStorage) return undefined + return decodeConsentStorageValue(localStorage.getItem(CONSENT_KEY)) }, @@ -104,6 +110,8 @@ const LocalStore = { }, get persistenceConsent(): boolean | undefined { + if (!canUseStorage) return undefined + return resolvePersistedPersistenceConsent( decodeConsentStorageValue(localStorage.getItem(PERSISTENCE_CONSENT_KEY)), LocalStore.consent, @@ -120,6 +128,8 @@ const LocalStore = { * @returns `true` or `false` when stored, or `undefined` otherwise. */ get debug(): boolean | undefined { + if (!canUseStorage) return undefined + const debug = localStorage.getItem(DEBUG_FLAG_KEY) return debug ? debug === 'true' : undefined @@ -197,6 +207,8 @@ const LocalStore = { * @returns Parsed data when present and valid, otherwise `undefined`. */ getCache(key: string, parser: T): z.output | undefined { + if (!canUseStorage) return undefined + const cacheString = localStorage.getItem(key) if (!cacheString) return @@ -221,6 +233,8 @@ const LocalStore = { * restricted storage environments (e.g. quota exhaustion, denied access). */ setCache(key: string, data: unknown): void { + if (!canUseStorage) return + try { if (data === undefined) { localStorage.removeItem(key)