diff --git a/packages/universal/core-sdk/src/CoreStateful.experienceRequestState.test.ts b/packages/universal/core-sdk/src/CoreStateful.experienceRequestState.test.ts new file mode 100644 index 00000000..5ace3104 --- /dev/null +++ b/packages/universal/core-sdk/src/CoreStateful.experienceRequestState.test.ts @@ -0,0 +1,181 @@ +import type { ExperienceResponse } from '@contentful/optimization-api-schemas' +import { http, HttpResponse } from 'msw' +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import { batch, signals, type ExperienceRequestState } from './signals' +import { profile as profileFixture } from './test/fixtures/profile' +import { server } from './test/setup' + +const EXPERIENCE_BASE_URL = 'https://experience.ninetailed.co/' + +const config: CoreStatefulConfig = { + clientId: 'key_123', + environment: 'main', + defaults: { consent: true }, +} + +const SUCCESS_BODY: ExperienceResponse = { + data: { + profile: profileFixture, + experiences: [], + changes: [], + }, + message: 'OK', + error: null, +} + +const observe = (): { + states: ExperienceRequestState[] + unsubscribe: () => void +} => { + const states: ExperienceRequestState[] = [] + const unsubscribe = signals.experienceRequestState.subscribe((value) => { + states.push(value) + }) + return { states, unsubscribe } +} + +describe('CoreStateful experienceRequestState end-to-end', () => { + const createdCores: CoreStateful[] = [] + + beforeEach(() => { + batch(() => { + signals.consent.value = undefined + signals.persistenceConsent.value = undefined + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + signals.changes.value = undefined + signals.experienceRequestState.value = { status: 'idle' } + signals.online.value = true + }) + }) + + afterEach(() => { + while (createdCores.length > 0) { + const core = createdCores.pop() + core?.destroy() + } + batch(() => { + signals.experienceRequestState.value = { status: 'idle' } + signals.selectedOptimizations.value = undefined + }) + }) + + const createCore = (overrides: Partial = {}): CoreStateful => { + const core = new CoreStateful({ ...config, ...overrides }) + createdCores.push(core) + return core + } + + it('publishes idle as the initial value to new subscribers', () => { + const { states, unsubscribe } = observe() + expect(states).toEqual([{ status: 'idle' }]) + unsubscribe() + }) + + it('flips idle -> pending -> success when the Experience API responds 200', async () => { + server.use( + http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () => + HttpResponse.json(SUCCESS_BODY, { status: 200 }), + ), + ) + + const core = createCore() + const { states, unsubscribe } = observe() + + await core.page({}) + + expect(states).toEqual([{ status: 'idle' }, { status: 'pending' }, { status: 'success' }]) + expect(signals.selectedOptimizations.value).toBeDefined() + + unsubscribe() + }) + + it('flips idle -> pending -> failed:api-error on a 500 response', async () => { + server.use( + http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () => + HttpResponse.json({ error: 'kaboom' }, { status: 500 }), + ), + ) + + const core = createCore() + const { states, unsubscribe } = observe() + + await core.page({}).catch(() => undefined) + + expect(states).toEqual([ + { status: 'idle' }, + { status: 'pending' }, + { status: 'failed', reason: 'api-error' }, + ]) + expect(signals.selectedOptimizations.value).toBeUndefined() + + unsubscribe() + }) + + // The api-client's retry layer rethrows aborted requests as a generic Error + // (`createRetryFetchMethod.ts:"may not be retried"`), which loses the original + // `AbortError` identity. Today the queue cannot distinguish a timeout from + // a non-2xx response and reports both as `failed:api-error`. The reason + // union still allows for `timeout` so a future api-client change that + // preserves error identity (or a non-retry-wrapped platform) can emit it + // without breaking the public contract. + it('flips idle -> pending -> failed:api-error when the request times out', async () => { + server.use( + http.post( + `${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, + async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + return HttpResponse.json(SUCCESS_BODY, { status: 200 }) + }, + ), + ) + + const core = createCore({ fetchOptions: { requestTimeout: 25 } }) + const { states, unsubscribe } = observe() + + await core.page({}).catch(() => undefined) + + expect(states).toEqual([ + { status: 'idle' }, + { status: 'pending' }, + { status: 'failed', reason: 'api-error' }, + ]) + expect(signals.selectedOptimizations.value).toBeUndefined() + + unsubscribe() + }) + + it('overwrites a terminal failed state with pending on the next request', async () => { + server.use( + http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () => + HttpResponse.json({ error: 'kaboom' }, { status: 500 }), + ), + ) + + const core = createCore() + + await core.page({}).catch(() => undefined) + expect(signals.experienceRequestState.value).toEqual({ + status: 'failed', + reason: 'api-error', + }) + + server.use( + http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () => + HttpResponse.json(SUCCESS_BODY, { status: 200 }), + ), + ) + + const { states, unsubscribe } = observe() + + await core.page({}) + + expect(states).toEqual([ + { status: 'failed', reason: 'api-error' }, + { status: 'pending' }, + { status: 'success' }, + ]) + + unsubscribe() + }) +}) diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 5551ab69..1973ca4d 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -31,6 +31,8 @@ import { consent as consentSignal, effect, event as eventSignal, + type ExperienceRequestState, + experienceRequestState as experienceRequestStateSignal, locale as localeSignal, type Observable, online as onlineSignal, @@ -155,6 +157,8 @@ export interface CoreStates { canOptimize: Observable /** Whether the current consent + allow-list configuration could ever produce optimizations. */ optimizationPossible: Observable + /** Outcome of the most recent Experience API request. */ + experienceRequestState: Observable } /** @@ -254,6 +258,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController locale: toObservable(localeSignal), canOptimize: toObservable(canOptimizeSignal), optimizationPossible: toObservable(this.optimizationPossibleSignal), + experienceRequestState: toObservable(experienceRequestStateSignal), selectedOptimizations: toObservable(selectedOptimizationsSignal), previewPanelAttached: toObservable(previewPanelAttachedSignal), previewPanelOpen: toObservable(previewPanelOpenSignal), @@ -392,6 +397,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController changesSignal.value = undefined profileSignal.value = undefined selectedOptimizationsSignal.value = undefined + experienceRequestStateSignal.value = { status: 'idle' } }) } diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index faf130ce..9fb24f35 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -11,6 +11,8 @@ export { signals, toDistinctObservable, toObservable, + type ExperienceRequestFailureReason, + type ExperienceRequestState, type Observable, type Signal, type SignalFns, diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts new file mode 100644 index 00000000..d5091b40 --- /dev/null +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts @@ -0,0 +1,160 @@ +import type { + ExperienceEventArray, + OptimizationData, +} from '@contentful/optimization-api-client/api-schemas' +import { InterceptorManager } from '../lib/interceptor' +import { resolveQueueFlushPolicy } from '../lib/queue' +import { + experienceRequestState, + online as onlineSignal, + selectedOptimizations as selectedOptimizationsSignal, + type ExperienceRequestState, +} from '../signals' +import { profile as profileFixture } from '../test/fixtures/profile' +import { ExperienceQueue } from './ExperienceQueue' + +const SAMPLE_DATA: OptimizationData = { + changes: [], + selectedOptimizations: [], + profile: profileFixture, +} + +class ExperienceQueueTestHarness extends ExperienceQueue { + async invokeUpsert(events: ExperienceEventArray): Promise { + return await this.upsertProfile(events) + } +} + +interface BuildQueueOptions { + upsertProfile?: (payload: { + profileId?: string + events: ExperienceEventArray + }) => Promise +} + +const buildQueue = ({ upsertProfile }: BuildQueueOptions = {}): { + queue: ExperienceQueueTestHarness + upsertProfile: ReturnType +} => { + const upsertProfileMock = + upsertProfile !== undefined + ? rs.fn(upsertProfile) + : rs.fn(async () => await Promise.resolve(SAMPLE_DATA)) + + const queue = new ExperienceQueueTestHarness({ + experienceApi: { upsertProfile: upsertProfileMock }, + eventInterceptors: new InterceptorManager(), + flushPolicy: resolveQueueFlushPolicy(undefined), + getAnonymousId: () => undefined, + offlineMaxEvents: 100, + stateInterceptors: new InterceptorManager(), + }) + + return { queue, upsertProfile: upsertProfileMock } +} + +const observeRequestState = (): { + states: ExperienceRequestState[] + unsubscribe: () => void +} => { + const states: ExperienceRequestState[] = [] + const unsubscribe = experienceRequestState.subscribe((value) => { + states.push(value) + }) + return { states, unsubscribe } +} + +describe('ExperienceQueue.experienceRequestState transitions', () => { + beforeEach(() => { + experienceRequestState.value = { status: 'idle' } + onlineSignal.value = true + selectedOptimizationsSignal.value = undefined + }) + + afterEach(() => { + experienceRequestState.value = { status: 'idle' } + selectedOptimizationsSignal.value = undefined + }) + + it('starts in the idle state', () => { + expect(experienceRequestState.value).toEqual({ status: 'idle' }) + }) + + it('transitions pending -> success around a successful upsert', async () => { + const { queue } = buildQueue() + const { states, unsubscribe } = observeRequestState() + + await queue.invokeUpsert([]) + + expect(states).toEqual([{ status: 'idle' }, { status: 'pending' }, { status: 'success' }]) + expect(experienceRequestState.value).toEqual({ status: 'success' }) + + unsubscribe() + }) + + it('transitions pending -> failed:timeout when the request aborts', async () => { + const abortError = new Error('Aborted') + abortError.name = 'AbortError' + const { queue } = buildQueue({ + upsertProfile: async () => { + await Promise.resolve() + throw abortError + }, + }) + const { states, unsubscribe } = observeRequestState() + + await expect(queue.invokeUpsert([])).rejects.toBe(abortError) + + expect(states).toEqual([ + { status: 'idle' }, + { status: 'pending' }, + { status: 'failed', reason: 'timeout' }, + ]) + expect(experienceRequestState.value).toEqual({ status: 'failed', reason: 'timeout' }) + + unsubscribe() + }) + + it('transitions pending -> failed:api-error for non-abort failures', async () => { + const { queue } = buildQueue({ + upsertProfile: async () => { + await Promise.resolve() + throw new Error('500 Internal Server Error') + }, + }) + const { states, unsubscribe } = observeRequestState() + + await expect(queue.invokeUpsert([])).rejects.toThrow('500 Internal Server Error') + + expect(states.at(-1)).toEqual({ status: 'failed', reason: 'api-error' }) + + unsubscribe() + }) + + it('overwrites a terminal failed state with pending on the next request', async () => { + let attempt = 0 + const { queue } = buildQueue({ + upsertProfile: async () => { + await Promise.resolve() + attempt += 1 + if (attempt === 1) throw new Error('boom') + return SAMPLE_DATA + }, + }) + + await expect(queue.invokeUpsert([])).rejects.toThrow('boom') + expect(experienceRequestState.value).toEqual({ status: 'failed', reason: 'api-error' }) + + const { states, unsubscribe } = observeRequestState() + + await queue.invokeUpsert([]) + + expect(states).toEqual([ + { status: 'failed', reason: 'api-error' }, + { status: 'pending' }, + { status: 'success' }, + ]) + + unsubscribe() + }) +}) diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts index 875daa67..db20aff7 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -13,13 +13,20 @@ import { batch, changes as changesSignal, event as eventSignal, + experienceRequestState as experienceRequestStateSignal, online as onlineSignal, profile as profileSignal, selectedOptimizations as selectedOptimizationsSignal, + type ExperienceRequestFailureReason, } from '../signals' const coreLogger = createScopedLogger('CoreStateful') +const classifyExperienceRequestFailure = (error: unknown): ExperienceRequestFailureReason => { + if (error instanceof Error && error.name === 'AbortError') return 'timeout' + return 'api-error' +} + /** * Context payload emitted when offline Experience events are dropped. * @@ -209,30 +216,44 @@ export class ExperienceQueue { } } - private async upsertProfile(events: ExperienceEventArray): Promise { + protected async upsertProfile(events: ExperienceEventArray): Promise { const anonymousId = this.getAnonymousId() if (anonymousId) coreLogger.debug(`Anonymous ID found: ${anonymousId}`) - const data = await this.experienceApi.upsertProfile({ - profileId: anonymousId ?? profileSignal.value?.id, - events, - }) + experienceRequestStateSignal.value = { status: 'pending' } - await this.updateOutputSignals(data) + try { + const data = await this.experienceApi.upsertProfile({ + profileId: anonymousId ?? profileSignal.value?.id, + events, + }) - return data + await this.updateOutputSignals(data) + + return data + } catch (error) { + experienceRequestStateSignal.value = { + status: 'failed', + reason: classifyExperienceRequestFailure(error), + } + throw error + } } private async updateOutputSignals(data: OptimizationData): Promise { const intercepted = await this.stateInterceptors.run(data) const { changes, profile, selectedOptimizations } = intercepted + // success must be written inside this batch because experienceRequestState transitions + // to 'success' atomically with selectedOptimizations so consumers never observe + // a render where !pending is true but canOptimize is still false batch(() => { if (!isEqual(changesSignal.value, changes)) changesSignal.value = changes if (!isEqual(profileSignal.value, profile)) profileSignal.value = profile if (!isEqual(selectedOptimizationsSignal.value, selectedOptimizations)) { selectedOptimizationsSignal.value = selectedOptimizations } + experienceRequestStateSignal.value = { status: 'success' } }) } } diff --git a/packages/universal/core-sdk/src/signals/signals.ts b/packages/universal/core-sdk/src/signals/signals.ts index cd8c7652..eed798ba 100644 --- a/packages/universal/core-sdk/src/signals/signals.ts +++ b/packages/universal/core-sdk/src/signals/signals.ts @@ -97,6 +97,44 @@ export const canOptimize = computed(() => selectedOptimizations.value ! */ export const profile: Signal = signal() +/** + * Reason for an Experience API request to enter the `failed` state. + * + * - `timeout`: the request was aborted by the configured request timeout. + * - `api-error`: the API responded with a non-success HTTP status or returned an unparseable body. + * - `aborted`: the request was aborted for any reason other than the request timeout. + * + * @public + */ +export type ExperienceRequestFailureReason = 'timeout' | 'api-error' | 'aborted' + +/** + * Outcome of the most recent Experience API request. + * + * Transitions: `idle` -> `pending` (request started) -> `success` | `failed`. Once a terminal state + * is reached it stays there until the next request transitions back to `pending`. + * + * Consumers can subscribe to this state to fail-open to baseline rendering when the Experience API + * cannot resolve optimization data (network failures, timeouts, 5xx). + * + * @public + */ +export type ExperienceRequestState = + | { status: 'idle' } + | { status: 'pending' } + | { status: 'success' } + | { status: 'failed'; reason: ExperienceRequestFailureReason } + +/** + * Outcome signal for the most recent Experience API request. + * + * Written exclusively by the `ExperienceQueue`; exposed read-only on `CoreStateful.states`. + * + * @public + */ +export const experienceRequestState: Signal = + signal({ status: 'idle' }) + /** * Collection of shared stateful Core signals. * @@ -127,6 +165,8 @@ export interface Signals { canOptimize: typeof canOptimize /** Active profile signal. */ profile: typeof profile + /** Outcome of the most recent Experience API request. */ + experienceRequestState: typeof experienceRequestState } /** @@ -163,6 +203,7 @@ export const signals: Signals = { selectedOptimizations, canOptimize, profile, + experienceRequestState, } /** diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index 4ebddaac..499948d8 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -113,10 +113,12 @@ describe('OptimizedEntry', () => { }) it('uses loadingFallback while unresolved and removes resolved tracking attrs during loading', async () => { - const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { - if (!selectedOptimizations?.length) return { entry } - return { entry: variantA, selectedOptimization: selectedOptimizations[0] } - }) + const { optimization, emit, setExperienceRequestState } = createRuntime( + (entry, selectedOptimizations) => { + if (!selectedOptimizations?.length) return { entry } + return { entry: variantA, selectedOptimization: selectedOptimizations[0] } + }, + ) const view = await renderComponent( 'loading'}> @@ -125,6 +127,8 @@ describe('OptimizedEntry', () => { optimization, ) + await setExperienceRequestState({ status: 'pending' }) + expect(view.container.textContent).toContain('loading') const loadingWrapper = getWrapper(view.container) @@ -139,11 +143,11 @@ describe('OptimizedEntry', () => { await view.unmount() }) - it('renders baseline immediately for optimized entries when optimization is not possible', async () => { + it('renders baseline immediately for optimized entries when no request is in flight', async () => { const { optimization } = createRuntime((entry, selectedOptimizations) => { if (!selectedOptimizations?.length) return { entry } return { entry: variantA, selectedOptimization: selectedOptimizations[0] } - }, false) + }) const view = await renderComponent( 'loading'}> @@ -158,8 +162,8 @@ describe('OptimizedEntry', () => { await view.unmount() }) - it('transitions out of the loading fallback once optimization becomes impossible', async () => { - const { optimization, setOptimizationPossible } = createRuntime( + it('transitions out of the loading fallback once the experience request fails', async () => { + const { optimization, setExperienceRequestState } = createRuntime( (entry, selectedOptimizations) => { if (!selectedOptimizations?.length) return { entry } return { entry: variantA, selectedOptimization: selectedOptimizations[0] } @@ -174,9 +178,11 @@ describe('OptimizedEntry', () => { optimization, ) + await setExperienceRequestState({ status: 'pending' }) + expect(view.container.textContent).toContain('loading') - await setOptimizationPossible(false) + await setExperienceRequestState({ status: 'failed', reason: 'api-error' }) expect(view.container.textContent).toContain('optimized-baseline') expect(view.container.textContent).not.toContain('loading') @@ -351,10 +357,12 @@ describe('OptimizedEntry', () => { }) it('does not render entry content initially in SPA mode', async () => { - const { optimization } = createRuntime((entry, selectedOptimizations) => { - if (!selectedOptimizations?.length) return { entry } - return { entry: variantA, selectedOptimization: selectedOptimizations[0] } - }) + const { optimization, setExperienceRequestState } = createRuntime( + (entry, selectedOptimizations) => { + if (!selectedOptimizations?.length) return { entry } + return { entry: variantA, selectedOptimization: selectedOptimizations[0] } + }, + ) const view = await renderComponent( @@ -363,6 +371,8 @@ describe('OptimizedEntry', () => { optimization, ) + await setExperienceRequestState({ status: 'pending' }) + expect(view.container.textContent).toContain('optimized-baseline') const loadingWrapper = getWrapper(view.container) @@ -373,7 +383,7 @@ describe('OptimizedEntry', () => { await view.unmount() }) - it('renders loading until canOptimize is true for optimized flow', async () => { + it('renders baseline until optimized data arrives in optimized flow', async () => { const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { if (!selectedOptimizations?.length) return { entry } return { entry: variantA, selectedOptimization: selectedOptimizations[0] } @@ -445,10 +455,12 @@ describe('OptimizedEntry', () => { }) it('retains loading layout-target behavior when display:contents visibility is unsupported', async () => { - const { optimization } = createRuntime((entry, selectedOptimizations) => { - if (!selectedOptimizations?.length) return { entry } - return { entry: variantA, selectedOptimization: selectedOptimizations[0] } - }) + const { optimization, setExperienceRequestState } = createRuntime( + (entry, selectedOptimizations) => { + if (!selectedOptimizations?.length) return { entry } + return { entry: variantA, selectedOptimization: selectedOptimizations[0] } + }, + ) const divView = await renderComponent( @@ -456,6 +468,7 @@ describe('OptimizedEntry', () => { , optimization, ) + await setExperienceRequestState({ status: 'pending' }) const divLoadingTarget = getRequiredElement( divView.container, '[data-ctfl-loading-layout-target]', @@ -464,12 +477,19 @@ describe('OptimizedEntry', () => { expect(divLoadingTarget.style.display).toBe('block') await divView.unmount() + const { optimization: optimization2, setExperienceRequestState: setExperienceRequestState2 } = + createRuntime((entry, selectedOptimizations) => { + if (!selectedOptimizations?.length) return { entry } + return { entry: variantA, selectedOptimization: selectedOptimizations[0] } + }) + const spanView = await renderComponent( {(resolved) => readTitle(resolved)} , - optimization, + optimization2, ) + await setExperienceRequestState2({ status: 'pending' }) const spanLoadingTarget = getRequiredElement( spanView.container, '[data-ctfl-loading-layout-target]', diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx index 2e009fdd..3ee154fa 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx @@ -48,9 +48,11 @@ async function renderHook(params: { describe('useOptimizedEntry', () => { it('returns baseline state before optimization is available', async () => { const baselineEntry = makeOptimizableEntry('baseline') - const { optimization } = createRuntime((entry) => ({ entry })) + const { optimization, setExperienceRequestState } = createRuntime((entry) => ({ entry })) const rendered = await renderHook({ baselineEntry, optimization }) + await setExperienceRequestState({ status: 'pending' }) + expect(rendered.getResult()).toMatchObject({ entry: baselineEntry, selectedOptimization: undefined, diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index d1b4b2c9..d91a11bb 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -1,5 +1,5 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { ExperienceRequestState, ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import { useEffect, useMemo, useState } from 'react' import { useLiveUpdates } from '../hooks/useLiveUpdates' @@ -31,7 +31,7 @@ export function useOptimizedEntry({ SelectedOptimizationArray | undefined >(undefined) const [canOptimize, setCanOptimize] = useState(false) - const [optimizationPossible, setOptimizationPossible] = useState(true) + const [experienceRequestPending, setExperienceRequestPending] = useState(false) const [sdkInitialized, setSdkInitialized] = useState(false) const shouldLiveUpdate = resolveShouldLiveUpdate({ @@ -43,7 +43,7 @@ export function useOptimizedEntry({ useEffect(() => { if (!sdk || !isReady) { setCanOptimize(false) - setOptimizationPossible(true) + setExperienceRequestPending(false) return } @@ -67,14 +67,16 @@ export function useOptimizedEntry({ setCanOptimize(value) }) - const optimizationPossibleSubscription = sdk.states.optimizationPossible.subscribe((value) => { - setOptimizationPossible(value) - }) + const experienceRequestStateSubscription = sdk.states.experienceRequestState.subscribe( + (state: ExperienceRequestState) => { + setExperienceRequestPending(state.status === 'pending') + }, + ) return () => { selectedOptimizationsSubscription.unsubscribe() canOptimizeSubscription.unsubscribe() - optimizationPossibleSubscription.unsubscribe() + experienceRequestStateSubscription.unsubscribe() } }, [isReady, sdk, shouldLiveUpdate]) @@ -91,7 +93,7 @@ export function useOptimizedEntry({ ) const requiresOptimization = hasOptimizationReferences(baselineEntry) - const isContentReady = requiresOptimization ? canOptimize || !optimizationPossible : true + const isContentReady = !requiresOptimization || !experienceRequestPending return { canOptimize, diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 5cb7b1cc..a5ed845e 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -1,5 +1,5 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { ExperienceRequestState, ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import type { ReactElement, ReactNode } from 'react' import { act } from 'react' @@ -33,6 +33,7 @@ export interface RuntimeOptimization extends OptimizationSdk { states: OptimizationSdk['states'] & { canOptimize: ObservableLike optimizationPossible: ObservableLike + experienceRequestState: ObservableLike selectedOptimizations: ObservableLike } } @@ -102,6 +103,7 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): blockedEventStream: createObservable(undefined), canOptimize: createObservable(false), optimizationPossible: createObservable(true), + experienceRequestState: createObservable({ status: 'idle' }), consent: createObservable(undefined), eventStream: createObservable(undefined), flag: () => createObservable(undefined), @@ -140,17 +142,21 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): export function createRuntime( resolveOptimizedEntry: ResolveOptimizedEntry, initialOptimizationPossible = true, + initialExperienceRequestState: ExperienceRequestState = { status: 'idle' }, ): { emit: (value: SelectedOptimizationState) => Promise setOptimizationPossible: (value: boolean) => Promise + setExperienceRequestState: (value: ExperienceRequestState) => Promise optimization: RuntimeOptimization } { const selectedOptimizationSubscribers = new Set>() const canOptimizeSubscribers = new Set>() const optimizationPossibleSubscribers = new Set>() + const experienceRequestStateSubscribers = new Set>() let current: SelectedOptimizationState = undefined let canOptimize = false let optimizationPossible = initialOptimizationPossible + let experienceRequestStateValue: ExperienceRequestState = initialExperienceRequestState const optimization = createOptimizationSdk({ resolveOptimizedEntry, @@ -193,6 +199,25 @@ export function createRuntime( return { unsubscribe: () => undefined } }, }, + experienceRequestState: { + get current() { + return experienceRequestStateValue + }, + subscribe(next: RuntimeSubscriber) { + experienceRequestStateSubscribers.add(next) + next(experienceRequestStateValue) + + return { + unsubscribe() { + experienceRequestStateSubscribers.delete(next) + }, + } + }, + subscribeOnce(next: RuntimeSubscriber) { + next(experienceRequestStateValue) + return { unsubscribe: () => undefined } + }, + }, selectedOptimizations: { get current() { return current @@ -220,6 +245,9 @@ export function createRuntime( async function emit(value: SelectedOptimizationState): Promise { current = value canOptimize = value !== undefined + if (canOptimize) { + experienceRequestStateValue = { status: 'success' } + } await act(async () => { await Promise.resolve() @@ -229,6 +257,11 @@ export function createRuntime( selectedOptimizationSubscribers.forEach((subscriber) => { subscriber(value) }) + if (canOptimize) { + experienceRequestStateSubscribers.forEach((subscriber) => { + subscriber({ status: 'success' }) + }) + } }) } @@ -243,7 +276,18 @@ export function createRuntime( }) } - return { emit, setOptimizationPossible, optimization } + async function setExperienceRequestState(value: ExperienceRequestState): Promise { + experienceRequestStateValue = value + + await act(async () => { + await Promise.resolve() + experienceRequestStateSubscribers.forEach((subscriber) => { + subscriber(value) + }) + }) + } + + return { emit, setOptimizationPossible, setExperienceRequestState, optimization } } export function defaultLiveUpdatesContext(): LiveUpdatesContextValue {