experiment(react-web-sdk): initialize SDK on server (V2 drop useLifecycle)#353
experiment(react-web-sdk): initialize SDK on server (V2 drop useLifecycle)#353David Nalchevanidze (nalchevanidze) wants to merge 22 commits into
Conversation
… 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>
…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>
| export const acquireStatefulRuntimeSingleton = (owner: string, enforce: boolean): void => { | ||
| if (!enforce) return | ||
|
|
There was a problem hiding this comment.
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>
|
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>
Summary
ContentfulOptimization.getOrCreate(config)— new static factory on the web SDK. Adoptswindow.contentfulOptimizationif it already exists (callssetConfigto sync mutable props) or constructs a new instance. Returns the SDK directly, no wrapper object.ContentfulOptimization.setConfig(patch)— new instance method. UpdateslocaleandautoTrackEntryInteractionin-place without recreating the SDK.EntryInteractionRuntime.setAutoTrackOptions()— new method enabling live update of auto-track flags.OptimizationProviderreplacesuseLifecyclewith a plainuseRefinit (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.ProviderStatesimplified:sdkremoved from React state (lives insdkRef),isReadyderived fromsdk !== undefined. State is now just[error, setupDone].useLifecyclehook and its tests deleted — no longer needed.OptimizationProvider.singleton.test.tsxcovering 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 passpnpm lint— cleanpnpm typecheck— cleannextjs-sdk_hybrid(port 3002) andnextjs-sdk_ssr(port 3001) and verify no lifecycle crashes under React StrictMode🤖 Generated with Claude Code