diff --git a/implementations/nextjs-sdk_hybrid/components/ControlPanel.tsx b/implementations/nextjs-sdk_hybrid/components/ControlPanel.tsx index 0ce19895..3c7672d8 100644 --- a/implementations/nextjs-sdk_hybrid/components/ControlPanel.tsx +++ b/implementations/nextjs-sdk_hybrid/components/ControlPanel.tsx @@ -5,15 +5,15 @@ import { appConfig } from '@/lib/config' import { useConsent, useFlagSubscription } from '@/lib/hooks' import { useLiveUpdates, - useOptimization, useOptimizationActions, + useOptimizationContext, useProfileState, useSelectedOptimizationsState, } from '@contentful/optimization-nextjs/client' import { type JSX } from 'react' export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}): JSX.Element { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() const { identify, reset } = useOptimizationActions() const { consent, setConsent } = useConsent() const profile = useProfileState() @@ -164,6 +164,7 @@ export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}): className="btn btn--secondary btn--sm" data-testid="track-conversion-button" onClick={() => { + if (!sdk) return void sdk.trackView({ componentId: 'page-two-demo-cta', viewId: crypto.randomUUID(), diff --git a/implementations/nextjs-sdk_hybrid/components/CustomViewTracker.tsx b/implementations/nextjs-sdk_hybrid/components/CustomViewTracker.tsx index d3b61fe1..1f70528b 100644 --- a/implementations/nextjs-sdk_hybrid/components/CustomViewTracker.tsx +++ b/implementations/nextjs-sdk_hybrid/components/CustomViewTracker.tsx @@ -1,12 +1,13 @@ 'use client' -import { useOptimization } from '@contentful/optimization-nextjs/client' +import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect } from 'react' export function CustomViewTracker({ componentId }: { readonly componentId: string }): null { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() useEffect(() => { + if (!sdk) return void sdk.trackView({ componentId, viewId: crypto.randomUUID(), viewDurationMs: 0 }) }, [sdk, componentId]) diff --git a/implementations/nextjs-sdk_hybrid/lib/hooks.ts b/implementations/nextjs-sdk_hybrid/lib/hooks.ts index 153c739c..d43b46e4 100644 --- a/implementations/nextjs-sdk_hybrid/lib/hooks.ts +++ b/implementations/nextjs-sdk_hybrid/lib/hooks.ts @@ -2,7 +2,6 @@ import { useConsentState, - useOptimization, useOptimizationActions, useOptimizationContext, } from '@contentful/optimization-nextjs/client' @@ -92,18 +91,19 @@ export function useFlagSubscription(flagName: string): unknown { export function useManualViewTracking( manualTracking: boolean | undefined, ): (element: HTMLDivElement | null, entryId: string) => void { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() const trackedElement = useRef(null) useEffect( () => () => { const { current } = trackedElement - if (current) sdk.tracking.clearElement('views', current) + if (current) sdk?.tracking.clearElement('views', current) }, - [sdk.tracking], + [sdk?.tracking], ) return (element: HTMLDivElement | null, entryId: string): void => { + if (!sdk) return const { current: previous } = trackedElement if (previous && previous !== element) sdk.tracking.clearElement('views', previous) trackedElement.current = element diff --git a/implementations/nextjs-sdk_ssr/components/ControlPanel.tsx b/implementations/nextjs-sdk_ssr/components/ControlPanel.tsx index d7deaafe..5d093e4c 100644 --- a/implementations/nextjs-sdk_ssr/components/ControlPanel.tsx +++ b/implementations/nextjs-sdk_ssr/components/ControlPanel.tsx @@ -5,15 +5,15 @@ import { appConfig } from '@/lib/config' import { useConsent, useFlagSubscription } from '@/lib/hooks' import { useLiveUpdates, - useOptimization, useOptimizationActions, + useOptimizationContext, useProfileState, useSelectedOptimizationsState, } from '@contentful/optimization-nextjs/client' import type { JSX } from 'react' export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}): JSX.Element { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() const { identify, reset } = useOptimizationActions() const { consent, setConsent } = useConsent() const profile = useProfileState() @@ -164,6 +164,7 @@ export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}): className="btn btn--secondary btn--sm" data-testid="track-conversion-button" onClick={() => { + if (!sdk) return void sdk.trackView({ componentId: 'page-two-demo-cta', viewId: crypto.randomUUID(), diff --git a/implementations/nextjs-sdk_ssr/components/CustomViewTracker.tsx b/implementations/nextjs-sdk_ssr/components/CustomViewTracker.tsx index d3b61fe1..1f70528b 100644 --- a/implementations/nextjs-sdk_ssr/components/CustomViewTracker.tsx +++ b/implementations/nextjs-sdk_ssr/components/CustomViewTracker.tsx @@ -1,12 +1,13 @@ 'use client' -import { useOptimization } from '@contentful/optimization-nextjs/client' +import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect } from 'react' export function CustomViewTracker({ componentId }: { readonly componentId: string }): null { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() useEffect(() => { + if (!sdk) return void sdk.trackView({ componentId, viewId: crypto.randomUUID(), viewDurationMs: 0 }) }, [sdk, componentId]) diff --git a/implementations/nextjs-sdk_ssr/lib/hooks.ts b/implementations/nextjs-sdk_ssr/lib/hooks.ts index 153c739c..d43b46e4 100644 --- a/implementations/nextjs-sdk_ssr/lib/hooks.ts +++ b/implementations/nextjs-sdk_ssr/lib/hooks.ts @@ -2,7 +2,6 @@ import { useConsentState, - useOptimization, useOptimizationActions, useOptimizationContext, } from '@contentful/optimization-nextjs/client' @@ -92,18 +91,19 @@ export function useFlagSubscription(flagName: string): unknown { export function useManualViewTracking( manualTracking: boolean | undefined, ): (element: HTMLDivElement | null, entryId: string) => void { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() const trackedElement = useRef(null) useEffect( () => () => { const { current } = trackedElement - if (current) sdk.tracking.clearElement('views', current) + if (current) sdk?.tracking.clearElement('views', current) }, - [sdk.tracking], + [sdk?.tracking], ) return (element: HTMLDivElement | null, entryId: string): void => { + if (!sdk) return const { current: previous } = trackedElement if (previous && previous !== element) sdk.tracking.clearElement('views', previous) trackedElement.current = element diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index 1d8c714f..b388a86f 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -1,6 +1,6 @@ 'use client' -import { useOptimization } from '@contentful/optimization-react-web' +import { useOptimizationContext } from '@contentful/optimization-react-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' import { useLayoutEffect } from 'react' @@ -22,9 +22,10 @@ export interface NextjsOptimizationStateProps { } export function NextjsOptimizationState({ data }: NextjsOptimizationStateProps): null { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() useLayoutEffect(() => { + if (!sdk) return void hydrateOptimizationData(sdk, data) }, [data, sdk]) diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts index ec21721d..0c847814 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useOptimization } from '../hooks/useOptimization' +import { useOptimizationContext } from '../hooks/useOptimization' import { useConsentState } from '../hooks/useOptimizationState' import type { AutoPagePayload } from './types' @@ -51,11 +51,11 @@ export function useAutoPageEmitter({ routeKey, buildPayload, }: UseAutoPageEmitterArgs): void { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() const consent = useConsentState() useEffect(() => { - if (!enabled) { + if (!enabled || !sdk) { return } diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useEntryResolver.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useEntryResolver.ts index 55191f97..97a9fbfd 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useEntryResolver.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useEntryResolver.ts @@ -3,7 +3,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import { useMemo } from 'react' -import { useOptimization } from './useOptimization' +import { useOptimizationContext } from './useOptimization' /** * Helper methods for resolving Contentful entries against selected optimizations. @@ -31,6 +31,10 @@ export interface UseEntryResolverResult { ) => ResolvedData } +function toBaselineResolvedData(entry: Entry): ResolvedData { + return { entry, selectedOptimization: undefined } +} + /** * Returns entry-resolution helpers for React components. * @@ -38,6 +42,10 @@ export interface UseEntryResolverResult { * When `selectedOptimizations` is omitted, helpers use the current SDK * `states.selectedOptimizations` value. * + * SSR-safe: when the SDK is not yet ready the helpers return the unmodified + * baseline entry so server-rendered content matches the client-hydrated + * baseline before optimizations resolve. + * * @example * ```tsx * const { resolveEntry } = useEntryResolver() @@ -47,16 +55,16 @@ export interface UseEntryResolverResult { * @public */ export function useEntryResolver(): UseEntryResolverResult { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() return useMemo( () => ({ resolveOptimizedEntry: (entry: Entry, selectedOptimizations?: SelectedOptimizationArray) => - sdk.resolveOptimizedEntry(entry, selectedOptimizations), + sdk?.resolveOptimizedEntry(entry, selectedOptimizations) ?? toBaselineResolvedData(entry), resolveEntry: (entry: Entry, selectedOptimizations?: SelectedOptimizationArray) => - sdk.resolveOptimizedEntry(entry, selectedOptimizations).entry, + sdk?.resolveOptimizedEntry(entry, selectedOptimizations).entry ?? entry, resolveEntryData: (entry: Entry, selectedOptimizations?: SelectedOptimizationArray) => - sdk.resolveOptimizedEntry(entry, selectedOptimizations), + sdk?.resolveOptimizedEntry(entry, selectedOptimizations) ?? toBaselineResolvedData(entry), }), [sdk], ) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.ts index 53ecb824..f02398f7 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { OptimizationSdk } from '../context/OptimizationContext' -import { useOptimization } from './useOptimization' +import { useOptimizationContext } from './useOptimization' /** * Helper methods for resolving Contentful merge tag entries against the current visitor profile. @@ -18,6 +18,9 @@ export interface UseMergeTagResolverResult { /** * Returns merge-tag resolution helpers for React components. * + * @remarks + * SSR-safe: when the SDK is not yet ready the resolver returns `undefined`. + * * @example * ```tsx * const { getMergeTagValue } = useMergeTagResolver() @@ -27,12 +30,12 @@ export interface UseMergeTagResolverResult { * @public */ export function useMergeTagResolver(): UseMergeTagResolverResult { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() return useMemo( () => ({ getMergeTagValue: (embeddedEntryNodeTarget, profile) => - sdk.getMergeTagValue(embeddedEntryNodeTarget, profile), + sdk?.getMergeTagValue(embeddedEntryNodeTarget, profile), }), [sdk], ) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts index 95004008..bad9819c 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationActions.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { OptimizationSdk } from '../context/OptimizationContext' -import { useOptimization } from './useOptimization' +import { useOptimizationContext } from './useOptimization' /** * Bound Optimization SDK actions safe to destructure in React components. @@ -21,40 +21,38 @@ export interface UseOptimizationActionsResult { /** * Returns bound Optimization SDK actions that are safe to destructure. * + * @remarks + * SSR-safe: when the SDK is not yet ready (server render or initial + * synchronous client render) the returned actions no-op and event-emitting + * actions resolve to `{ accepted: false }`. Once the SDK is ready subsequent + * calls invoke the real methods. + * * @example * ```tsx * const { track, screen, flush, consent, reset } = useOptimizationActions() * await track({ event: 'purchase' }) - * await screen({ name: 'Cart' }) - * await flush() - * consent(true) - * reset() * ``` * - * @remarks - * This hook does not create a new SDK instance. It binds the most common - * actions from the existing SDK instance returned by `useOptimization()`. - * * @public */ export function useOptimizationActions(): UseOptimizationActionsResult { - const sdk = useOptimization() + const { sdk } = useOptimizationContext() return useMemo( () => ({ consent: (value) => { - sdk.consent(value) + sdk?.consent(value) }, flush: async () => { - await sdk.flush() + await sdk?.flush() }, - identify: async (payload) => await sdk.identify(payload), - page: async (payload) => await sdk.page(payload), + identify: async (payload) => (await sdk?.identify(payload)) ?? { accepted: false }, + page: async (payload) => (await sdk?.page(payload)) ?? { accepted: false }, reset: () => { - sdk.reset() + sdk?.reset() }, - screen: async (payload) => await sdk.screen(payload), - track: async (payload) => await sdk.track(payload), + screen: async (payload) => (await sdk?.screen(payload)) ?? { accepted: false }, + track: async (payload) => (await sdk?.track(payload)) ?? { accepted: false }, }), [sdk], ) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts index 50e33598..3f29091e 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import type { OptimizationSdk } from '../context/OptimizationContext' -import { useOptimization } from './useOptimization' +import { useOptimizationContext } from './useOptimization' type OptimizationStates = OptimizationSdk['states'] type ObservableValue = T extends { readonly current: infer V } ? V : never @@ -11,6 +11,13 @@ interface ObservableLike { readonly subscribe: (next: (value: T) => void) => { unsubscribe: () => void } } +// Stable no-op observable used when the SDK is not yet ready (SSR / initial +// client render). Every state hook falls back to this so SSR does not crash. +const NOOP_SUBSCRIPTION = { unsubscribe: (): void => undefined } +function emptyObservable(value: T): ObservableLike { + return { current: value, subscribe: () => NOOP_SUBSCRIPTION } +} + function useObservableState(observable: ObservableLike): T { const snapshotRef = useRef(observable.current) const observableRef = useRef(observable) @@ -42,53 +49,56 @@ function useObservableState(observable: ObservableLike): T { } /** - * Returns the current consent state. + * Returns the current consent state. Returns `undefined` before the SDK is ready. * * @public */ export function useConsentState(): ObservableValue { - const sdk = useOptimization() - return useObservableState(sdk.states.consent) + const { sdk } = useOptimizationContext() + return useObservableState(sdk?.states.consent ?? emptyObservable(undefined)) } /** - * Returns whether optimization data is currently available. + * Returns whether optimization data is currently available. Returns `false` + * before the SDK is ready. * * @public */ export function useCanOptimizeState(): ObservableValue { - const sdk = useOptimization() - return useObservableState(sdk.states.canOptimize) + const { sdk } = useOptimizationContext() + return useObservableState(sdk?.states.canOptimize ?? emptyObservable(false)) } /** - * Returns the latest emitted event payload. + * Returns the latest emitted event payload. Returns `undefined` before the SDK + * is ready. * * @public */ export function useEventStreamState(): ObservableValue { - const sdk = useOptimization() - return useObservableState(sdk.states.eventStream) + const { sdk } = useOptimizationContext() + return useObservableState(sdk?.states.eventStream ?? emptyObservable(undefined)) } /** - * Returns the current profile state. + * Returns the current profile state. Returns `undefined` before the SDK is ready. * * @public */ export function useProfileState(): ObservableValue { - const sdk = useOptimization() - return useObservableState(sdk.states.profile) + const { sdk } = useOptimizationContext() + return useObservableState(sdk?.states.profile ?? emptyObservable(undefined)) } /** - * Returns the current selected optimizations state. + * Returns the current selected optimizations state. Returns `undefined` before + * the SDK is ready. * * @public */ export function useSelectedOptimizationsState(): ObservableValue< OptimizationStates['selectedOptimizations'] > { - const sdk = useOptimization() - return useObservableState(sdk.states.selectedOptimizations) + const { sdk } = useOptimizationContext() + return useObservableState(sdk?.states.selectedOptimizations ?? emptyObservable(undefined)) } diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 2cfea12d..0bbb2c3a 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -103,7 +103,7 @@ describe('@contentful/optimization-react-web core providers', () => { let capturedOptimization: OptimizationSdk | undefined = undefined function Probe(): null { - capturedOptimization = useOptimization() + capturedOptimization = useOptimizationContext().sdk return null } @@ -141,7 +141,7 @@ describe('@contentful/optimization-react-web core providers', () => { withoutLocale.unmount() }) - it('does not create an owned optimization instance during server render', () => { + it('renders children during server render without constructing the SDK', () => { let renderedChild = false function Probe(): null { @@ -159,8 +159,10 @@ describe('@contentful/optimization-react-web core providers', () => { , ) + // SSR always renders children so browsers hydrate real HTML. + // The SDK is not constructed server-side (useLayoutEffect does not run). expect(markup).toBe('') - expect(renderedChild).toBe(false) + expect(renderedChild).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -169,7 +171,7 @@ describe('@contentful/optimization-react-web core providers', () => { let capturedGlobalLiveUpdates: boolean | null = null function Probe(): null { - capturedOptimization = useOptimization() + capturedOptimization = useOptimizationContext().sdk const { globalLiveUpdates } = useLiveUpdates() capturedGlobalLiveUpdates = globalLiveUpdates return null @@ -284,7 +286,7 @@ describe('@contentful/optimization-react-web core providers', () => { let capturedResolver: UseEntryResolverResult | undefined = undefined function Probe(): null { - capturedOptimization = useOptimization() + capturedOptimization = useOptimizationContext().sdk capturedResolver = useEntryResolver() return null } @@ -510,9 +512,10 @@ describe('@contentful/optimization-react-web core providers', () => { const results: boolean[] = [] function Probe({ liveUpdates }: { liveUpdates?: boolean }): null { + const { isReady } = useOptimizationContext() const context = useLiveUpdates() const isLive = liveUpdates ?? context.globalLiveUpdates - results.push(isLive) + if (isReady) results.push(isLive) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c7569c61..19b3b296 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -230,8 +230,13 @@ describe('OptimizationProvider onStatesReady', () => { let profileFromChild: OptimizationData['profile'] | undefined = undefined function Probe(): null { - setupOrder.push('child') - profileFromChild = useOptimization().states.profile.current + const { sdk } = useOptimizationContext() + // Skip the pre-mount render (sdk undefined) — the ordering guarantee + // applies to the ready render only. + if (sdk) { + setupOrder.push('child') + profileFromChild = sdk.states.profile.current + } return null } @@ -262,7 +267,7 @@ describe('OptimizationProvider onStatesReady', () => { let profileFromChild: OptimizationData['profile'] | undefined = undefined function Probe(): null { - profileFromChild = useOptimization().states.profile.current + profileFromChild = useOptimizationContext().sdk?.states.profile.current return null } @@ -285,8 +290,11 @@ describe('OptimizationProvider onStatesReady', () => { let profileFromChild: OptimizationData['profile'] | undefined = undefined function Probe(): null { - setupOrder.push('child') - profileFromChild = useOptimization().states.profile.current + const { sdk: readySdk } = useOptimizationContext() + if (readySdk) { + setupOrder.push('child') + profileFromChild = readySdk.states.profile.current + } return null } @@ -315,7 +323,7 @@ describe('OptimizationProvider onStatesReady', () => { let profileFromChild: OptimizationData['profile'] | undefined = undefined function Probe(): null { - profileFromChild = useOptimization().states.profile.current + profileFromChild = useOptimizationContext().sdk?.states.profile.current return null } @@ -353,7 +361,7 @@ describe('OptimizationProvider onStatesReady', () => { ) expect(markup).toBe('') - expect(childRendered).toBe(false) + expect(childRendered).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -424,7 +432,7 @@ describe('OptimizationProvider onStatesReady', () => { ) expect(markup).toBe('') - expect(childRendered).toBe(false) + expect(childRendered).toBe(true) expect(onStatesReady).not.toHaveBeenCalled() }) @@ -467,11 +475,11 @@ describe('OptimizationProvider onStatesReady', () => { const sdk = createOptimizationSdk() const destroySpy = rs.spyOn(sdk, 'destroy') const cleanup = rs.fn() - let capturedOptimization: ReturnType | undefined = undefined + let capturedOptimization: OptimizationSdk | undefined = undefined const rendered = createClientRoot() function Probe(): null { - capturedOptimization = useOptimization() + capturedOptimization = useOptimizationContext().sdk return null } @@ -527,11 +535,11 @@ describe('OptimizationProvider onStatesReady', () => { const secondSdk = createOptimizationSdk() const firstReady = rs.fn() const secondReady = rs.fn() - let capturedOptimization: ReturnType | undefined = undefined + let capturedOptimization: OptimizationSdk | undefined = undefined const rendered = createClientRoot() function Probe(): null { - capturedOptimization = useOptimization() + capturedOptimization = useOptimizationContext().sdk return null } diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index eaa002da..a820a821 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -172,6 +172,11 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const initialPropsRef = useRef(props) const liveLocale = props.sdk === undefined ? props.locale : undefined const canRenderInjectedSdk = canUseInjectedSdkDuringInitialRender(props) + // `false` during SSR and the initial synchronous client render (before the + // first useLayoutEffect fires). Flipped to `true` inside the effect below. + // On the server useLayoutEffect never runs, so this stays `false` and the + // gate below always renders children — SSR produces HTML. + const hasMountedRef = useRef(false) const [state, setState] = useState(() => ({ error: undefined, isReady: canRenderInjectedSdk, @@ -179,6 +184,7 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle })) useLayoutEffect(() => { + hasMountedRef.current = true const { current: initialProps } = initialPropsRef if (canUseInjectedSdkDuringInitialRender(initialProps)) { @@ -248,9 +254,14 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } }, [liveLocale, props.sdk, state.sdk]) - const shouldRenderChildren = state.isReady || state.error !== undefined + // On the client, block rendering while the SDK is still initializing so + // components that call `useOptimization()` at render time don't throw. + // On the server (`hasMountedRef.current === false`) always render children + // so SSR produces HTML; internal hooks read the SDK via context and safely + // default when `sdk` is undefined. + const needsBlock = hasMountedRef.current && !state.isReady && state.error === undefined - if (!shouldRenderChildren) { + if (needsBlock) { return null }