Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
be961e0
experiment: initialize SDK on server via SSR-safe LocalStore and sync…
nalchevanidze Jul 1, 2026
b43eae8
experiment: fix useLayoutEffect browser path regressions
nalchevanidze Jul 1, 2026
72d087e
chore: merge origin/fix-ssr — remove SSR stub, keep server-side useSt…
nalchevanidze Jul 1, 2026
4d1bd07
experiment(ssr-sdk-init): initialize web SDK on server, skip singleto…
nalchevanidze Jul 1, 2026
35e8fc5
fix(ssr-sdk-init): use 'window' in globalThis instead of typeof window
nalchevanidze Jul 1, 2026
c56b438
refactor(core-sdk, web-sdk): make singleton enforcement explicit via …
nalchevanidze Jul 1, 2026
eb1866c
refactor(react-web-sdk): replace serverEnv IS_SERVER with isBrowser()…
nalchevanidze Jul 1, 2026
4cd251c
fix(web-sdk): guard entry interaction start against missing browser A…
nalchevanidze Jul 1, 2026
ac21746
fix(react-web-sdk): dispose server SDK before replacing with browser …
nalchevanidze Jul 1, 2026
6f0dacb
fix(react-web-sdk): always initialize SDK synchronously in useState t…
nalchevanidze Jul 1, 2026
a82e4b9
refactor(react-web-sdk): extract useLifecycle hook and simplify Optim…
nalchevanidze Jul 1, 2026
8ebde6b
fix(react-web-sdk): destroy stale window singleton before constructin…
nalchevanidze Jul 2, 2026
b1326fb
fix(react-web-sdk): inline isBrowser check to fix CI build
nalchevanidze Jul 2, 2026
2d1acf3
fix(react-web-sdk): export isBrowser from web-sdk index and use it in…
nalchevanidze Jul 2, 2026
4c0660c
fix(react-web-sdk): export isBrowser from web-sdk index and use it in…
nalchevanidze Jul 2, 2026
4e813b1
fix(react-web-sdk): restore idempotent init() — revert dispose-on-rei…
nalchevanidze Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions packages/universal/core-sdk/src/CoreStateful.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
13 changes: 9 additions & 4 deletions packages/universal/core-sdk/src/CoreStateful.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -305,7 +310,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController

this.initializeEffects()
} catch (error) {
releaseStatefulRuntimeSingleton(this.singletonOwner)
releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced)
throw error
}
}
Expand Down Expand Up @@ -454,7 +459,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController
})
this.insightsQueue.clearPeriodicFlushTimer()

releaseStatefulRuntimeSingleton(this.singletonOwner)
releaseStatefulRuntimeSingleton(this.singletonOwner, this.singletonEnforced)
}

reset(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
48 changes: 25 additions & 23 deletions packages/web/frameworks/react-web-sdk/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<OptimizationProvider
clientId={testConfig.clientId}
environment={testConfig.environment}
api={testConfig.api}
>
<Probe />
</OptimizationProvider>,
)

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
Expand Down Expand Up @@ -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(
<OptimizationProvider
clientId={testConfig.clientId}
environment={testConfig.environment}
api={testConfig.api}
>
<div />
</OptimizationProvider>,
)

expect(destroySpy).toHaveBeenCalledOnce()
expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization)
expect(window.contentfulOptimization).not.toBe(stale)

rendered.unmount()
})
})
Loading
Loading