Skip to content

experiment(react-web-sdk): initialize SDK on server#347

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

experiment(react-web-sdk): initialize SDK on server#347
David Nalchevanidze (nalchevanidze) wants to merge 16 commits into
fix-ssrfrom
experiment/ssr-sdk-init

Conversation

@nalchevanidze

@nalchevanidze David Nalchevanidze (nalchevanidze) commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

One of three experiment PRs for the SSR problem in #344. Each targets the same E2E suite as acceptance criteria.

PR Approach Trade-off
#349 Stub useOptimization No call-site changes; adds stub to bundle
#346 Return undefined Clean API; every call site needs a guard
#347 (this) SSR-safe LocalStore + sync useState init Real SDK on server; no stub, no API change

Changes

LocalStore.ts — guard all localStorage access with typeof localStorage !== 'undefined'. Reads return undefined on server; writes are no-ops. Only file changed in web-sdk.

OptimizationProvider.tsx — when typeof window === 'undefined', run initializeProviderSdk synchronously inside the useState initializer. Stores the binding in sdkBindingRef so useLayoutEffect registers cleanup without creating a second SDK instance. Browser path (defer to useLayoutEffect) is unchanged.

Result

  • useOptimization() returns the real SDK during SSR — no stubs, no undefined, no call-site changes
  • Browser hydration path unchanged
  • No bundle size impact

… 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>
@nalchevanidze David Nalchevanidze (nalchevanidze) marked this pull request as draft July 1, 2026 11:39
@nalchevanidze David Nalchevanidze (nalchevanidze) changed the title experiment: initialize SDK on server, remove SSR_STUB need experiment: initialize SDK on server Jul 1, 2026
@nalchevanidze David Nalchevanidze (nalchevanidze) changed the title experiment: initialize SDK on server experiment(react-web-sdk): initialize SDK on server 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>
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.

1 participant