experiment(react-web-sdk): initialize SDK on server#347
Closed
David Nalchevanidze (nalchevanidze) wants to merge 16 commits into
Closed
experiment(react-web-sdk): initialize SDK on server#347David Nalchevanidze (nalchevanidze) wants to merge 16 commits into
David Nalchevanidze (nalchevanidze) wants to merge 16 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>
This was referenced Jul 1, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One of three experiment PRs for the SSR problem in #344. Each targets the same E2E suite as acceptance criteria.
useOptimizationundefinedLocalStore+ syncuseStateinitChanges
LocalStore.ts— guard alllocalStorageaccess withtypeof localStorage !== 'undefined'. Reads returnundefinedon server; writes are no-ops. Only file changed in web-sdk.OptimizationProvider.tsx— whentypeof window === 'undefined', runinitializeProviderSdksynchronously inside theuseStateinitializer. Stores the binding insdkBindingRefsouseLayoutEffectregisters cleanup without creating a second SDK instance. Browser path (defer touseLayoutEffect) is unchanged.Result
useOptimization()returns the real SDK during SSR — no stubs, noundefined, no call-site changes