From efc93ed8b82c5c45b9d3b3a8f90977fa15e4861a Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Thu, 12 Mar 2026 12:25:23 +0100 Subject: [PATCH 1/8] feat: support ReactNode or render-prop for children in Personalization --- .../src/personalization/Personalization.tsx | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index 3b420cab..7f106633 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -10,6 +10,7 @@ import { useOptimization } from '../hooks/useOptimization' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' +export type PersonalizationRenderProp = (resolvedEntry: Entry) => ReactNode /** * Props for the {@link Personalization} component. @@ -24,9 +25,14 @@ export interface PersonalizationProps { baselineEntry: Entry /** - * Render prop that receives the resolved variant entry. + * Consumer content rendered inside the wrapper. + * + * @remarks + * Supports either: + * - render-prop form: `(resolvedEntry) => ReactNode` + * - direct node form: `ReactNode` */ - children: (resolvedEntry: Entry) => ReactNode + children: ReactNode | PersonalizationRenderProp /** * Whether this component should react to personalization state changes in real-time. @@ -37,6 +43,9 @@ export interface PersonalizationProps { /** * Wrapper element used to mount tracking attributes. * Defaults to `div`. + * + * @remarks + * Wrapper uses `display: contents` to be as layout-neutral as possible. */ as?: PersonalizationWrapperElement @@ -52,6 +61,10 @@ export interface PersonalizationProps { /** * Optional fallback rendered while personalization state is unresolved. + * + * @remarks + * TODO(spec-028): Align loading lifecycle semantics for explicit SPA vs + * hybrid SSR+SPA contracts. */ loadingFallback?: PersonalizationLoadingFallback } @@ -63,7 +76,33 @@ function resolveLoadingFallback( return loadingFallback } +function isPersonalizationRenderProp( + children: PersonalizationProps['children'], +): children is PersonalizationRenderProp { + return typeof children === 'function' +} + +function resolveChildren( + children: PersonalizationProps['children'], + entry: Entry, +): ReactNode { + if (!isPersonalizationRenderProp(children)) return children + + return children(entry) +} + const WRAPPER_STYLE = Object.freeze({ display: 'contents' as const }) +const DEFAULT_LOADING_FALLBACK = ( + + Loading... + +) + +function hasPersonalizationReferences(entry: Entry): boolean { + const ntExperiences = entry.fields.nt_experiences + if (!Array.isArray(ntExperiences)) return false + return ntExperiences.length > 0 +} function resolveDuplicationScope( personalization: SelectedPersonalization | undefined, @@ -110,6 +149,8 @@ export function Personalization({ 'data-testid': dataTestIdProp, loadingFallback, }: PersonalizationProps): JSX.Element { + // TODO(spec-028): Add same-baseline nesting guard. Nested wrappers with the same + // baseline entry ID as an ancestor should be blocked by runtime safety checks. const optimization = useOptimization() const liveUpdatesContext = useLiveUpdates() @@ -156,15 +197,18 @@ export function Personalization({ [optimization, baselineEntry, lockedPersonalizations], ) - const isLoading = !canPersonalize - const showLoadingFallback = loadingFallback !== undefined && isLoading + // TODO(spec-028): Extend to explicit hybrid SSR+SPA lifecycle mode behavior. + const requiresPersonalization = hasPersonalizationReferences(baselineEntry) + const isLoading = requiresPersonalization && !canPersonalize + const showLoadingFallback = isLoading + const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK const dataTestId = dataTestIdProp ?? testId const Wrapper = as if (showLoadingFallback) { return ( - {resolveLoadingFallback(loadingFallback)} + {resolvedLoadingFallback} ) } @@ -173,7 +217,7 @@ export function Personalization({ return ( - {children(resolvedData.entry)} + {resolveChildren(children, resolvedData.entry)} ) } From 6e9fa2a9551e94763d14ee372a9c08e6f6922b47 Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Thu, 12 Mar 2026 13:58:39 +0100 Subject: [PATCH 2/8] feat: document Personalization wrapper options and nesting guard - Update README with render-prop, wrapper element, and layout-neutral behavior details. - Add nesting guard documentation and implementation to block nested Personalization with duplicate baseline entry IDs. - Add lifecycle and nesting tests for Personalization component. - Refine loading fallback and baseline personalizable logic. --- .../web/frameworks/react-web-sdk/README.md | 19 +- .../Personalization.lifecycle.test.tsx | 313 ++++++++++++++++++ .../personalization/Personalization.test.tsx | 12 +- .../src/personalization/Personalization.tsx | 107 ++++-- 4 files changed, 421 insertions(+), 30 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 6aa6287d..e81d7dd9 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -112,10 +112,13 @@ import { Personalization } from '@contentful/optimization-react-web' - `liveUpdates={true}` enables continuous updates as personalization state changes. - If `liveUpdates` is omitted, global root `liveUpdates` is used. - If both are omitted, live updates default to `false`. +- Consumer content supports render-prop (`(resolvedEntry) => ReactNode`) or direct `ReactNode`. +- Wrapper element is configurable with `as: 'div' | 'span'` (defaults to `div`). +- Wrapper style uses `display: contents` to remain layout-neutral as much as possible. #### Loading Fallback -When `loadingFallback` is provided, it is rendered until personalization state is first resolved. +When `loadingFallback` is provided, it is rendered while readiness is unresolved. ```tsx ``` -If `loadingFallback` is not provided, rendering follows the regular baseline/resolved path. +- If a baseline entry is personalizable and unresolved, loading UI is rendered by default. +- If the entry is not personalizable, baseline/resolved content is rendered directly. #### Nested Composition @@ -144,6 +148,11 @@ Nested personalizations are supported by explicit composition: ``` +Nesting guard behavior: + +- Nested wrappers with the same baseline entry ID as an ancestor are invalid and are blocked. +- Nested wrappers with different baseline entry IDs remain supported. + #### Auto-Tracking Data Attributes When resolved content is rendered, the wrapper emits attributes used by @@ -179,6 +188,12 @@ This gives: - then root-level `liveUpdates` - then default `false` +### SDK Initialization Contract + +- Core/Web SDK initialization is synchronous; no dedicated `sdkInitialized` state is exposed. +- React provider initialization outcome is represented by instance creation success/failure. +- The async runtime path is preview panel lifecycle, already represented by preview panel state. + ## Singleton Behavior The underlying `@contentful/optimization-web` SDK enforces a singleton pattern. Only one diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx new file mode 100644 index 00000000..3c497b07 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -0,0 +1,313 @@ +import type { SelectedPersonalizationArray } from '@contentful/optimization-web/api-schemas' +import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { Entry, EntrySkeletonType } from 'contentful' +import { act, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext' +import { LiveUpdatesContext } from '../context/LiveUpdatesContext' +import { OptimizationContext } from '../context/OptimizationContext' +import { Personalization } from './Personalization' + +type TestEntry = Entry +type PersonalizationState = SelectedPersonalizationArray | undefined +type PersonalizeEntry = ( + entry: TestEntry, + personalizations: PersonalizationState, +) => ResolvedData +type PersonalizationsSubscriber = (value: PersonalizationState) => void +type CanPersonalizeSubscriber = (value: boolean) => void + +interface RuntimeOptimization { + personalizeEntry: PersonalizeEntry + states: { + canPersonalize: { + subscribe: (next: CanPersonalizeSubscriber) => { unsubscribe: () => void } + } + personalizations: { + subscribe: (next: PersonalizationsSubscriber) => { unsubscribe: () => void } + } + } +} + +function makeEntry(id: string): TestEntry { + const entry: TestEntry = { + fields: { title: id }, + metadata: { tags: [] }, + sys: { + contentType: { sys: { id: 'test-content-type', linkType: 'ContentType', type: 'Link' } }, + createdAt: '2024-01-01T00:00:00.000Z', + environment: { sys: { id: 'main', linkType: 'Environment', type: 'Link' } }, + id, + publishedVersion: 1, + revision: 1, + space: { sys: { id: 'space-id', linkType: 'Space', type: 'Link' } }, + type: 'Entry', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + } + + return entry +} + +function makePersonalizableEntry(id: string): TestEntry { + const entry = makeEntry(id) + entry.fields = { + ...entry.fields, + nt_experiences: [{ sys: { id: 'exp-1' } }], + } + return entry +} + +function createRuntime(personalizeEntry: PersonalizeEntry): { + emit: (value: PersonalizationState) => Promise + optimization: RuntimeOptimization +} { + const subscribers = new Set() + const canPersonalizeSubscribers = new Set() + let current: PersonalizationState = undefined + let canPersonalize = false + + const optimization: RuntimeOptimization = { + personalizeEntry, + states: { + canPersonalize: { + subscribe(next: CanPersonalizeSubscriber) { + canPersonalizeSubscribers.add(next) + next(canPersonalize) + + return { + unsubscribe() { + canPersonalizeSubscribers.delete(next) + }, + } + }, + }, + personalizations: { + subscribe(next: PersonalizationsSubscriber) { + subscribers.add(next) + next(current) + + return { + unsubscribe() { + subscribers.delete(next) + }, + } + }, + }, + }, + } + + async function emit(value: PersonalizationState): Promise { + current = value + canPersonalize = value !== undefined + + await act(async () => { + await Promise.resolve() + canPersonalizeSubscribers.forEach((subscriber) => { + subscriber(canPersonalize) + }) + subscribers.forEach((subscriber) => { + subscriber(value) + }) + }) + } + + return { emit, optimization } +} + +function defaultLiveUpdatesContext(): LiveUpdatesContextValue { + return { + globalLiveUpdates: false, + previewPanelVisible: false, + setPreviewPanelVisible() { + return undefined + }, + } +} + +async function renderComponent( + node: ReactNode, + optimization: RuntimeOptimization, + liveUpdatesContext = defaultLiveUpdatesContext(), +): Promise<{ container: HTMLDivElement; unmount: () => Promise }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + await act(async () => { + await Promise.resolve() + root.render( + // @ts-expect-error test double only implements the subset used by Personalization + + {node} + , + ) + }) + + return { + container, + async unmount() { + await act(async () => { + await Promise.resolve() + root.unmount() + }) + container.remove() + }, + } +} + +function getWrapper(container: HTMLElement): HTMLElement { + const { firstElementChild: wrapper } = container + + if (!(wrapper instanceof HTMLElement)) { + throw new TypeError('Expected first child to be an HTMLElement') + } + + return wrapper +} + +function readTitle(entry: TestEntry): string { + const { + fields: { title }, + } = entry + return typeof title === 'string' ? title : '' +} + +describe('Personalization lifecycle and nesting guard', () => { + const baseline = makeEntry('baseline') + const personalizedBaseline = makePersonalizableEntry('personalized-baseline') + const variantA = makeEntry('variant-a') + + const variantOneState: SelectedPersonalizationArray = [ + { + experienceId: 'exp-hero', + sticky: true, + variantIndex: 1, + variants: { + baseline: 'variant-a', + }, + }, + ] + + void afterEach(() => { + document.body.innerHTML = '' + rs.restoreAllMocks() + }) + + it('renders plain ReactNode children without requiring render-prop usage', async () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const view = await renderComponent( + +
static-child
+
, + optimization, + ) + + const wrapper = getWrapper(view.container) + expect(wrapper.tagName).toBe('DIV') + expect(wrapper.style.display).toBe('contents') + + const staticNode = view.container.querySelector('[data-testid="static-node"]') + expect(staticNode?.textContent).toBe('static-child') + + await view.unmount() + }) + + it('does not render entry content initially in SPA mode', async () => { + const { optimization } = createRuntime((entry, personalizations) => { + if (!personalizations?.length) return { entry } + return { entry: variantA, personalization: personalizations[0] } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('Loading...') + expect(view.container.textContent).not.toContain('personalized-baseline') + + const loadingWrapper = getWrapper(view.container) + expect(loadingWrapper.dataset.ctflEntryId).toBeUndefined() + + await view.unmount() + }) + + it('renders loading until canPersonalize is true for personalized flow', async () => { + const { optimization, emit } = createRuntime((entry, personalizations) => { + if (!personalizations?.length) return { entry } + return { entry: variantA, personalization: personalizations[0] } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('Loading...') + expect(view.container.textContent).not.toContain('variant-a') + + await emit(variantOneState) + + expect(view.container.textContent).toContain('variant-a') + expect(view.container.textContent).not.toContain('Loading...') + + await view.unmount() + }) + + it('prevents nested Personalization with same baseline entry id', async () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const view = await renderComponent( + + {(parentResolved) => ( +
+

{readTitle(parentResolved)}

+ + {(childResolved) =>

{readTitle(childResolved)}

} +
+
+ )} +
, + optimization, + ) + + expect(view.container.textContent).toContain('baseline') + expect(view.container.querySelector('[data-testid="nested-same-id"]')).toBeNull() + + await view.unmount() + }) + + it('supports consumer wrapper element selection with div default', async () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const defaultView = await renderComponent( + default-wrapper, + optimization, + ) + const defaultWrapper = getWrapper(defaultView.container) + expect(defaultWrapper.tagName).toBe('DIV') + expect(defaultWrapper.style.display).toBe('contents') + await defaultView.unmount() + + const spanView = await renderComponent( + + span-wrapper + , + optimization, + ) + const spanWrapper = getWrapper(spanView.container) + expect(spanWrapper.tagName).toBe('SPAN') + expect(spanWrapper.style.display).toBe('contents') + await spanView.unmount() + }) + + it.skip('retains loading layout-target behavior when display:contents visibility is unsupported', () => { + // TODO: Validate hybrid SSR+SPA loading layout constraints. + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx index c696d983..98e62deb 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx @@ -49,6 +49,15 @@ function makeEntry(id: string): TestEntry { return entry } +function makePersonalizableEntry(id: string): TestEntry { + const entry = makeEntry(id) + entry.fields = { + ...entry.fields, + nt_experiences: [{ sys: { id: 'exp-1' } }], + } + return entry +} + function createRuntime(personalizeEntry: PersonalizeEntry): { emit: (value: PersonalizationState) => Promise optimization: RuntimeOptimization @@ -166,6 +175,7 @@ function readTitle(entry: TestEntry): string { describe('Personalization', () => { const baseline = makeEntry('baseline') + const personalizedBaseline = makePersonalizableEntry('personalized-baseline') const variantA = makeEntry('variant-a') const variantB = makeEntry('variant-b') @@ -278,7 +288,7 @@ describe('Personalization', () => { }) const view = await renderComponent( - 'loading'}> + 'loading'}> {(resolved) => readTitle(resolved)} , optimization, diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index 7f106633..4e0c0ab8 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -4,9 +4,19 @@ import type { } from '@contentful/optimization-web/api-schemas' import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' -import { useEffect, useMemo, useState, type JSX, type ReactNode } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type JSX, + type ReactNode, +} from 'react' import { useLiveUpdates } from '../hooks/useLiveUpdates' import { useOptimization } from '../hooks/useOptimization' +import { createScopedLogger } from '../logger' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' @@ -61,10 +71,6 @@ export interface PersonalizationProps { /** * Optional fallback rendered while personalization state is unresolved. - * - * @remarks - * TODO(spec-028): Align loading lifecycle semantics for explicit SPA vs - * hybrid SSR+SPA contracts. */ loadingFallback?: PersonalizationLoadingFallback } @@ -72,7 +78,9 @@ export interface PersonalizationProps { function resolveLoadingFallback( loadingFallback: PersonalizationLoadingFallback | undefined, ): ReactNode { - if (typeof loadingFallback === 'function') return loadingFallback() + if (typeof loadingFallback === 'function') { + return loadingFallback() + } return loadingFallback } @@ -82,11 +90,10 @@ function isPersonalizationRenderProp( return typeof children === 'function' } -function resolveChildren( - children: PersonalizationProps['children'], - entry: Entry, -): ReactNode { - if (!isPersonalizationRenderProp(children)) return children +function resolveChildren(children: PersonalizationProps['children'], entry: Entry): ReactNode { + if (!isPersonalizationRenderProp(children)) { + return children + } return children(entry) } @@ -97,10 +104,15 @@ const DEFAULT_LOADING_FALLBACK = ( Loading... ) +const PersonalizationNestingContext = createContext | null>(null) +const logger = createScopedLogger('React:Personalization') function hasPersonalizationReferences(entry: Entry): boolean { - const ntExperiences = entry.fields.nt_experiences - if (!Array.isArray(ntExperiences)) return false + const { fields } = entry + const { nt_experiences: ntExperiences } = fields + if (!Array.isArray(ntExperiences)) { + return false + } return ntExperiences.length > 0 } @@ -111,7 +123,9 @@ function resolveDuplicationScope( personalization && typeof personalization === 'object' && 'duplicationScope' in personalization ? personalization.duplicationScope : undefined - if (typeof candidate !== 'string') return undefined + if (typeof candidate !== 'string') { + return undefined + } return candidate.trim() ? candidate : undefined } @@ -121,18 +135,25 @@ function resolveShouldLiveUpdate(params: { globalLiveUpdates: boolean }): boolean { const { previewPanelVisible, componentLiveUpdates, globalLiveUpdates } = params - if (previewPanelVisible) return true + if (previewPanelVisible) { + return true + } return componentLiveUpdates ?? globalLiveUpdates } function resolveTrackingAttributes( resolvedData: ResolvedData, ): Record { - const { personalization } = resolvedData + const { + personalization, + entry: { + sys: { id: entryId }, + }, + } = resolvedData return { 'data-ctfl-duplication-scope': resolveDuplicationScope(personalization), - 'data-ctfl-entry-id': resolvedData.entry.sys.id, + 'data-ctfl-entry-id': entryId, 'data-ctfl-personalization-id': personalization?.experienceId, 'data-ctfl-sticky': personalization?.sticky === undefined ? undefined : String(personalization.sticky), @@ -149,10 +170,38 @@ export function Personalization({ 'data-testid': dataTestIdProp, loadingFallback, }: PersonalizationProps): JSX.Element { - // TODO(spec-028): Add same-baseline nesting guard. Nested wrappers with the same - // baseline entry ID as an ancestor should be blocked by runtime safety checks. const optimization = useOptimization() const liveUpdatesContext = useLiveUpdates() + const ancestorBaselineIds = useContext(PersonalizationNestingContext) + const warnedDuplicateBaselineId = useRef(false) + const { + sys: { id: baselineEntryId }, + } = baselineEntry + const hasDuplicateBaselineAncestor = ancestorBaselineIds?.has(baselineEntryId) ?? false + + useEffect(() => { + if (!hasDuplicateBaselineAncestor || warnedDuplicateBaselineId.current) { + return + } + + if (process.env.NODE_ENV !== 'production') { + logger.warn( + `[Personalization] Nested Personalization with baseline entry ID "${baselineEntryId}" is blocked.`, + ) + } + + warnedDuplicateBaselineId.current = true + }, [baselineEntryId, hasDuplicateBaselineAncestor]) + + const currentAndAncestorBaselineIds = useMemo(() => { + const nextIds = new Set(ancestorBaselineIds ?? []) + nextIds.add(baselineEntryId) + return nextIds + }, [ancestorBaselineIds, baselineEntryId]) + + if (hasDuplicateBaselineAncestor) { + return <> + } const shouldLiveUpdate = resolveShouldLiveUpdate({ componentLiveUpdates: liveUpdates, @@ -197,28 +246,32 @@ export function Personalization({ [optimization, baselineEntry, lockedPersonalizations], ) - // TODO(spec-028): Extend to explicit hybrid SSR+SPA lifecycle mode behavior. const requiresPersonalization = hasPersonalizationReferences(baselineEntry) const isLoading = requiresPersonalization && !canPersonalize const showLoadingFallback = isLoading - const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK + const resolvedLoadingFallback = + resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK const dataTestId = dataTestIdProp ?? testId const Wrapper = as if (showLoadingFallback) { return ( - - {resolvedLoadingFallback} - + + + {resolvedLoadingFallback} + + ) } const trackingAttributes = resolveTrackingAttributes(resolvedData) return ( - - {resolveChildren(children, resolvedData.entry)} - + + + {resolveChildren(children, resolvedData.entry)} + + ) } From 2377f7f6e7670ca837d17f0b0aa7c96416cfa967 Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Thu, 12 Mar 2026 14:11:49 +0100 Subject: [PATCH 3/8] feat: Add loading layout-target element for fallback UI Render a concrete element with `data-ctfl-loading-layout-target` during loading, ensuring layout and visibility remain targetable even when using `display: contents`. Adds tests for block and inline wrapper cases. --- .../web/frameworks/react-web-sdk/README.md | 3 ++ .../Personalization.lifecycle.test.tsx | 45 ++++++++++++++++++- .../src/personalization/Personalization.tsx | 26 ++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index e81d7dd9..7b9b5d8d 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -131,6 +131,9 @@ When `loadingFallback` is provided, it is rendered while readiness is unresolved - If a baseline entry is personalizable and unresolved, loading UI is rendered by default. - If the entry is not personalizable, baseline/resolved content is rendered directly. +- During loading, a concrete layout-target element is rendered (`data-ctfl-loading-layout-target`) + so loading visibility/layout behavior remains targetable even when wrapper uses + `display: contents`. #### Nested Composition diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx index 3c497b07..567d94f0 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -166,6 +166,16 @@ function getWrapper(container: HTMLElement): HTMLElement { return wrapper } +function getRequiredElement(container: HTMLElement, selector: string): HTMLElement { + const target = container.querySelector(selector) + + if (!(target instanceof HTMLElement)) { + throw new TypeError(`Expected selector "${selector}" to resolve to an HTMLElement`) + } + + return target +} + function readTitle(entry: TestEntry): string { const { fields: { title }, @@ -307,7 +317,38 @@ describe('Personalization lifecycle and nesting guard', () => { await spanView.unmount() }) - it.skip('retains loading layout-target behavior when display:contents visibility is unsupported', () => { - // TODO: Validate hybrid SSR+SPA loading layout constraints. + it('retains loading layout-target behavior when display:contents visibility is unsupported', async () => { + const { optimization } = createRuntime((entry, personalizations) => { + if (!personalizations?.length) return { entry } + return { entry: variantA, personalization: personalizations[0] } + }) + + const divView = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + const divLoadingTarget = getRequiredElement( + divView.container, + '[data-ctfl-loading-layout-target]', + ) + expect(divLoadingTarget.tagName).toBe('DIV') + expect(divLoadingTarget.style.display).toBe('block') + await divView.unmount() + + const spanView = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + const spanLoadingTarget = getRequiredElement( + spanView.container, + '[data-ctfl-loading-layout-target]', + ) + expect(spanLoadingTarget.tagName).toBe('SPAN') + expect(spanLoadingTarget.style.display).toBe('inline') + await spanView.unmount() }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index 4e0c0ab8..4142b382 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -104,6 +104,12 @@ const DEFAULT_LOADING_FALLBACK = ( Loading... ) +const LOADING_LAYOUT_TARGET_STYLE = Object.freeze({ + display: 'block' as const, +}) +const LOADING_LAYOUT_TARGET_STYLE_INLINE = Object.freeze({ + display: 'inline' as const, +}) const PersonalizationNestingContext = createContext | null>(null) const logger = createScopedLogger('React:Personalization') @@ -161,6 +167,16 @@ function resolveTrackingAttributes( } } +function resolveLoadingLayoutTargetStyle( + wrapperElement: PersonalizationWrapperElement, +): typeof LOADING_LAYOUT_TARGET_STYLE | typeof LOADING_LAYOUT_TARGET_STYLE_INLINE { + if (wrapperElement === 'span') { + return LOADING_LAYOUT_TARGET_STYLE_INLINE + } + + return LOADING_LAYOUT_TARGET_STYLE +} + export function Personalization({ baselineEntry, children, @@ -255,10 +271,18 @@ export function Personalization({ const Wrapper = as if (showLoadingFallback) { + const LoadingLayoutTarget = Wrapper + const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(as) + return ( - {resolvedLoadingFallback} + + {resolvedLoadingFallback} + ) From e18ce5c406ae0a4e3f92c05e1ccf54f7db2f8ac0 Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Thu, 12 Mar 2026 14:16:22 +0100 Subject: [PATCH 4/8] feat: Add tests to ensure event handlers do not interfere with clicks or hovers --- .../click/createEntryClickDetector.test.ts | 21 +++++++++++++++++ .../hover/createEntryHoverDetector.test.ts | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts index d7c7062d..b8b54801 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts @@ -360,4 +360,25 @@ describe('EntryClickTracker', () => { cleanup() }) + + it('does not call preventDefault or stopPropagation while observing clicks', () => { + const entry = document.createElement('button') + entry.dataset.ctflEntryId = 'entry-non-interference' + document.body.append(entry) + + const preventDefaultSpy = rs.spyOn(Event.prototype, 'preventDefault') + const stopPropagationSpy = rs.spyOn(Event.prototype, 'stopPropagation') + + const { core, trackComponentClick } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryClickDetector(core)) + + tracker.start() + entry.click() + + expect(trackComponentClick).toHaveBeenCalledTimes(1) + expect(preventDefaultSpy).not.toHaveBeenCalled() + expect(stopPropagationSpy).not.toHaveBeenCalled() + + cleanup() + }) }) diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts index f4a696b8..2de7f124 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts @@ -297,4 +297,27 @@ describe('EntryHoverTracker', () => { cleanup() }) + + it('does not call preventDefault or stopPropagation while observing hover events', async () => { + const entry = document.createElement('div') + entry.dataset.ctflEntryId = 'entry-hover-non-interference' + document.body.append(entry) + + const preventDefaultSpy = rs.spyOn(Event.prototype, 'preventDefault') + const stopPropagationSpy = rs.spyOn(Event.prototype, 'stopPropagation') + + const { core, trackComponentHover } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryHoverDetector(core)) + + tracker.start({ dwellTimeMs: 0 }) + + dispatchHoverEnter(entry) + await advance(0) + + expect(trackComponentHover).toHaveBeenCalledTimes(1) + expect(preventDefaultSpy).not.toHaveBeenCalled() + expect(stopPropagationSpy).not.toHaveBeenCalled() + + cleanup() + }) }) From f392524de736e7f2cfb2451fffadf19ceb7bc9d8 Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Thu, 12 Mar 2026 14:58:37 +0100 Subject: [PATCH 5/8] feat: Add hybrid SSR lifecycle mode to Personalization component - Introduce `lifecycleMode` prop for SPA and hybrid SSR-SPA rendering - Render invisible loading target during SSR in hybrid mode - Block nested wrappers with duplicate baseline entry IDs at runtime - Update docs and tests for new lifecycle behavior --- .../web/frameworks/react-web-sdk/README.md | 13 ++ .../Personalization.lifecycle.test.tsx | 46 +++++++ .../src/personalization/Personalization.tsx | 125 ++++++++++++++---- packages/web/web-sdk/README.md | 5 + 4 files changed, 163 insertions(+), 26 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 7b9b5d8d..7748e3cf 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -115,6 +115,8 @@ import { Personalization } from '@contentful/optimization-react-web' - Consumer content supports render-prop (`(resolvedEntry) => ReactNode`) or direct `ReactNode`. - Wrapper element is configurable with `as: 'div' | 'span'` (defaults to `div`). - Wrapper style uses `display: contents` to remain layout-neutral as much as possible. +- Lifecycle mode is configurable with `lifecycleMode: 'spa' | 'hybrid-ssr-spa'` (defaults to + `'spa'`). #### Loading Fallback @@ -134,6 +136,8 @@ When `loadingFallback` is provided, it is rendered while readiness is unresolved - During loading, a concrete layout-target element is rendered (`data-ctfl-loading-layout-target`) so loading visibility/layout behavior remains targetable even when wrapper uses `display: contents`. +- In `hybrid-ssr-spa` mode, unresolved loading is rendered invisibly (`visibility: hidden`) to + preserve layout space before content is ready. #### Nested Composition @@ -197,6 +201,15 @@ This gives: - React provider initialization outcome is represented by instance creation success/failure. - The async runtime path is preview panel lifecycle, already represented by preview panel state. +### Migration Notes + +- `Personalization` now accepts either render-prop children or direct `ReactNode` children. +- Personalizable entries now render loading UI until personalization readiness is available. +- When no `loadingFallback` is provided, a default loading UI is rendered for unresolved + personalizable entries. +- Nested wrappers with the same baseline entry ID are now blocked at runtime. +- Loading renders include `data-ctfl-loading-layout-target` for layout/visibility targeting. + ## Singleton Behavior The underlying `@contentful/optimization-web` SDK enforces a singleton pattern. Only one diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx index 567d94f0..a83b1a53 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -3,6 +3,7 @@ import type { ResolvedData } from '@contentful/optimization-web/core-sdk' import type { Entry, EntrySkeletonType } from 'contentful' import { act, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' @@ -156,6 +157,19 @@ async function renderComponent( } } +function renderComponentToString( + node: ReactNode, + optimization: RuntimeOptimization, + liveUpdatesContext = defaultLiveUpdatesContext(), +): string { + return renderToString( + // @ts-expect-error test double only implements the subset used by Personalization + + {node} + , + ) +} + function getWrapper(container: HTMLElement): HTMLElement { const { firstElementChild: wrapper } = container @@ -351,4 +365,36 @@ describe('Personalization lifecycle and nesting guard', () => { expect(spanLoadingTarget.style.display).toBe('inline') await spanView.unmount() }) + + it('renders invisible loading target on initial hybrid SSR pass for non-personalized entries', () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const markup = renderComponentToString( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(markup).toContain('data-ctfl-loading-layout-target="true"') + expect(markup).toContain('visibility:hidden') + expect(markup).toContain('Loading...') + expect(markup).not.toContain('baseline') + }) + + it('renders non-personalized content after sdk initialization in hybrid mode', async () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('baseline') + expect(view.container.textContent).not.toContain('Loading...') + + await view.unmount() + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index 4142b382..c1b2fea4 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -21,6 +21,7 @@ import { createScopedLogger } from '../logger' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' export type PersonalizationRenderProp = (resolvedEntry: Entry) => ReactNode +export type PersonalizationLifecycleMode = 'spa' | 'hybrid-ssr-spa' /** * Props for the {@link Personalization} component. @@ -73,6 +74,15 @@ export interface PersonalizationProps { * Optional fallback rendered while personalization state is unresolved. */ loadingFallback?: PersonalizationLoadingFallback + + /** + * Controls rendering lifecycle semantics. + * + * @remarks + * - `spa`: Non-personalized entries render immediately once entry data is present. + * - `hybrid-ssr-spa`: Non-personalized entries wait for client-side SDK initialization. + */ + lifecycleMode?: PersonalizationLifecycleMode } function resolveLoadingFallback( @@ -110,9 +120,48 @@ const LOADING_LAYOUT_TARGET_STYLE = Object.freeze({ const LOADING_LAYOUT_TARGET_STYLE_INLINE = Object.freeze({ display: 'inline' as const, }) +const LOADING_LAYOUT_TARGET_STYLE_HIDDEN = Object.freeze({ + display: 'block' as const, + visibility: 'hidden' as const, +}) +const LOADING_LAYOUT_TARGET_STYLE_INLINE_HIDDEN = Object.freeze({ + display: 'inline' as const, + visibility: 'hidden' as const, +}) const PersonalizationNestingContext = createContext | null>(null) const logger = createScopedLogger('React:Personalization') +function useDuplicateBaselineGuard(baselineEntryId: string): { + currentAndAncestorBaselineIds: ReadonlySet + hasDuplicateBaselineAncestor: boolean +} { + const ancestorBaselineIds = useContext(PersonalizationNestingContext) + const warnedDuplicateBaselineId = useRef(false) + const hasDuplicateBaselineAncestor = ancestorBaselineIds?.has(baselineEntryId) ?? false + + useEffect(() => { + if (!hasDuplicateBaselineAncestor || warnedDuplicateBaselineId.current) { + return + } + + if (process.env.NODE_ENV !== 'production') { + logger.warn( + `[Personalization] Nested Personalization with baseline entry ID "${baselineEntryId}" is blocked.`, + ) + } + + warnedDuplicateBaselineId.current = true + }, [baselineEntryId, hasDuplicateBaselineAncestor]) + + const currentAndAncestorBaselineIds = useMemo(() => { + const nextIds = new Set(ancestorBaselineIds ?? []) + nextIds.add(baselineEntryId) + return nextIds + }, [ancestorBaselineIds, baselineEntryId]) + + return { currentAndAncestorBaselineIds, hasDuplicateBaselineAncestor } +} + function hasPersonalizationReferences(entry: Entry): boolean { const { fields } = entry const { nt_experiences: ntExperiences } = fields @@ -169,7 +218,20 @@ function resolveTrackingAttributes( function resolveLoadingLayoutTargetStyle( wrapperElement: PersonalizationWrapperElement, -): typeof LOADING_LAYOUT_TARGET_STYLE | typeof LOADING_LAYOUT_TARGET_STYLE_INLINE { + isInvisible: boolean, +): + | typeof LOADING_LAYOUT_TARGET_STYLE + | typeof LOADING_LAYOUT_TARGET_STYLE_INLINE + | typeof LOADING_LAYOUT_TARGET_STYLE_HIDDEN + | typeof LOADING_LAYOUT_TARGET_STYLE_INLINE_HIDDEN { + if (isInvisible) { + if (wrapperElement === 'span') { + return LOADING_LAYOUT_TARGET_STYLE_INLINE_HIDDEN + } + + return LOADING_LAYOUT_TARGET_STYLE_HIDDEN + } + if (wrapperElement === 'span') { return LOADING_LAYOUT_TARGET_STYLE_INLINE } @@ -177,6 +239,25 @@ function resolveLoadingLayoutTargetStyle( return LOADING_LAYOUT_TARGET_STYLE } +function resolveContentReadyState(params: { + lifecycleMode: PersonalizationLifecycleMode + requiresPersonalization: boolean + canPersonalize: boolean + sdkInitialized: boolean +}): boolean { + const { lifecycleMode, requiresPersonalization, canPersonalize, sdkInitialized } = params + + if (requiresPersonalization) { + return canPersonalize + } + + if (lifecycleMode === 'hybrid-ssr-spa') { + return sdkInitialized + } + + return true +} + export function Personalization({ baselineEntry, children, @@ -185,35 +266,15 @@ export function Personalization({ testId, 'data-testid': dataTestIdProp, loadingFallback, + lifecycleMode = 'spa', }: PersonalizationProps): JSX.Element { const optimization = useOptimization() const liveUpdatesContext = useLiveUpdates() - const ancestorBaselineIds = useContext(PersonalizationNestingContext) - const warnedDuplicateBaselineId = useRef(false) const { sys: { id: baselineEntryId }, } = baselineEntry - const hasDuplicateBaselineAncestor = ancestorBaselineIds?.has(baselineEntryId) ?? false - - useEffect(() => { - if (!hasDuplicateBaselineAncestor || warnedDuplicateBaselineId.current) { - return - } - - if (process.env.NODE_ENV !== 'production') { - logger.warn( - `[Personalization] Nested Personalization with baseline entry ID "${baselineEntryId}" is blocked.`, - ) - } - - warnedDuplicateBaselineId.current = true - }, [baselineEntryId, hasDuplicateBaselineAncestor]) - - const currentAndAncestorBaselineIds = useMemo(() => { - const nextIds = new Set(ancestorBaselineIds ?? []) - nextIds.add(baselineEntryId) - return nextIds - }, [ancestorBaselineIds, baselineEntryId]) + const { currentAndAncestorBaselineIds, hasDuplicateBaselineAncestor } = + useDuplicateBaselineGuard(baselineEntryId) if (hasDuplicateBaselineAncestor) { return <> @@ -229,6 +290,7 @@ export function Personalization({ SelectedPersonalizationArray | undefined >(undefined) const [canPersonalize, setCanPersonalize] = useState(false) + const [sdkInitialized, setSdkInitialized] = useState(false) useEffect(() => { const personalizationsSubscription = optimization.states.personalizations.subscribe((p) => { @@ -257,22 +319,33 @@ export function Personalization({ } }, [optimization, shouldLiveUpdate]) + useEffect(() => { + setSdkInitialized(true) + }, []) + const resolvedData: ResolvedData = useMemo( () => optimization.personalizeEntry(baselineEntry, lockedPersonalizations), [optimization, baselineEntry, lockedPersonalizations], ) const requiresPersonalization = hasPersonalizationReferences(baselineEntry) - const isLoading = requiresPersonalization && !canPersonalize + const isContentReady = resolveContentReadyState({ + canPersonalize, + lifecycleMode, + requiresPersonalization, + sdkInitialized, + }) + const isLoading = !isContentReady const showLoadingFallback = isLoading const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK + const isInvisibleLoading = lifecycleMode === 'hybrid-ssr-spa' && isLoading const dataTestId = dataTestIdProp ?? testId const Wrapper = as if (showLoadingFallback) { const LoadingLayoutTarget = Wrapper - const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(as) + const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(as, isInvisibleLoading) return ( diff --git a/packages/web/web-sdk/README.md b/packages/web/web-sdk/README.md index 3abac3f0..5cbea533 100644 --- a/packages/web/web-sdk/README.md +++ b/packages/web/web-sdk/README.md @@ -719,6 +719,11 @@ Optimization Web SDK can automatically track observed entry elements for events views", "component hovers", and "component clicks", and it can also automatically observe elements that are marked as entry-related elements. +Interaction observers are passive with respect to host event flow: + +- They do not call `event.preventDefault()`. +- They do not call `event.stopPropagation()`. + ### Manual Entry View Tracking To manually track entry views using custom tracking code, simply call `trackComponentView` with the From 18dec99540e936b58f4141dcfc759bd7f295ff3c Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Fri, 13 Mar 2026 14:32:25 +0100 Subject: [PATCH 6/8] fix: remove lifecycleMode prop and update SSR loading behavior - Baseline content now renders invisibly during SSR or first hydration frame. - Non-personalized entries are no longer gated behind sdkInitialized. - Tests and documentation updated to reflect new readiness logic. --- .../web/frameworks/react-web-sdk/README.md | 7 +- .../Personalization.lifecycle.test.tsx | 38 +++++-- .../src/personalization/Personalization.tsx | 105 ++++-------------- 3 files changed, 54 insertions(+), 96 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 7748e3cf..bb26b274 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -115,8 +115,9 @@ import { Personalization } from '@contentful/optimization-react-web' - Consumer content supports render-prop (`(resolvedEntry) => ReactNode`) or direct `ReactNode`. - Wrapper element is configurable with `as: 'div' | 'span'` (defaults to `div`). - Wrapper style uses `display: contents` to remain layout-neutral as much as possible. -- Lifecycle mode is configurable with `lifecycleMode: 'spa' | 'hybrid-ssr-spa'` (defaults to - `'spa'`). +- Readiness is inferred automatically: + - personalized entries render when `canPersonalize === true` + - non-personalized entries render when the SDK instance is initialized #### Loading Fallback @@ -136,7 +137,7 @@ When `loadingFallback` is provided, it is rendered while readiness is unresolved - During loading, a concrete layout-target element is rendered (`data-ctfl-loading-layout-target`) so loading visibility/layout behavior remains targetable even when wrapper uses `display: contents`. -- In `hybrid-ssr-spa` mode, unresolved loading is rendered invisibly (`visibility: hidden`) to +- During server rendering, unresolved loading is rendered invisibly (`visibility: hidden`) to preserve layout space before content is ready. #### Nested Composition diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx index a83b1a53..10d6cdca 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -170,6 +170,26 @@ function renderComponentToString( ) } +function renderToStringWithoutWindow(render: () => string): string { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + + if (!descriptor?.configurable) { + throw new TypeError('Expected global window descriptor to be configurable in test runtime') + } + + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: undefined, + }) + + try { + return render() + } finally { + Object.defineProperty(globalThis, 'window', descriptor) + } +} + function getWrapper(container: HTMLElement): HTMLElement { const { firstElementChild: wrapper } = container @@ -366,14 +386,16 @@ describe('Personalization lifecycle and nesting guard', () => { await spanView.unmount() }) - it('renders invisible loading target on initial hybrid SSR pass for non-personalized entries', () => { + it('renders invisible loading target during SSR for non-personalized entries', () => { const { optimization } = createRuntime((entry) => ({ entry })) - const markup = renderComponentToString( - - {(resolved) => readTitle(resolved)} - , - optimization, + const markup = renderToStringWithoutWindow(() => + renderComponentToString( + + {(resolved) => readTitle(resolved)} + , + optimization, + ), ) expect(markup).toContain('data-ctfl-loading-layout-target="true"') @@ -382,11 +404,11 @@ describe('Personalization lifecycle and nesting guard', () => { expect(markup).not.toContain('baseline') }) - it('renders non-personalized content after sdk initialization in hybrid mode', async () => { + it('renders non-personalized content after sdk initialization', async () => { const { optimization } = createRuntime((entry) => ({ entry })) const view = await renderComponent( - + {(resolved) => readTitle(resolved)} , optimization, diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index c1b2fea4..8fdafff5 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -1,3 +1,6 @@ +// ✅ CHANGES: render baseline content invisibly during SSR/first hydration frame, +// and don't gate non-personalized entries behind sdkInitialized. + import type { SelectedPersonalization, SelectedPersonalizationArray, @@ -21,68 +24,15 @@ import { createScopedLogger } from '../logger' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' export type PersonalizationRenderProp = (resolvedEntry: Entry) => ReactNode -export type PersonalizationLifecycleMode = 'spa' | 'hybrid-ssr-spa' -/** - * Props for the {@link Personalization} component. - * - * @public - */ export interface PersonalizationProps { - /** - * The baseline Contentful entry fetched with `include: 10`. - * Must include `nt_experiences` field with linked personalization data. - */ baselineEntry: Entry - - /** - * Consumer content rendered inside the wrapper. - * - * @remarks - * Supports either: - * - render-prop form: `(resolvedEntry) => ReactNode` - * - direct node form: `ReactNode` - */ children: ReactNode | PersonalizationRenderProp - - /** - * Whether this component should react to personalization state changes in real-time. - * When `undefined`, inherits from the `liveUpdates` prop on {@link OptimizationRoot}. - */ liveUpdates?: boolean - - /** - * Wrapper element used to mount tracking attributes. - * Defaults to `div`. - * - * @remarks - * Wrapper uses `display: contents` to be as layout-neutral as possible. - */ as?: PersonalizationWrapperElement - - /** - * Optional test id prop. - */ testId?: string - - /** - * Optional data-testid prop. - */ 'data-testid'?: string - - /** - * Optional fallback rendered while personalization state is unresolved. - */ loadingFallback?: PersonalizationLoadingFallback - - /** - * Controls rendering lifecycle semantics. - * - * @remarks - * - `spa`: Non-personalized entries render immediately once entry data is present. - * - `hybrid-ssr-spa`: Non-personalized entries wait for client-side SDK initialization. - */ - lifecycleMode?: PersonalizationLifecycleMode } function resolveLoadingFallback( @@ -104,7 +54,6 @@ function resolveChildren(children: PersonalizationProps['children'], entry: Entr if (!isPersonalizationRenderProp(children)) { return children } - return children(entry) } @@ -239,25 +188,6 @@ function resolveLoadingLayoutTargetStyle( return LOADING_LAYOUT_TARGET_STYLE } -function resolveContentReadyState(params: { - lifecycleMode: PersonalizationLifecycleMode - requiresPersonalization: boolean - canPersonalize: boolean - sdkInitialized: boolean -}): boolean { - const { lifecycleMode, requiresPersonalization, canPersonalize, sdkInitialized } = params - - if (requiresPersonalization) { - return canPersonalize - } - - if (lifecycleMode === 'hybrid-ssr-spa') { - return sdkInitialized - } - - return true -} - export function Personalization({ baselineEntry, children, @@ -266,7 +196,6 @@ export function Personalization({ testId, 'data-testid': dataTestIdProp, loadingFallback, - lifecycleMode = 'spa', }: PersonalizationProps): JSX.Element { const optimization = useOptimization() const liveUpdatesContext = useLiveUpdates() @@ -290,25 +219,24 @@ export function Personalization({ SelectedPersonalizationArray | undefined >(undefined) const [canPersonalize, setCanPersonalize] = useState(false) + const [sdkInitialized, setSdkInitialized] = useState(false) useEffect(() => { const personalizationsSubscription = optimization.states.personalizations.subscribe((p) => { setLockedPersonalizations((previous) => { if (shouldLiveUpdate) { - // Live updates enabled - always update state return p } if (previous === undefined && p !== undefined) { - // First non-undefined value - lock it return p } - // Otherwise ignore updates (we're locked to the initial value) return previous }) }) + const canPersonalizeSubscription = optimization.states.canPersonalize.subscribe((value) => { setCanPersonalize(value) }) @@ -323,23 +251,30 @@ export function Personalization({ setSdkInitialized(true) }, []) + const baselineChildren = useMemo( + () => resolveChildren(children, baselineEntry), + [children, baselineEntry], + ) + const resolvedData: ResolvedData = useMemo( () => optimization.personalizeEntry(baselineEntry, lockedPersonalizations), [optimization, baselineEntry, lockedPersonalizations], ) const requiresPersonalization = hasPersonalizationReferences(baselineEntry) - const isContentReady = resolveContentReadyState({ - canPersonalize, - lifecycleMode, - requiresPersonalization, - sdkInitialized, - }) + + const isContentReady = requiresPersonalization ? canPersonalize : true + const isLoading = !isContentReady const showLoadingFallback = isLoading + const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK - const isInvisibleLoading = lifecycleMode === 'hybrid-ssr-spa' && isLoading + + const isInvisibleLoading = isLoading && !sdkInitialized + + const loadingContent = !sdkInitialized ? baselineChildren : resolvedLoadingFallback + const dataTestId = dataTestIdProp ?? testId const Wrapper = as @@ -354,7 +289,7 @@ export function Personalization({ data-ctfl-loading-layout-target="true" style={loadingLayoutTargetStyle} > - {resolvedLoadingFallback} + {loadingContent} From eb53e5220b4296814d9d38b1435a8690f06dca69 Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Fri, 13 Mar 2026 18:39:03 +0100 Subject: [PATCH 7/8] feat: Add DefaultLoadingFallback component and update Personalization loading logic --- .../DefaultLoadingFallback.test.tsx | 12 ++++++ .../DefaultLoadingFallback.tsx | 11 +++++ .../Personalization.lifecycle.test.tsx | 12 +++--- .../src/personalization/Personalization.tsx | 41 ++++++++++--------- .../src/provider/LiveUpdatesProvider.tsx | 2 +- .../click/createEntryClickDetector.test.ts | 4 +- .../hover/createEntryHoverDetector.test.ts | 4 +- 7 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.test.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.tsx diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.test.tsx new file mode 100644 index 00000000..c80a7941 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.test.tsx @@ -0,0 +1,12 @@ +import { renderToString } from 'react-dom/server' +import { DefaultLoadingFallback } from './DefaultLoadingFallback' + +describe('DefaultLoadingFallback', () => { + it('renders the default loading affordance', () => { + const markup = renderToString() + + expect(markup).toContain('data-ctfl-loading="true"') + expect(markup).toContain('aria-label="Loading content"') + expect(markup).toContain('Loading...') + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.tsx new file mode 100644 index 00000000..52067e31 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/DefaultLoadingFallback.tsx @@ -0,0 +1,11 @@ +import type { JSX } from 'react' + +export function DefaultLoadingFallback(): JSX.Element { + return ( + + Loading... + + ) +} + +export default DefaultLoadingFallback diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx index 10d6cdca..ce587073 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -15,7 +15,7 @@ type PersonalizeEntry = ( entry: TestEntry, personalizations: PersonalizationState, ) => ResolvedData -type PersonalizationsSubscriber = (value: PersonalizationState) => void +type SelectedPersonalizationsSubscriber = (value: PersonalizationState) => void type CanPersonalizeSubscriber = (value: boolean) => void interface RuntimeOptimization { @@ -24,8 +24,8 @@ interface RuntimeOptimization { canPersonalize: { subscribe: (next: CanPersonalizeSubscriber) => { unsubscribe: () => void } } - personalizations: { - subscribe: (next: PersonalizationsSubscriber) => { unsubscribe: () => void } + selectedPersonalizations: { + subscribe: (next: SelectedPersonalizationsSubscriber) => { unsubscribe: () => void } } } } @@ -63,7 +63,7 @@ function createRuntime(personalizeEntry: PersonalizeEntry): { emit: (value: PersonalizationState) => Promise optimization: RuntimeOptimization } { - const subscribers = new Set() + const subscribers = new Set() const canPersonalizeSubscribers = new Set() let current: PersonalizationState = undefined let canPersonalize = false @@ -83,8 +83,8 @@ function createRuntime(personalizeEntry: PersonalizeEntry): { } }, }, - personalizations: { - subscribe(next: PersonalizationsSubscriber) { + selectedPersonalizations: { + subscribe(next: SelectedPersonalizationsSubscriber) { subscribers.add(next) next(current) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index 09f13c18..f0f9813f 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -17,6 +17,7 @@ import { import { useLiveUpdates } from '../hooks/useLiveUpdates' import { useOptimization } from '../hooks/useOptimization' import { createScopedLogger } from '../logger' +import { DefaultLoadingFallback } from './DefaultLoadingFallback' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' @@ -55,11 +56,6 @@ function resolveChildren(children: PersonalizationProps['children'], entry: Entr } const WRAPPER_STYLE = Object.freeze({ display: 'contents' as const }) -const DEFAULT_LOADING_FALLBACK = ( - - Loading... - -) const LOADING_LAYOUT_TARGET_STYLE = Object.freeze({ display: 'block' as const, }) @@ -221,19 +217,23 @@ export function Personalization({ useEffect(() => { const selectedPersonalizationsSubscription = - contentfulOptimization.states.personalizations.subscribe((p) => { - setLockedSelectedPersonalizations((previous) => { - if (shouldLiveUpdate) { - return p - } - - if (previous === undefined && p !== undefined) { - return p - } - - return previous - }) - }) + contentfulOptimization.states.selectedPersonalizations.subscribe( + (selectedPersonalizations: SelectedPersonalizationArray | undefined) => { + setLockedSelectedPersonalizations( + (previous: SelectedPersonalizationArray | undefined) => { + if (shouldLiveUpdate) { + return selectedPersonalizations + } + + if (previous === undefined && selectedPersonalizations !== undefined) { + return selectedPersonalizations + } + + return previous + }, + ) + }, + ) const canPersonalizeSubscription = contentfulOptimization.states.canPersonalize.subscribe( (value) => { @@ -268,8 +268,9 @@ export function Personalization({ const isLoading = !isContentReady const showLoadingFallback = isLoading - const resolvedLoadingFallback = - resolveLoadingFallback(loadingFallback) ?? DEFAULT_LOADING_FALLBACK + const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? ( + + ) const isInvisibleLoading = isLoading && !sdkInitialized diff --git a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx index 947ff20f..d589b1f3 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx @@ -15,7 +15,7 @@ export function LiveUpdatesProvider({ const [previewPanelVisible, setPreviewPanelVisible] = useState(false) useEffect(() => { - const sub = contentfulOptimization.states.previewPanelOpen.subscribe((isOpen) => { + const sub = contentfulOptimization.states.previewPanelOpen.subscribe((isOpen: boolean) => { setPreviewPanelVisible(isOpen) }) return () => { diff --git a/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts index f8a4723e..43f11264 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/click/createEntryClickDetector.test.ts @@ -369,13 +369,13 @@ describe('EntryClickTracker', () => { const preventDefaultSpy = rs.spyOn(Event.prototype, 'preventDefault') const stopPropagationSpy = rs.spyOn(Event.prototype, 'stopPropagation') - const { core, trackComponentClick } = createCore() + const { core, trackClick } = createCore() const { cleanup, tracker } = createEntryTrackingHarness(createEntryClickDetector(core)) tracker.start() entry.click() - expect(trackComponentClick).toHaveBeenCalledTimes(1) + expect(trackClick).toHaveBeenCalledTimes(1) expect(preventDefaultSpy).not.toHaveBeenCalled() expect(stopPropagationSpy).not.toHaveBeenCalled() diff --git a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts index 37029d6c..b9b3cd8c 100644 --- a/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts +++ b/packages/web/web-sdk/src/entry-tracking/events/hover/createEntryHoverDetector.test.ts @@ -306,7 +306,7 @@ describe('EntryHoverTracker', () => { const preventDefaultSpy = rs.spyOn(Event.prototype, 'preventDefault') const stopPropagationSpy = rs.spyOn(Event.prototype, 'stopPropagation') - const { core, trackComponentHover } = createCore() + const { core, trackHover } = createCore() const { cleanup, tracker } = createEntryTrackingHarness(createEntryHoverDetector(core)) tracker.start({ dwellTimeMs: 0 }) @@ -314,7 +314,7 @@ describe('EntryHoverTracker', () => { dispatchHoverEnter(entry) await advance(0) - expect(trackComponentHover).toHaveBeenCalledTimes(1) + expect(trackHover).toHaveBeenCalledTimes(1) expect(preventDefaultSpy).not.toHaveBeenCalled() expect(stopPropagationSpy).not.toHaveBeenCalled() From 039d6db329cb67a0a7dbedfc648d3940f54bebdc Mon Sep 17 00:00:00 2001 From: Lotfi Arif Date: Fri, 13 Mar 2026 18:45:15 +0100 Subject: [PATCH 8/8] feat: Make resolveChildren async in baselineChildren useMemo --- .../react-web-sdk/src/personalization/Personalization.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx index f0f9813f..887448f9 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -252,7 +252,7 @@ export function Personalization({ }, []) const baselineChildren = useMemo( - () => resolveChildren(children, baselineEntry), + async () => await resolveChildren(children, baselineEntry), [children, baselineEntry], )