Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions packages/web/frameworks/react-web-sdk/src/hooks/ssrStub.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
readonly current: T
subscribe: (next: (v: T) => void) => StubSubscription
subscribeOnce: (next: (v: NonNullable<T>) => void) => StubSubscription
}

function ssrObs<T>(current: T): StubObservable<T> {
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<EntrySkeletonType> => ({
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()
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type OptimizationContextValue,
type OptimizationSdk,
} from '../context/OptimizationContext'
import { SSR_STUB } from './ssrStub'

function getMissingProviderError(): Error {
return new Error(
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions packages/web/frameworks/react-web-sdk/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ describe('@contentful/optimization-react-web core providers', () => {
</OptimizationProvider>,
)

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()
})

Expand Down Expand Up @@ -546,7 +547,10 @@ describe('@contentful/optimization-react-web core providers', () => {
renderClient(<FirstScenario />).unmount()
renderClient(<SecondScenario />).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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,9 @@ describe('OptimizationProvider onStatesReady', () => {
</OptimizationProvider>,
)

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()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down