From 00a7e620e595e245b9feca804c8cd5807ade7c4f Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:22:58 +0200 Subject: [PATCH 1/6] feat(core-sdk): add experienceRequestState signal to track Experience API outcomes Introduces a writable `experienceRequestState` signal in `core-sdk` that transitions through `idle -> pending -> success | failed` as the Experience API request progresses. `ExperienceQueue.upsertProfile` writes the signal on each request; success is published in the same reactive batch as the output signals so consumers never observe stale data. Failure is classified as `timeout` (AbortError) or `api-error` (all other throws). Exposes the signal read-only on `CoreStateful.states.experienceRequestState` and exports `ExperienceRequestState` and `ExperienceRequestFailureReason` from the package public API. Co-Authored-By: Claude Sonnet 4.6 --- ...oreStateful.experienceRequestState.test.ts | 181 ++++++++++++++++++ .../universal/core-sdk/src/CoreStateful.ts | 5 + packages/universal/core-sdk/src/index.ts | 2 + .../src/queues/ExperienceQueue.test.ts | 160 ++++++++++++++++ .../core-sdk/src/queues/ExperienceQueue.ts | 32 +++- .../universal/core-sdk/src/signals/signals.ts | 41 ++++ 6 files changed, 414 insertions(+), 7 deletions(-) create mode 100644 packages/universal/core-sdk/src/CoreStateful.experienceRequestState.test.ts create mode 100644 packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts 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..9ab22b15 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), 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..4949937b 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,18 +216,28 @@ 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 { @@ -233,6 +250,7 @@ export class ExperienceQueue { 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, } /** From 0fad5caaf344f703a7b9f78a21f9e19ddf8abeb4 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:23:37 +0200 Subject: [PATCH 2/6] feat(react-web-sdk): exit loading state when Experience API request fails Subscribes useOptimizedEntry to experienceRequestState and sets experienceRequestFailed when the status is 'failed'. Adds that flag as a third escape hatch in the isContentReady expression alongside canOptimize and !optimizationPossible, so OptimizedEntry renders baseline content immediately on API errors, timeouts, or network blocks rather than staying in the loading state indefinitely. Updates sdkTestUtils createRuntime to expose a controllable experienceRequestState observable and setExperienceRequestState helper, and adds the signal to createOptimizationSdk's default states so all test paths have it available. Co-Authored-By: Claude Sonnet 4.6 --- .../src/optimized-entry/useOptimizedEntry.ts | 15 ++++++- .../react-web-sdk/src/test/sdkTestUtils.tsx | 40 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) 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..b8a6cf8d 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' @@ -32,6 +32,7 @@ export function useOptimizedEntry({ >(undefined) const [canOptimize, setCanOptimize] = useState(false) const [optimizationPossible, setOptimizationPossible] = useState(true) + const [experienceRequestFailed, setExperienceRequestFailed] = useState(false) const [sdkInitialized, setSdkInitialized] = useState(false) const shouldLiveUpdate = resolveShouldLiveUpdate({ @@ -44,6 +45,7 @@ export function useOptimizedEntry({ if (!sdk || !isReady) { setCanOptimize(false) setOptimizationPossible(true) + setExperienceRequestFailed(false) return } @@ -71,10 +73,17 @@ export function useOptimizedEntry({ setOptimizationPossible(value) }) + const experienceRequestStateSubscription = sdk.states.experienceRequestState.subscribe( + (state: ExperienceRequestState) => { + setExperienceRequestFailed(state.status === 'failed') + }, + ) + return () => { selectedOptimizationsSubscription.unsubscribe() canOptimizeSubscription.unsubscribe() optimizationPossibleSubscription.unsubscribe() + experienceRequestStateSubscription.unsubscribe() } }, [isReady, sdk, shouldLiveUpdate]) @@ -91,7 +100,9 @@ export function useOptimizedEntry({ ) const requiresOptimization = hasOptimizationReferences(baselineEntry) - const isContentReady = requiresOptimization ? canOptimize || !optimizationPossible : true + const isContentReady = requiresOptimization + ? canOptimize || !optimizationPossible || experienceRequestFailed + : true 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..637f472f 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 @@ -243,7 +268,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 { From 7f65472526a5d19c2e32fc6e21a942675f39f0f1 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:24:07 +0200 Subject: [PATCH 3/6] docs: add experience-request-state concept document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the experienceRequestState signal — the state machine, transition rules, what failure cases it covers (adblocker, timeout, 4xx/5xx), what it does not cover (no page() call ever made), the consumer pattern used in useOptimizedEntry, and the cross-platform contract all SDK implementations must honour. Co-Authored-By: Claude Sonnet 4.6 --- .../concepts/experience-request-state.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 documentation/concepts/experience-request-state.md diff --git a/documentation/concepts/experience-request-state.md b/documentation/concepts/experience-request-state.md new file mode 100644 index 00000000..6dc7a4b1 --- /dev/null +++ b/documentation/concepts/experience-request-state.md @@ -0,0 +1,130 @@ +--- +title: Experience request state +--- + +# Experience request state + +Use this document to understand how the Optimization SDK Suite reports the outcome of the most +recent Experience API request, and why consumers should react to that outcome instead of waiting +indefinitely for optimization data. It explains the state machine, the transitions, the contract +each platform implementation must honor, and the cases this signal does and does not cover. + +For the broader signal catalog and how `states` is exposed, see +[Core state management](./core-state-management.md). For installation and setup, see +[Integrating the React web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md). + +
+ Table of Contents + + +- [Why this signal exists](#why-this-signal-exists) +- [The state machine](#the-state-machine) +- [Transition rules](#transition-rules) +- [What this signal covers](#what-this-signal-covers) +- [What this signal does not cover](#what-this-signal-does-not-cover) +- [Consumer pattern](#consumer-pattern) +- [Cross-platform contract](#cross-platform-contract) + + +
+ +## Why this signal exists + +The `OptimizedEntry` component, and any other consumer that gates rendering on optimization data, +needs to know when the Experience API request that should produce that data has succeeded or failed. +Without that signal, a consumer that is waiting for `selectedOptimizations` has no way to +distinguish "still loading" from "the request already failed and no more data is coming". A failed +request that is silently retained as `loading` results in regions of the page that never resolve to +either a variant or the baseline. + +`experienceRequestState` reports the outcome explicitly so consumers can fail-open to baseline +content when the API cannot resolve optimization data. + +## The state machine + +`experienceRequestState` is a writable signal exposed read-only on `CoreStateful.states`. Its value +is one of: + +| State | Meaning | +| ------------------------------------------- | -------------------------------------------------------------------------------------- | +| `{ status: 'idle' }` | No Experience API request has been attempted in this runtime. | +| `{ status: 'pending' }` | An Experience API request is in flight. | +| `{ status: 'success' }` | The most recent request returned data and the output signals have been updated. | +| `{ status: 'failed', reason: 'timeout' }` | The most recent request was aborted by the configured request timeout. | +| `{ status: 'failed', reason: 'api-error' }` | The most recent request returned a non-success HTTP status or an unparseable response. | +| `{ status: 'failed', reason: 'aborted' }` | Reserved for explicit-cancellation paths added in future work. | + +The `failed` state carries a `reason` so consumers and telemetry can distinguish network outages +from API errors. The set of reasons is closed; new reasons are additive on this signal rather than +introduced as separate signals. + +In the current TypeScript runtime, the api-client's retry layer rethrows aborted requests as a +generic `Error`, which loses the original `AbortError` identity. That means the queue cannot +distinguish a timeout from a non-success HTTP response and reports both as `failed:api-error`. The +`timeout` reason is retained in the public type so a future api-client change that preserves error +identity, or any platform that does not wrap fetch in the same retry layer, can emit it without a +breaking change. Treat the reason as a hint, not a guarantee, until the api-client preserves the +error name end-to-end. + +## Transition rules + +- The `ExperienceQueue` is the only writer. Consumers receive the value through the read-only + `Observable` exposed at `states.experienceRequestState`. +- A request transitions `pending` before the network call and either `success` after the output + signals (`changes`, `profile`, `selectedOptimizations`) have been written, or `failed` when the + underlying call rejects. +- `success` is published in the same reactive batch as the output signals. Consumers will never + observe `success` while `selectedOptimizations` is still stale. +- Terminal states (`success`, `failed`) persist until the next request begins. They are not reset to + `idle` between requests. Subscribers always see "the outcome of the last request." +- The next request flips the value to `pending` directly; there is no intermediate `idle` flicker. + +## What this signal covers + +- Adblockers or DNS errors that prevent the request from completing. +- The configured request timeout aborting an in-flight request. +- HTTP 4xx or 5xx responses from the Experience API. +- Response bodies that fail schema validation. + +In all of these cases the signal transitions to a `failed` state and consumers can render the +baseline immediately. + +## What this signal does not cover + +- The case where no Experience API request is ever attempted because the integrating application did + not call `page()`, `identify()`, `screen()`, or `track()`. In that case no flush occurs, the + signal stays `idle`, and `OptimizedEntry` would still wait for optimization data that never + arrives. Closing this gap requires a separate end-to-end resolution timer that transitions the + state to `failed` after a configurable interval when no request has been observed. That timer is + intentionally not part of this signal — it composes on top of it. +- Per-request retry semantics. Retries are owned by the queue's flush policy. The signal reports the + outcome of each completed network call. + +## Consumer pattern + +Consumers subscribe to `experienceRequestState` alongside `canOptimize` and `optimizationPossible`, +and treat any `failed` state as a third escape hatch from "still loading": + +```ts +isContentReady = canOptimize || !optimizationPossible || experienceRequestFailed +``` + +In the React web SDK, `useOptimizedEntry` applies this rule, so `OptimizedEntry` renders the +baseline content without any consumer changes when the Experience API fails. Other consumers that +gate rendering or branching on optimization data should mirror the rule. + +## Cross-platform contract + +The TypeScript signal lives in `core-sdk` and is consumed directly by the universal, web, Node, and +React Native runtimes. The native iOS and Android SDKs do not link to TypeScript core, so they +implement the same contract independently. Every platform that exposes optimization-driven rendering +must surface a state with: + +- The same four states (`idle`, `pending`, `success`, `failed`). +- The same set of failure reasons (`timeout`, `api-error`, `aborted`). +- The "stay terminal until the next request" rule. +- The "publish `success` in the same batch as the output signals" rule. + +Implementations that diverge from this contract will produce inconsistent behavior across platforms +when the Experience API fails. The contract is the cross-platform agreement; the TypeScript signal +is one realization of it. From 2847cb62b4c91eda77bcc56225389f56db471357 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:14:02 +0200 Subject: [PATCH 4/6] refactor(react-web-sdk): clarify isContentReady expression and remove internal concept doc --- .../concepts/experience-request-state.md | 130 ------------------ .../src/optimized-entry/useOptimizedEntry.ts | 5 +- 2 files changed, 2 insertions(+), 133 deletions(-) delete mode 100644 documentation/concepts/experience-request-state.md diff --git a/documentation/concepts/experience-request-state.md b/documentation/concepts/experience-request-state.md deleted file mode 100644 index 6dc7a4b1..00000000 --- a/documentation/concepts/experience-request-state.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: Experience request state ---- - -# Experience request state - -Use this document to understand how the Optimization SDK Suite reports the outcome of the most -recent Experience API request, and why consumers should react to that outcome instead of waiting -indefinitely for optimization data. It explains the state machine, the transitions, the contract -each platform implementation must honor, and the cases this signal does and does not cover. - -For the broader signal catalog and how `states` is exposed, see -[Core state management](./core-state-management.md). For installation and setup, see -[Integrating the React web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md). - -
- Table of Contents - - -- [Why this signal exists](#why-this-signal-exists) -- [The state machine](#the-state-machine) -- [Transition rules](#transition-rules) -- [What this signal covers](#what-this-signal-covers) -- [What this signal does not cover](#what-this-signal-does-not-cover) -- [Consumer pattern](#consumer-pattern) -- [Cross-platform contract](#cross-platform-contract) - - -
- -## Why this signal exists - -The `OptimizedEntry` component, and any other consumer that gates rendering on optimization data, -needs to know when the Experience API request that should produce that data has succeeded or failed. -Without that signal, a consumer that is waiting for `selectedOptimizations` has no way to -distinguish "still loading" from "the request already failed and no more data is coming". A failed -request that is silently retained as `loading` results in regions of the page that never resolve to -either a variant or the baseline. - -`experienceRequestState` reports the outcome explicitly so consumers can fail-open to baseline -content when the API cannot resolve optimization data. - -## The state machine - -`experienceRequestState` is a writable signal exposed read-only on `CoreStateful.states`. Its value -is one of: - -| State | Meaning | -| ------------------------------------------- | -------------------------------------------------------------------------------------- | -| `{ status: 'idle' }` | No Experience API request has been attempted in this runtime. | -| `{ status: 'pending' }` | An Experience API request is in flight. | -| `{ status: 'success' }` | The most recent request returned data and the output signals have been updated. | -| `{ status: 'failed', reason: 'timeout' }` | The most recent request was aborted by the configured request timeout. | -| `{ status: 'failed', reason: 'api-error' }` | The most recent request returned a non-success HTTP status or an unparseable response. | -| `{ status: 'failed', reason: 'aborted' }` | Reserved for explicit-cancellation paths added in future work. | - -The `failed` state carries a `reason` so consumers and telemetry can distinguish network outages -from API errors. The set of reasons is closed; new reasons are additive on this signal rather than -introduced as separate signals. - -In the current TypeScript runtime, the api-client's retry layer rethrows aborted requests as a -generic `Error`, which loses the original `AbortError` identity. That means the queue cannot -distinguish a timeout from a non-success HTTP response and reports both as `failed:api-error`. The -`timeout` reason is retained in the public type so a future api-client change that preserves error -identity, or any platform that does not wrap fetch in the same retry layer, can emit it without a -breaking change. Treat the reason as a hint, not a guarantee, until the api-client preserves the -error name end-to-end. - -## Transition rules - -- The `ExperienceQueue` is the only writer. Consumers receive the value through the read-only - `Observable` exposed at `states.experienceRequestState`. -- A request transitions `pending` before the network call and either `success` after the output - signals (`changes`, `profile`, `selectedOptimizations`) have been written, or `failed` when the - underlying call rejects. -- `success` is published in the same reactive batch as the output signals. Consumers will never - observe `success` while `selectedOptimizations` is still stale. -- Terminal states (`success`, `failed`) persist until the next request begins. They are not reset to - `idle` between requests. Subscribers always see "the outcome of the last request." -- The next request flips the value to `pending` directly; there is no intermediate `idle` flicker. - -## What this signal covers - -- Adblockers or DNS errors that prevent the request from completing. -- The configured request timeout aborting an in-flight request. -- HTTP 4xx or 5xx responses from the Experience API. -- Response bodies that fail schema validation. - -In all of these cases the signal transitions to a `failed` state and consumers can render the -baseline immediately. - -## What this signal does not cover - -- The case where no Experience API request is ever attempted because the integrating application did - not call `page()`, `identify()`, `screen()`, or `track()`. In that case no flush occurs, the - signal stays `idle`, and `OptimizedEntry` would still wait for optimization data that never - arrives. Closing this gap requires a separate end-to-end resolution timer that transitions the - state to `failed` after a configurable interval when no request has been observed. That timer is - intentionally not part of this signal — it composes on top of it. -- Per-request retry semantics. Retries are owned by the queue's flush policy. The signal reports the - outcome of each completed network call. - -## Consumer pattern - -Consumers subscribe to `experienceRequestState` alongside `canOptimize` and `optimizationPossible`, -and treat any `failed` state as a third escape hatch from "still loading": - -```ts -isContentReady = canOptimize || !optimizationPossible || experienceRequestFailed -``` - -In the React web SDK, `useOptimizedEntry` applies this rule, so `OptimizedEntry` renders the -baseline content without any consumer changes when the Experience API fails. Other consumers that -gate rendering or branching on optimization data should mirror the rule. - -## Cross-platform contract - -The TypeScript signal lives in `core-sdk` and is consumed directly by the universal, web, Node, and -React Native runtimes. The native iOS and Android SDKs do not link to TypeScript core, so they -implement the same contract independently. Every platform that exposes optimization-driven rendering -must surface a state with: - -- The same four states (`idle`, `pending`, `success`, `failed`). -- The same set of failure reasons (`timeout`, `api-error`, `aborted`). -- The "stay terminal until the next request" rule. -- The "publish `success` in the same batch as the output signals" rule. - -Implementations that diverge from this contract will produce inconsistent behavior across platforms -when the Experience API fails. The contract is the cross-platform agreement; the TypeScript signal -is one realization of it. 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 b8a6cf8d..3771755e 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 @@ -100,9 +100,8 @@ export function useOptimizedEntry({ ) const requiresOptimization = hasOptimizationReferences(baselineEntry) - const isContentReady = requiresOptimization - ? canOptimize || !optimizationPossible || experienceRequestFailed - : true + const optimizationResolved = canOptimize || !optimizationPossible || experienceRequestFailed + const isContentReady = !requiresOptimization || optimizationResolved return { canOptimize, From 7af8b73052d55f2f1fb484f5c037b614aa49b6dd Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:58:52 +0200 Subject: [PATCH 5/6] feat: reframe conditional for experience request state --- .../src/optimized-entry/useOptimizedEntry.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 3771755e..c8727a3b 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 @@ -32,7 +32,7 @@ export function useOptimizedEntry({ >(undefined) const [canOptimize, setCanOptimize] = useState(false) const [optimizationPossible, setOptimizationPossible] = useState(true) - const [experienceRequestFailed, setExperienceRequestFailed] = useState(false) + const [experienceRequestPending, setExperienceRequestPending] = useState(false) const [sdkInitialized, setSdkInitialized] = useState(false) const shouldLiveUpdate = resolveShouldLiveUpdate({ @@ -45,7 +45,7 @@ export function useOptimizedEntry({ if (!sdk || !isReady) { setCanOptimize(false) setOptimizationPossible(true) - setExperienceRequestFailed(false) + setExperienceRequestPending(false) return } @@ -75,7 +75,7 @@ export function useOptimizedEntry({ const experienceRequestStateSubscription = sdk.states.experienceRequestState.subscribe( (state: ExperienceRequestState) => { - setExperienceRequestFailed(state.status === 'failed') + setExperienceRequestPending(state.status === 'pending') }, ) @@ -100,8 +100,7 @@ export function useOptimizedEntry({ ) const requiresOptimization = hasOptimizationReferences(baselineEntry) - const optimizationResolved = canOptimize || !optimizationPossible || experienceRequestFailed - const isContentReady = !requiresOptimization || optimizationResolved + const isContentReady = !requiresOptimization || !optimizationPossible || !experienceRequestPending return { canOptimize, From 3a17242c4ad13761404be838dcb462f3aaa94809 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:36:11 +0200 Subject: [PATCH 6/6] feat(react-web-sdk): integrate experienceRequestState into OptimizedEntry and tests This update introduces the `experienceRequestState` signal into the `OptimizedEntry` component and its associated tests. The state transitions are now handled to reflect loading, success, and failure scenarios, ensuring that the component behaves correctly based on the API request outcomes. Additionally, the tests have been updated to utilize the new `setExperienceRequestState` helper for better control over the experience request lifecycle. --- .../universal/core-sdk/src/CoreStateful.ts | 1 + .../core-sdk/src/queues/ExperienceQueue.ts | 3 + .../optimized-entry/OptimizedEntry.test.tsx | 58 +++++++++++++------ .../useOptimizedEntry.test.tsx | 4 +- .../src/optimized-entry/useOptimizedEntry.ts | 9 +-- .../react-web-sdk/src/test/sdkTestUtils.tsx | 8 +++ 6 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 9ab22b15..1973ca4d 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -397,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/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts index 4949937b..db20aff7 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -244,6 +244,9 @@ export class ExperienceQueue { 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 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 c8727a3b..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 @@ -31,7 +31,6 @@ 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) @@ -44,7 +43,6 @@ export function useOptimizedEntry({ useEffect(() => { if (!sdk || !isReady) { setCanOptimize(false) - setOptimizationPossible(true) setExperienceRequestPending(false) return } @@ -69,10 +67,6 @@ 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') @@ -82,7 +76,6 @@ export function useOptimizedEntry({ return () => { selectedOptimizationsSubscription.unsubscribe() canOptimizeSubscription.unsubscribe() - optimizationPossibleSubscription.unsubscribe() experienceRequestStateSubscription.unsubscribe() } }, [isReady, sdk, shouldLiveUpdate]) @@ -100,7 +93,7 @@ export function useOptimizedEntry({ ) const requiresOptimization = hasOptimizationReferences(baselineEntry) - const isContentReady = !requiresOptimization || !optimizationPossible || !experienceRequestPending + 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 637f472f..a5ed845e 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -245,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() @@ -254,6 +257,11 @@ export function createRuntime( selectedOptimizationSubscribers.forEach((subscriber) => { subscriber(value) }) + if (canOptimize) { + experienceRequestStateSubscribers.forEach((subscriber) => { + subscriber({ status: 'success' }) + }) + } }) }