Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
65fad95
feat(react-web-sdk): replace useLifecycle with page-level SDK singlet…
nalchevanidze Jul 2, 2026
93010be
refactor(react-web-sdk): collapse canUseInjectedSdkDuringInitialRende…
nalchevanidze Jul 2, 2026
c11a5c5
refactor(react-web-sdk): simplify mutable-props effect and rename pre…
nalchevanidze Jul 2, 2026
fc218e8
refactor(react-web-sdk): drop createOptimizationRootSdkBinding from i…
nalchevanidze Jul 2, 2026
4daea71
fix(react-web-sdk): restore narrowing alias for setConfig effect, ren…
nalchevanidze Jul 2, 2026
2120a77
refactor(react-web-sdk): inline intermediate prop type aliases
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

Comment on lines +19 to +21

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd understand this better if I knew what "enforce" means and what its intent is. Is there possibly a more descriptive name? If this is getting more nuanced usage with the additional flag, we may also need to add some TSDoc, even though this is otherwise internal functionality.

I'm also wondering whether "enforcement" should be opt-out instead of opt-in, since the normal use case for a Stateful SDK would be for use in stateful environments.

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
70 changes: 29 additions & 41 deletions packages/web/frameworks/react-web-sdk/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,43 +125,6 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(optimization.trackHover).toBeTypeOf('function')
expect(optimization.tracking).toBeDefined()
rendered.unmount()

capturedOptimization = undefined
const withoutLocale = renderClient(
<OptimizationProvider
clientId={testConfig.clientId}
environment={testConfig.environment}
api={testConfig.api}
>
<Probe />
</OptimizationProvider>,
)

expect(requireOptimizationSdk(capturedOptimization).locale).toBeUndefined()
withoutLocale.unmount()
})

it('does not create an owned optimization instance during server render', () => {
let renderedChild = false

function Probe(): null {
renderedChild = true
return null
}

const markup = renderToString(
<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', () => {
Expand Down Expand Up @@ -549,7 +512,7 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(results).toEqual([true, false, true])
})

it('destroys the optimization singleton on provider unmount', () => {
it('keeps the SDK singleton alive after provider unmount', () => {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
Expand All @@ -572,7 +535,7 @@ describe('@contentful/optimization-react-web core providers', () => {
root.unmount()
})

expect(window.contentfulOptimization).toBeUndefined()
expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization)

const remountRoot = createRoot(container)

Expand All @@ -597,7 +560,7 @@ describe('@contentful/optimization-react-web core providers', () => {
container.remove()
})

it('cleans up layout-effect initialization during StrictMode replay', () => {
it('keeps the SDK singleton alive after StrictMode unmount', () => {
const rendered = renderClient(
<StrictMode>
<OptimizationProvider
Expand All @@ -614,7 +577,7 @@ describe('@contentful/optimization-react-web core providers', () => {

rendered.unmount()

expect(window.contentfulOptimization).toBeUndefined()
expect(window.contentfulOptimization).toBeInstanceOf(ContentfulOptimization)
})

it('uses an injected sdk instance without taking ownership of teardown', () => {
Expand Down Expand Up @@ -649,4 +612,29 @@ describe('@contentful/optimization-react-web core providers', () => {
expect(destroySpy).not.toHaveBeenCalled()
container.remove()
})

it('adopts a window singleton left by an interrupted concurrent-mode render instead of destroying it', () => {
// Simulate a concurrent-mode render that was interrupted before effects ran:
// the SDK constructor set window.contentfulOptimization but the component was
// discarded without cleanup. The new behavior is to reuse the instance rather
// than destroy-and-recreate it.
const stale = new ContentfulOptimization(testConfig)
const destroySpy = rs.spyOn(stale, 'destroy')
window.contentfulOptimization = stale

const rendered = renderClient(
<OptimizationProvider
clientId={testConfig.clientId}
environment={testConfig.environment}
api={testConfig.api}
>
<div />
</OptimizationProvider>,
)

expect(destroySpy).not.toHaveBeenCalled()
expect(window.contentfulOptimization).toBe(stale)

rendered.unmount()
})
})
Loading
Loading