Skip to content

experiment(react-web-sdk): initialize SDK on server (V2 drop useLifecycle)#353

Closed
David Nalchevanidze (nalchevanidze) wants to merge 22 commits into
fix-ssrfrom
experiment/ssr-sdk-lifecycle
Closed

experiment(react-web-sdk): initialize SDK on server (V2 drop useLifecycle)#353
David Nalchevanidze (nalchevanidze) wants to merge 22 commits into
fix-ssrfrom
experiment/ssr-sdk-lifecycle

Conversation

@nalchevanidze

Copy link
Copy Markdown
Contributor

Summary

  • ContentfulOptimization.getOrCreate(config) — new static factory on the web SDK. Adopts window.contentfulOptimization if it already exists (calls setConfig to sync mutable props) or constructs a new instance. Returns the SDK directly, no wrapper object.
  • ContentfulOptimization.setConfig(patch) — new instance method. Updates locale and autoTrackEntryInteraction in-place without recreating the SDK.
  • EntryInteractionRuntime.setAutoTrackOptions() — new method enabling live update of auto-track flags.
  • OptimizationProvider replaces useLifecycle with a plain useRef init (sdkRef ??= during render — SSR-safe, StrictMode-safe). The SDK is never destroyed on provider unmount; it outlives the React tree as a page-level singleton.
  • ProviderState simplified: sdk removed from React state (lives in sdkRef), isReady derived from sdk !== undefined. State is now just [error, setupDone].
  • useLifecycle hook and its tests deleted — no longer needed.
  • New test file OptimizationProvider.singleton.test.tsx covering singleton adoption, no-destroy-on-unmount, StrictMode safety, and live prop updates.

Motivation

The previous approach of destroying and recreating the SDK on every React mount/unmount caused crashes under StrictMode double-invocation and concurrent-mode render interruptions. Treating the SDK as a singleton eliminates those races entirely.

Test plan

  • pnpm test:unit — all 117 react-web-sdk tests + 283 web-sdk tests pass
  • pnpm lint — clean
  • pnpm typecheck — clean
  • Manual: start nextjs-sdk_hybrid (port 3002) and nextjs-sdk_ssr (port 3001) and verify no lifecycle crashes under React StrictMode

🤖 Generated with Claude Code

… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ate init

The stub (ssrStub.ts) was pushing index.cjs and index.mjs over their gzip
budgets (+55 / +61 bytes). The SSR_STUB is not needed on this branch because
the server already gets a real SDK from the synchronous useState initializer.

Conflicts resolved:
- useOptimization.ts: take fix-ssr version (throw instead of returning stub)
- OptimizationProvider.tsx: auto-merged correctly (server init path preserved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n lock for SSR

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…disableSingleton flag

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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…PIs 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…o 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 <noreply@anthropic.com>
…izationProvider

Extracts the async-safe mount/dispose logic from OptimizationProvider into a
generic useLifecycle<T> hook with focused unit tests covering sync, async,
StrictMode double-mount, and post-unmount resolution edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…g 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 <noreply@anthropic.com>
@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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…nit in useLifecycle

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 <noreply@anthropic.com>
…on via getOrCreate/setConfig

SDK is now treated as a page-level singleton that outlives the React tree.
ContentfulOptimization.getOrCreate() adopts an existing window singleton or
constructs a new one; setConfig() updates mutable props (locale,
autoTrackEntryInteraction) in-place without recreating the SDK instance.

OptimizationProvider replaces the useLifecycle hook with a plain useRef init
(sdkRef ??= during render — SSR-safe and StrictMode-safe) plus a useLayoutEffect
for the async setup path (serverOptimizationState hydration + onStatesReady).
ProviderState is simplified to [error, setupDone]; sdk lives in the ref.
destroy() is never called on provider unmount.

EntryInteractionRuntime gains setAutoTrackOptions() to support live config
updates without restarting the runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r + requiresAsyncSetup into needsSetupPhase

The two predicates were redundant — canUseInjectedSdkDuringInitialRender
was a strict subset of !requiresAsyncSetup. A single needsSetupPhase()
util replaces both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nalchevanidze David Nalchevanidze (nalchevanidze) changed the base branch from main to fix-ssr July 2, 2026 12:38
…dicates

Collapse the two props.sdk === undefined ternaries into a single ownedProps
narrowing. The setConfig effect reads locale and trackEntryInteraction
directly from ownedProps, removing the undefined spread conditionals.

Rename needsSetupPhase → hasSetupCallbacks to describe the condition
(serverOptimizationState or onStatesReady is set) rather than the effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nalchevanidze David Nalchevanidze (nalchevanidze) changed the title feat(react-web-sdk): page-level SDK singleton — getOrCreate / setConfig / drop useLifecycle experiment(react-web-sdk): initialize SDK on server (V2 drop useLifecycle) Jul 2, 2026
@nalchevanidze David Nalchevanidze (nalchevanidze) marked this pull request as draft July 2, 2026 12:48
Comment on lines +19 to +21
export const acquireStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => {
if (!enforce) return

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.

…njected path and ownedProps alias

Injected SDK path now reads props.sdk directly instead of wrapping and
immediately unwrapping via createOptimizationRootSdkBinding.

ownedProps narrowing alias removed; setConfig effect checks props.sdk
directly and lets TypeScript narrow to OptimizationProviderConfigProps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@phobetron

Copy link
Copy Markdown
Collaborator

I feel like this change muddies the use cases for the stateful SDK and it's not straightforward to understand why the stateful SDK needs to have its singleton status not "enforced". I want to make sure this isn't an overly-invasive treatment against a symptom that could otherwise be handled more explicitly without making it so easy to break the stateful SDK's "this is a singleton with all the protections afforded a singleton" contract.

…ame to configProps

TypeScript cannot narrow a union-typed prop through a guard inside a
closure. Restore the explicit narrowing alias (props.sdk === undefined ?
props : undefined) and rename from ownedProps to configProps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove OptimizationProviderBaseConfigProps and ServerOptimizationStateProps
one-liner aliases; inline their content directly into the two public prop
types. No behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants