diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/ssrStub.ts b/packages/web/frameworks/react-web-sdk/src/hooks/ssrStub.ts new file mode 100644 index 00000000..b8d70a97 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/hooks/ssrStub.ts @@ -0,0 +1,100 @@ +import ContentfulOptimization from '@contentful/optimization-web' +import type { EventEmissionResult, ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { Entry, EntrySkeletonType } from 'contentful' + +import type { OptimizationSdk } from '../context/OptimizationContext' + +function noop(): void { + // intentional no-op +} + +interface StubSubscription { + unsubscribe: () => void +} + +interface StubObservable { + readonly current: T + subscribe: (next: (v: T) => void) => StubSubscription + subscribeOnce: (next: (v: NonNullable) => void) => StubSubscription +} + +function ssrObs(current: T): StubObservable { + return { + current, + subscribe: () => ({ unsubscribe: noop }), + subscribeOnce: () => ({ unsubscribe: noop }), + } +} + +const SSR_STATES: OptimizationSdk['states'] = { + blockedEventStream: ssrObs(undefined), + canOptimize: ssrObs(false), + consent: ssrObs(undefined), + eventStream: ssrObs(undefined), + experienceRequestState: ssrObs({ status: 'idle' as const }), + flag: () => ssrObs(undefined), + locale: ssrObs(undefined), + optimizationPossible: ssrObs(false), + persistenceConsent: ssrObs(undefined), + previewPanelAttached: ssrObs(false), + previewPanelOpen: ssrObs(false), + profile: ssrObs(undefined), + selectedOptimizations: ssrObs(undefined), +} + +const SSR_TRACKING: OptimizationSdk['tracking'] = { + clearElement: noop, + disable: noop, + disableElement: noop, + enable: noop, + enableElement: noop, +} + +const NOT_ACCEPTED: EventEmissionResult = { accepted: false } + +function makeSsrStub(): OptimizationSdk { + const stub = { + consent: noop, + destroy: noop, + getFlag: () => undefined, + getMergeTagValue: () => undefined, + hasConsent: () => false, + identify: async () => { + await Promise.resolve() + return NOT_ACCEPTED + }, + locale: undefined, + page: async () => { + await Promise.resolve() + return NOT_ACCEPTED + }, + reset: noop, + resolveOptimizedEntry: (_entry: Entry): ResolvedData => ({ + entry: _entry, + }), + setLocale: () => undefined, + states: SSR_STATES, + track: async () => { + await Promise.resolve() + return NOT_ACCEPTED + }, + trackClick: async () => { + await Promise.resolve() + }, + trackView: async () => { + await Promise.resolve() + return NOT_ACCEPTED + }, + tracking: SSR_TRACKING, + } + + Object.setPrototypeOf(stub, ContentfulOptimization.prototype) + + if (!(stub instanceof ContentfulOptimization)) { + throw new Error('Expected SSR stub to use the ContentfulOptimization prototype.') + } + + return stub +} + +export const SSR_STUB: OptimizationSdk = makeSsrStub() diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts index d2792e18..796e486d 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts @@ -5,6 +5,7 @@ import { type OptimizationContextValue, type OptimizationSdk, } from '../context/OptimizationContext' +import { SSR_STUB } from './ssrStub' function getMissingProviderError(): Error { return new Error( @@ -38,10 +39,11 @@ export function useOptimization(): OptimizationSdk { }) } - throw new Error( - 'ContentfulOptimization SDK is still initializing. ' + - 'This should not happen when using the loading gate in OptimizationProvider.', - ) + // The SDK initializes in useLayoutEffect. Before that fires (during SSR and on the first + // client render), return a stub so components can render without throwing. All actual SDK + // method calls happen in effects or event handlers, which run after useLayoutEffect has + // already set the real SDK into context. + return SSR_STUB } return sdk 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..8014fd84 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -159,8 +159,9 @@ describe('@contentful/optimization-react-web core providers', () => { , ) - expect(markup).toBe('') - expect(renderedChild).toBe(false) + // Children render during SSR; the SDK constructor must never run on the server. + expect(markup).toMatchInlineSnapshot(`""`) + expect(renderedChild).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -546,7 +547,10 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - expect(results).toEqual([true, false, true]) + // Children render twice per mount: once in the initial state (isReady: false) and once after + // useLayoutEffect fires and sets the owned SDK (isReady: true). The liveUpdates values are + // stable across both renders since they come from provider props, not SDK readiness. + expect(results).toEqual([true, false, true, false, true, true]) }) it('destroys the optimization singleton on provider unmount', () => { 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..b3d20a4f 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 @@ -352,8 +352,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + // Children render during SSR; the SDK constructor must never run on the server. + expect(markup).toMatchInlineSnapshot(`""`) + expect(childRendered).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) 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..578290b9 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -248,9 +248,12 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } }, [liveLocale, props.sdk, state.sdk]) - const shouldRenderChildren = state.isReady || state.error !== undefined - - if (!shouldRenderChildren) { + // When onStatesReady is set, gate rendering until the callback has run — the app may subscribe + // to SDK state in the callback and children must not mount before that. + // Without onStatesReady, always render: the owned SDK initializes in useLayoutEffect and + // client components already guard on isReady/sdk in their own effects. This also allows + // Next.js SSR to produce HTML from server components that live inside the provider tree. + if (props.onStatesReady && !state.isReady && state.error === undefined) { return null }