diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 56ed1b2e..d2c546a2 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -114,10 +114,16 @@ 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. +- Readiness is inferred automatically: + - personalized entries render when `canPersonalize === true` + - non-personalized entries render when the SDK instance is initialized #### 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. +- 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`. +- During server rendering, unresolved loading is rendered invisibly (`visibility: hidden`) to + preserve layout space before content is ready. #### Nested Composition @@ -146,6 +158,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 @@ -181,6 +198,21 @@ 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. + +### 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/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 new file mode 100644 index 00000000..ce587073 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.lifecycle.test.tsx @@ -0,0 +1,422 @@ +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 { renderToString } from 'react-dom/server' +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 SelectedPersonalizationsSubscriber = (value: PersonalizationState) => void +type CanPersonalizeSubscriber = (value: boolean) => void + +interface RuntimeOptimization { + personalizeEntry: PersonalizeEntry + states: { + canPersonalize: { + subscribe: (next: CanPersonalizeSubscriber) => { unsubscribe: () => void } + } + selectedPersonalizations: { + subscribe: (next: SelectedPersonalizationsSubscriber) => { 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) + }, + } + }, + }, + selectedPersonalizations: { + subscribe(next: SelectedPersonalizationsSubscriber) { + 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 renderComponentToString( + node: ReactNode, + optimization: RuntimeOptimization, + liveUpdatesContext = defaultLiveUpdatesContext(), +): string { + return renderToString( + // @ts-expect-error test double only implements the subset used by Personalization + + {node} + , + ) +} + +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 + + if (!(wrapper instanceof HTMLElement)) { + throw new TypeError('Expected first child to be an 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 }, + } = 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('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() + }) + + it('renders invisible loading target during SSR for non-personalized entries', () => { + const { optimization } = createRuntime((entry) => ({ entry })) + + const markup = renderToStringWithoutWindow(() => + 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', 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.test.tsx b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.test.tsx index 1627237d..353550b5 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: SelectedPersonalizationState) => Promise contentfulOptimization: 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)} , contentfulOptimization, 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 258e0535..887448f9 100644 --- a/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx +++ b/packages/web/frameworks/react-web-sdk/src/personalization/Personalization.tsx @@ -4,66 +4,114 @@ 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' +import { DefaultLoadingFallback } from './DefaultLoadingFallback' export type PersonalizationLoadingFallback = ReactNode | (() => ReactNode) export type PersonalizationWrapperElement = 'div' | 'span' +export type PersonalizationRenderProp = (resolvedEntry: Entry) => ReactNode -/** - * 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 - - /** - * Render prop that receives the resolved variant entry. - */ - children: (resolvedEntry: Entry) => ReactNode - - /** - * Whether this component should react to personalization state changes in real-time. - * When `undefined`, inherits from the `liveUpdates` prop on {@link OptimizationRoot}. - */ + children: ReactNode | PersonalizationRenderProp liveUpdates?: boolean - - /** - * Wrapper element used to mount tracking attributes. - * Defaults to `div`. - */ 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 } function resolveLoadingFallback( loadingFallback: PersonalizationLoadingFallback | undefined, ): ReactNode { - if (typeof loadingFallback === 'function') return loadingFallback() + if (typeof loadingFallback === 'function') { + return loadingFallback() + } 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 LOADING_LAYOUT_TARGET_STYLE = Object.freeze({ + display: 'block' as const, +}) +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 + if (!Array.isArray(ntExperiences)) { + return false + } + return ntExperiences.length > 0 +} function resolveDuplicationScope( personalization: SelectedPersonalization | undefined, @@ -72,7 +120,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 } @@ -82,18 +132,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), @@ -101,6 +158,29 @@ function resolveTrackingAttributes( } } +function resolveLoadingLayoutTargetStyle( + wrapperElement: PersonalizationWrapperElement, + 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 + } + + return LOADING_LAYOUT_TARGET_STYLE +} + export function Personalization({ baselineEntry, children, @@ -112,6 +192,15 @@ export function Personalization({ }: PersonalizationProps): JSX.Element { const contentfulOptimization = useOptimization() const liveUpdatesContext = useLiveUpdates() + const { + sys: { id: baselineEntryId }, + } = baselineEntry + const { currentAndAncestorBaselineIds, hasDuplicateBaselineAncestor } = + useDuplicateBaselineGuard(baselineEntryId) + + if (hasDuplicateBaselineAncestor) { + return <>> + } const shouldLiveUpdate = resolveShouldLiveUpdate({ componentLiveUpdates: liveUpdates, @@ -124,24 +213,28 @@ export function Personalization({ >(undefined) const [canPersonalize, setCanPersonalize] = useState(false) + const [sdkInitialized, setSdkInitialized] = useState(false) + useEffect(() => { const selectedPersonalizationsSubscription = - contentfulOptimization.states.selectedPersonalizations.subscribe((p) => { - setLockedSelectedPersonalizations((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 - }) - }) + 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) => { setCanPersonalize(value) @@ -154,30 +247,64 @@ export function Personalization({ } }, [contentfulOptimization, shouldLiveUpdate]) + useEffect(() => { + setSdkInitialized(true) + }, []) + + const baselineChildren = useMemo( + async () => await resolveChildren(children, baselineEntry), + [children, baselineEntry], + ) + const resolvedData: ResolvedData = useMemo( () => contentfulOptimization.personalizeEntry(baselineEntry, lockedSelectedPersonalizations), [contentfulOptimization, baselineEntry, lockedSelectedPersonalizations], ) - const isLoading = !canPersonalize - const showLoadingFallback = loadingFallback !== undefined && isLoading + const requiresPersonalization = hasPersonalizationReferences(baselineEntry) + + const isContentReady = requiresPersonalization ? canPersonalize : true + + const isLoading = !isContentReady + const showLoadingFallback = isLoading + + const resolvedLoadingFallback = resolveLoadingFallback(loadingFallback) ?? ( + + ) + + const isInvisibleLoading = isLoading && !sdkInitialized + + const loadingContent = !sdkInitialized ? baselineChildren : resolvedLoadingFallback + const dataTestId = dataTestIdProp ?? testId const Wrapper = as if (showLoadingFallback) { + const LoadingLayoutTarget = Wrapper + const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(as, isInvisibleLoading) + return ( - - {resolveLoadingFallback(loadingFallback)} - + + + + {loadingContent} + + + ) } const trackingAttributes = resolveTrackingAttributes(resolvedData) return ( - - {children(resolvedData.entry)} - + + + {resolveChildren(children, resolvedData.entry)} + + ) } 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/README.md b/packages/web/web-sdk/README.md index d76643c2..12a4e5c6 100644 --- a/packages/web/web-sdk/README.md +++ b/packages/web/web-sdk/README.md @@ -742,6 +742,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 `trackView` with the necessary 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 a8535119..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 @@ -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, trackClick } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryClickDetector(core)) + + tracker.start() + entry.click() + + expect(trackClick).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 3c4d566a..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 @@ -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, trackHover } = createCore() + const { cleanup, tracker } = createEntryTrackingHarness(createEntryHoverDetector(core)) + + tracker.start({ dwellTimeMs: 0 }) + + dispatchHoverEnter(entry) + await advance(0) + + expect(trackHover).toHaveBeenCalledTimes(1) + expect(preventDefaultSpy).not.toHaveBeenCalled() + expect(stopPropagationSpy).not.toHaveBeenCalled() + + cleanup() + }) })
{readTitle(childResolved)}