diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index f77932d0..3affd0dc 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -295,14 +295,19 @@ The shared Core resolver follows one path for every SDK package: 8. Find the `EntryReplacement` component, including components with omitted `type`, whose `baseline.id` equals the baseline entry `sys.id` and whose baseline is not hidden. 9. Select the configured variant at `variantIndex - 1`. -10. Find the linked Contentful variant entry in `optimizationEntry.fields.nt_variants` by the +10. When the selected variant has `id: ""` (empty string), return an **empty variant** outcome: the + `isEmptyVariant` flag is set to `true` in `ResolvedData`, the baseline entry is returned as the + entry field (for tracking context), and `selectedOptimization` metadata is preserved so a + component view impression can still be emitted. No warning is logged. Renderers must suppress + visible content when `isEmptyVariant` is `true`. +11. Find the linked Contentful variant entry in `optimizationEntry.fields.nt_variants` by the selected variant ID, and confirm that the variant entry uses the baseline entry content type. -11. Return the variant entry and `selectedOptimization` metadata when all checks pass. +12. Return the variant entry and `selectedOptimization` metadata when all checks pass. -If steps 8 to 10 fail after a `SelectedOptimization` has matched, the resolver returns the baseline -entry with the matched `selectedOptimization` metadata. Resolution returns entry objects from the -Contentful payload. Applications can cache raw Contentful payloads across requests, but -profile-resolved entries are request-local or session-local decisions. +If steps 8, 11, or 12 fail after a `SelectedOptimization` has matched (not caused by an empty +variant), the resolver returns the baseline entry with the matched `selectedOptimization` metadata. +Resolution returns entry objects from the Contentful payload. Applications can cache raw Contentful +payloads across requests, but profile-resolved entries are request-local or session-local decisions. Running resolution only chooses which entry to render. Stateful packages listen for optimization state changes around that decision, then choose again when their live-update rules allow it. @@ -355,20 +360,37 @@ before reading from the configured variant array. ### Fallback behavior Resolution is fail-soft. Invalid, incomplete, or unmatched data returns the baseline entry instead -of throwing. The selected metadata result distinguishes a baseline selection from a miss: - -| Condition | Entry result | `selectedOptimization` result | -| ------------------------------------------------- | -------------- | ----------------------------- | -| No `selectedOptimizations` | Baseline entry | `undefined` | -| Entry is not optimized | Baseline entry | `undefined` | -| `nt_experiences` contains only unresolved links | Baseline entry | `undefined` | -| No selected experience matches an attached entry | Baseline entry | `undefined` | -| Selected `variantIndex` is `0` | Baseline entry | Matched selection | -| No relevant `EntryReplacement` component exists | Baseline entry | Matched selection | -| Selected variant index is out of range | Baseline entry | Matched selection | -| Variant ID exists in config but not `nt_variants` | Baseline entry | Matched selection | -| Variant entry is still an unresolved link | Baseline entry | Matched selection | -| Variant entry content type differs from baseline | Baseline entry | Matched selection | +of throwing. The selected metadata result distinguishes a baseline selection, an empty variant, and +a resolution miss: + +| Condition | Entry result | `isEmptyVariant` | `selectedOptimization` result | +| ------------------------------------------------- | -------------- | ---------------- | ----------------------------- | +| No `selectedOptimizations` | Baseline entry | — | `undefined` | +| Entry is not optimized | Baseline entry | — | `undefined` | +| `nt_experiences` contains only unresolved links | Baseline entry | — | `undefined` | +| No selected experience matches an attached entry | Baseline entry | — | `undefined` | +| Selected `variantIndex` is `0` | Baseline entry | — | Matched selection | +| No relevant `EntryReplacement` component exists | Baseline entry | — | Matched selection | +| Selected variant index is out of range | Baseline entry | — | Matched selection | +| Selected variant has `id: ""` (empty variant) | Baseline entry | `true` | Matched selection | +| Variant ID exists in config but not `nt_variants` | Baseline entry | — | Matched selection | +| Variant entry is still an unresolved link | Baseline entry | — | Matched selection | +| Variant entry content type differs from baseline | Baseline entry | — | Matched selection | + +The "empty variant" row is distinct from data errors. When a content author selects the **Empty +variant** option in the Personalization UI, the variant is stored in `nt_config` as +`{ "id": "", "hidden": true }`. An unfilled placeholder slot (a variant added or unlinked but not +yet configured) is stored as `{ "id": "", "hidden": false }`. Both forms have `id: ""`, and both +produce `isEmptyVariant: true`. + +The SDK detects an empty variant by `id === ""` — not by the `hidden` flag. The `hidden` flag is not +a reliable signal because the Experience API strips it before runtime; it only survives in the +Contentful CDA `nt_config` payload that the resolver reads. Using `id === ""` is stable across both +data sources. + +When `isEmptyVariant` is `true`, renderers must not display any content. They must still emit a +component view impression so the empty variant is measurable in Insights. The baseline entry is +returned in the `entry` field as tracking context only. Consumers must not rely on exceptions to detect personalization misses. Render the baseline entry when no variant resolves; baseline fallback is expected behavior, not an error state. diff --git a/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts b/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts index 212d0593..7f5e4e79 100644 --- a/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts +++ b/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts @@ -4,19 +4,46 @@ import * as z from 'zod/mini' * Zod schema describing a single entry replacement variant. * * @remarks - * Each variant is identified by an `id` and can be marked as `hidden`. + * Each variant is identified by an `id` and can carry a `hidden` flag. + * + * An **empty variant** — the content author's deliberate choice to show nothing for an + * audience — is always encoded as `id: ""`. The resolver detects an empty variant via + * `id === ""` and returns `isEmptyVariant: true` in `ResolvedData` so renderers can + * suppress content while still emitting a component view impression for measurement. + * + * The `hidden` field on a variant has two possible states in Contentful-sourced data, + * but is **not** the detection signal for an empty variant: + * + * - `hidden: true` — the content author explicitly selected "Use empty variant" in the + * Personalization UI. This is the deliberate author intent described by this feature. + * - `hidden: false` (or absent) — an unfilled placeholder slot, created programmatically + * when a variant is added or unlinked but not yet filled with a real entry. + * + * Both states share `id: ""` and both result in `isEmptyVariant: true`. The `hidden` + * field itself is **not** a reliable detection signal because the Experience API strips + * it before runtime — `hidden` only survives in the Contentful CDA `nt_config` payload. + * Always use `id === ""` to detect an empty variant. + * + * The `hidden` field on the **baseline** (`EntryReplacementComponent.baseline`) has a + * different meaning: `true` excludes the entire component from variant resolution and + * allocation. This is unrelated to the empty-variant concept above. * * @public */ export const EntryReplacementVariant = z.object({ /** - * Unique identifier for the variant. + * Unique identifier for the variant. Empty string (`""`) for an empty variant — + * both the deliberate "Use empty variant" choice and an unfilled placeholder slot. */ id: z.string(), /** - * Indicates whether this variant is hidden from allocation/traffic. + * On a **baseline**: `true` excludes the whole component from allocation. * + * On a **variant**: can be `true` (author chose "Use empty variant") or `false` + * (unfilled placeholder). Both states share `id: ""`. Do not use this field to + * detect an empty variant — use `id === ""` instead. The Experience API strips + * this field before runtime; it is only present in Contentful CDA `nt_config`. */ hidden: z.optional(z.boolean()), }) diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index 9c1eec0f..288f779f 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -112,7 +112,7 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 19100, - "index.mjs": 19000, + "index.mjs": 19100, "bridge-support.cjs": 1200, "bridge-support.mjs": 2100, "runtime.cjs": 4000, diff --git a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts index 15363483..a1df9381 100644 --- a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts +++ b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts @@ -628,5 +628,161 @@ describe('OptimizedEntryResolver', () => { '2WzXDaWtDmstHl9p8Wufpp', ) }) + + describe('empty variant (id: "")', () => { + const createOptimizationEntryWithEmptyVariant = (baselineEntry: TestEntry): TestEntry => + createTestEntry( + 'experience-entry-empty', + { + nt_name: 'Personalization with empty variant', + nt_type: 'nt_personalization', + nt_experience_id: 'experience-entry-empty', + nt_config: { + components: [ + { + type: 'EntryReplacement', + baseline: { id: baselineEntry.sys.id }, + // Real Contentful data: hidden is always false on a variant, not true. + // Empty variant is detected solely by id === "". + variants: [{ id: '', hidden: false }], + }, + ], + }, + nt_variants: [], + }, + 'nt_experience', + ) + + const createSelectedOptimizationsForEmptyVariant = ( + baselineEntry: TestEntry, + ): SelectedOptimizationArray => [ + { + experienceId: 'experience-entry-empty', + variantIndex: 1, + variants: { [baselineEntry.sys.id]: '' }, + sticky: false, + }, + ] + + it('returns isEmptyVariant: true and does not render baseline content', () => { + const baselineFields: Record = {} + const baselineEntry = createTestEntry('baseline-entry', baselineFields) + const optimizationEntry = createOptimizationEntryWithEmptyVariant(baselineEntry) + baselineFields.nt_experiences = [optimizationEntry] + + const result = OptimizedEntryResolver.resolve( + baselineEntry, + createSelectedOptimizationsForEmptyVariant(baselineEntry), + ) + + expect(result.isEmptyVariant).toBe(true) + // The entry field contains the baseline (used for tracking context) + expect(result.entry).toBe(baselineEntry) + // selectedOptimization is preserved for tracking + expect(result.selectedOptimization).toEqual( + expect.objectContaining({ + experienceId: 'experience-entry-empty', + variantIndex: 1, + }), + ) + }) + + it('does not log a "could not resolve" warning for an empty variant', () => { + const baselineFields: Record = {} + const baselineEntry = createTestEntry('baseline-entry', baselineFields) + const optimizationEntry = createOptimizationEntryWithEmptyVariant(baselineEntry) + baselineFields.nt_experiences = [optimizationEntry] + + OptimizedEntryResolver.resolve( + baselineEntry, + createSelectedOptimizationsForEmptyVariant(baselineEntry), + ) + + expect(mockedLogger.warn).not.toHaveBeenCalledWith( + 'Optimization', + expect.stringContaining(RESOLUTION_WARNING_BASE), + ) + }) + + it('logs a debug message for the empty variant resolution', () => { + const baselineFields: Record = {} + const baselineEntry = createTestEntry('baseline-entry', baselineFields) + const optimizationEntry = createOptimizationEntryWithEmptyVariant(baselineEntry) + baselineFields.nt_experiences = [optimizationEntry] + + OptimizedEntryResolver.resolve( + baselineEntry, + createSelectedOptimizationsForEmptyVariant(baselineEntry), + ) + + expect(mockedLogger.debug).toHaveBeenCalledWith( + 'Optimization', + expect.stringContaining('resolved to empty variant'), + ) + }) + + it('preserves optimizationContext with selectedVariant for the empty variant', () => { + const baselineFields: Record = {} + const baselineEntry = createTestEntry('baseline-entry', baselineFields) + const optimizationEntry = createOptimizationEntryWithEmptyVariant(baselineEntry) + baselineFields.nt_experiences = [optimizationEntry] + + const { resolvedData, optimizationContext } = OptimizedEntryResolver.resolveWithContext( + baselineEntry, + createSelectedOptimizationsForEmptyVariant(baselineEntry), + ) + + expect(resolvedData.isEmptyVariant).toBe(true) + expect(optimizationContext?.selectedVariant).toEqual( + expect.objectContaining({ id: '', hidden: false }), + ) + expect(optimizationContext?.selectedOptimization).toEqual( + expect.objectContaining({ variantIndex: 1 }), + ) + }) + + it('still falls back to baseline with a warning for a genuine missing variant entry (non-empty id, no matching nt_variants entry)', () => { + const baselineFields: Record = {} + const baselineEntry = createTestEntry('baseline-entry', baselineFields) + // Variant has a real (non-empty) id but no matching entry in nt_variants — a real data error, + // not an intentional empty variant. + const optimizationEntry = createTestEntry( + 'experience-entry-broken', + { + nt_name: 'Broken variant', + nt_type: 'nt_personalization', + nt_experience_id: 'experience-entry-broken', + nt_config: { + components: [ + { + type: 'EntryReplacement', + baseline: { id: baselineEntry.sys.id }, + variants: [{ id: 'missing-variant-id' }], + }, + ], + }, + nt_variants: [], // entry missing — not intentionally empty + }, + 'nt_experience', + ) + baselineFields.nt_experiences = [optimizationEntry] + + const result = OptimizedEntryResolver.resolve(baselineEntry, [ + { + experienceId: 'experience-entry-broken', + variantIndex: 1, + variants: { [baselineEntry.sys.id]: 'missing-variant-id' }, + sticky: false, + }, + ]) + + expect(result.isEmptyVariant).toBeUndefined() + expect(result.entry).toBe(baselineEntry) + expect(mockedLogger.warn).toHaveBeenCalledWith( + 'Optimization', + expect.stringContaining(RESOLUTION_WARNING_BASE), + ) + }) + }) }) }) diff --git a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts index 5e242e14..5df91011 100644 --- a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts +++ b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts @@ -40,6 +40,27 @@ export interface ResolvedData< selectedOptimization?: SelectedOptimization /** Opaque runtime-owned optimization context ID for entry interaction tracking. */ optimizationContextId?: string + /** + * Whether the resolved variant is an empty variant — a deliberate author choice to + * render nothing for this audience. When `true`, the entry field still contains the + * baseline entry (used for tracking context) but renderers must display no content. + * This is distinct from a baseline selection (`variantIndex === 0`) and from a + * resolution error (broken variant link), both of which render the baseline entry. + * + * An empty variant is detected by `selectedVariant.id === ''`. Empty variants appear + * in two forms in Contentful CDA `nt_config` data: + * + * - `{ id: "", hidden: true }` — the author explicitly chose "Use empty variant" in + * the Personalization UI. This is the deliberate author intent for this feature. + * - `{ id: "", hidden: false }` — an unfilled placeholder slot, created + * programmatically when a variant is added or unlinked but not yet configured. + * + * Both forms produce `isEmptyVariant: true`. The `hidden` field is not used for + * detection because the Experience API strips it before runtime — it only survives + * in the Contentful CDA `nt_config` payload. Using `id === ''` catches both forms + * and is stable across all data sources. + */ + isEmptyVariant?: true } /** @@ -183,13 +204,18 @@ function resolveWithContext< const resolveTo = ( resolvedEntry: Entry, selectedVariant?: EntryReplacementVariant, + isEmptyVariant?: true, ): ResolvedDataWithOptimizationContext => { const audienceEntry = isResolvedAudienceEntry(maybeAudienceEntry) ? maybeAudienceEntry : undefined return { - resolvedData: { entry: resolvedEntry, selectedOptimization }, + resolvedData: { + entry: resolvedEntry, + selectedOptimization, + ...(isEmptyVariant ? { isEmptyVariant } : {}), + }, optimizationContext: { selectedOptimization, optimizationEntry, @@ -222,6 +248,18 @@ function resolveWithContext< return resolveTo(entry) } + // Detect an empty variant by id === ''. Two forms exist in CDA nt_config: + // { id: '', hidden: true } — author explicitly chose "Use empty variant" in the UI + // { id: '', hidden: false } — unfilled placeholder, added/unlinked but not configured + // Both produce isEmptyVariant: true. The `hidden` flag is not used because the + // Experience API strips it before runtime; id === '' is the stable invariant. + if (selectedVariant.id === '') { + logger.debug( + `Entry ${entry.sys.id} resolved to empty variant at index ${selectedVariantIndex} — rendering nothing`, + ) + return resolveTo(entry, selectedVariant, true) + } + const selectedVariantEntry = OptimizedEntryResolver.getSelectedVariantEntry({ optimizedEntry: entry, optimizationEntry, diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 18e315bd..db9936d0 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -121,8 +121,8 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 4700, - "index.mjs": 4000 + "index.cjs": 4800, + "index.mjs": 4100 } } }, diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index 42589170..36a7f29b 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -856,4 +856,97 @@ describe('OptimizedEntry', () => { await view.unmount() }) + + describe('empty variant', () => { + const emptyVariantState: SelectedOptimizationArray = [ + { + experienceId: 'exp-hero', + sticky: false, + variantIndex: 1, + variants: { 'optimized-baseline': '' }, + }, + ] + + it('renders no content when the resolved variant is empty', async () => { + const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { + if (selectedOptimizations?.length) { + return { + entry, + isEmptyVariant: true, + selectedOptimization: selectedOptimizations[0], + } + } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(emptyVariantState) + + expect(view.container.textContent).toBe('') + + await view.unmount() + }) + + it('keeps the tracking host with data-ctfl-empty-variant when the variant is empty', async () => { + const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { + if (selectedOptimizations?.length) { + return { + entry, + isEmptyVariant: true, + selectedOptimization: selectedOptimizations[0], + } + } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(emptyVariantState) + + const wrapper = getWrapper(view.container) + expect(wrapper.dataset.ctflEmptyVariant).toBe('true') + expect(wrapper.dataset.ctflBaselineId).toBe('optimized-baseline') + expect(wrapper.dataset.ctflOptimizationId).toBe('exp-hero') + expect(wrapper.dataset.ctflVariantIndex).toBe('1') + + await view.unmount() + }) + + it('does not render baseline content for an empty variant', async () => { + const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { + if (selectedOptimizations?.length) { + return { + entry, + isEmptyVariant: true, + selectedOptimization: selectedOptimizations[0], + } + } + return { entry } + }) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await emit(emptyVariantState) + + expect(view.container.textContent).not.toContain('optimized-baseline') + + await view.unmount() + }) + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx index 051273af..abf8caeb 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx @@ -213,6 +213,110 @@ function getTargetDisplay(wrapper: OptimizedEntryWrapperElement): 'block' | 'inl return wrapper === 'span' ? 'inline' : 'block' } +interface OptimizedEntryBodyProps { + readonly Wrapper: OptimizedEntryWrapperElement + readonly baselineEntry: Entry + readonly children: OptimizedEntryChildren + readonly currentAndAncestorBaselineIds: ReadonlySet + readonly dataTestId: string | undefined + readonly error: Error | undefined + readonly errorFallback: OptimizedEntryErrorFallback | undefined + readonly hasCustomLoadingFallback: boolean + readonly hasDuplicateBaselineAncestor: boolean + readonly loadingFallback: OptimizedEntryLoadingFallback | undefined + readonly managedEntry: Entry | undefined + readonly renderContext: OptimizedEntryRenderContext + readonly snapshot: ReturnType + readonly targetDisplay: 'block' | 'inline' +} + +function renderOptimizedEntryBody({ + Wrapper, + baselineEntry, + children, + currentAndAncestorBaselineIds, + dataTestId, + error, + errorFallback, + hasCustomLoadingFallback, + hasDuplicateBaselineAncestor, + loadingFallback, + managedEntry, + renderContext, + snapshot, + targetDisplay, +}: OptimizedEntryBodyProps): JSX.Element | null { + if (hasDuplicateBaselineAncestor) { + return null + } + + const frameProps = { + currentAndAncestorBaselineIds, + dataTestId, + Wrapper, + } + + if (error) { + return renderErrorFallback(errorFallback, error, frameProps) + } + + const resolvedLoadingFallback = hasCustomLoadingFallback + ? resolveLoadingFallback(loadingFallback) + : undefined + + if (!managedEntry) { + const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(targetDisplay, false) + + return ( + + + {resolvedLoadingFallback} + + + ) + } + + const { entry, hostAttributes, isEmptyVariant, loadingPresentation } = snapshot + const { + hideLoadingLayoutTarget, + shouldRenderBaselineWhileLoading, + showLoadingFallback, + targetDisplay: loadingTargetDisplay, + } = loadingPresentation + const loadingContent = shouldRenderBaselineWhileLoading + ? resolveChildren(children, baselineEntry, renderContext) + : resolvedLoadingFallback + + if (showLoadingFallback) { + const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle( + loadingTargetDisplay, + hideLoadingLayoutTarget, + ) + + return ( + + + {loadingContent} + + + ) + } + + return ( + + {isEmptyVariant ? null : resolveChildren(children, entry, renderContext)} + + ) +} + +function shouldNotifyEntryResolved( + managedEntry: Entry | undefined, + hasDuplicateBaselineAncestor: boolean, + isResolved: boolean, +): boolean { + return managedEntry !== undefined && !hasDuplicateBaselineAncestor && isResolved +} + function useDuplicateBaselineGuard(baselineEntryId: string): { currentAndAncestorBaselineIds: ReadonlySet hasDuplicateBaselineAncestor: boolean @@ -297,7 +401,13 @@ export function OptimizedEntry({ ) useEffect(() => { - if (managedEntry.entry && !hasDuplicateBaselineAncestor && snapshot.isResolved) { + if ( + shouldNotifyEntryResolved( + managedEntry.entry, + hasDuplicateBaselineAncestor, + snapshot.isResolved, + ) + ) { onEntryResolved?.(metadata) } }, [ @@ -308,73 +418,22 @@ export function OptimizedEntry({ snapshot.isResolved, ]) - if (hasDuplicateBaselineAncestor) { - return null - } - - const dataTestId = dataTestIdProp ?? testId - const Wrapper = as - const frameProps = { + return renderOptimizedEntryBody({ + Wrapper: as, + baselineEntry, + children, currentAndAncestorBaselineIds, - dataTestId, - Wrapper, - } - - if (managedEntry.error) { - return renderErrorFallback(errorFallback, managedEntry.error, frameProps) - } - - const resolvedLoadingFallback = hasCustomLoadingFallback - ? resolveLoadingFallback(loadingFallback) - : undefined - - if (!managedEntry.entry) { - const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(targetDisplay, false) - - return ( - - - {resolvedLoadingFallback} - - - ) - } - - const { entry, hostAttributes, loadingPresentation } = snapshot - const { - hideLoadingLayoutTarget, - shouldRenderBaselineWhileLoading, - showLoadingFallback, - targetDisplay: loadingTargetDisplay, - } = loadingPresentation - const loadingContent = shouldRenderBaselineWhileLoading - ? resolveChildren(children, baselineEntry, renderContext) - : resolvedLoadingFallback - - if (showLoadingFallback) { - const LoadingLayoutTarget = Wrapper - const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle( - loadingTargetDisplay, - hideLoadingLayoutTarget, - ) - - return ( - - - {loadingContent} - - - ) - } - - return ( - - {resolveChildren(children, entry, renderContext)} - - ) + dataTestId: dataTestIdProp ?? testId, + error: managedEntry.error, + errorFallback, + hasCustomLoadingFallback, + hasDuplicateBaselineAncestor, + loadingFallback, + managedEntry: managedEntry.entry, + renderContext, + snapshot, + targetDisplay, + }) } export default OptimizedEntry diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 7413a6b7..1d6b9620 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -130,14 +130,14 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web.umd.js": 34500, + "contentful-optimization-web.umd.js": 34550, "index.cjs": 12500, "index.mjs": 12500, "bridge-support.cjs": 1200, "bridge-support.mjs": 1200, "runtime.cjs": 900, "runtime.mjs": 320, - "contentful-optimization-web-components.umd.js": 39010, + "contentful-optimization-web-components.umd.js": 39120, "presentation.cjs": 3200, "presentation.mjs": 3200, "tracking-attributes.cjs": 1200, diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index 43b8ae71..8091414d 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -65,6 +65,12 @@ export interface OptimizedEntrySnapshot { readonly entry: Entry /** Host attributes needed for automatic entry interaction tracking. */ readonly hostAttributes: OptimizedEntryTrackingAttributes + /** + * Whether the resolved variant is an empty variant — the content author deliberately + * chose to show nothing for this audience. Renderers must suppress visible content + * when this is `true`, but still mount the tracking host so a component view fires. + */ + readonly isEmptyVariant: boolean /** Whether the optimized entry is still waiting for optimization state. */ readonly isLoading: boolean /** Whether the client presentation layer is ready to reveal rendered content. */ @@ -287,6 +293,7 @@ function areSnapshotValuesEqual( return ( left.canOptimize === right.canOptimize && left.entry === right.entry && + left.isEmptyVariant === right.isEmptyVariant && left.isLoading === right.isLoading && left.isPresentationReady === right.isPresentationReady && left.isResolved === right.isResolved && @@ -526,6 +533,7 @@ export class OptimizedEntryController { resolvedData, this.options, ), + isEmptyVariant: resolvedData.isEmptyVariant === true, isLoading, isPresentationReady: this.options.isPresentationReady, isResolved, diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts index 1ece32a8..a515e078 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts @@ -84,6 +84,7 @@ export function resolveOptimizedEntryTrackingAttributes( 'data-ctfl-baseline-id': baselineEntry.sys.id, 'data-ctfl-clickable': clickable === true ? true : undefined, 'data-ctfl-duplication-scope': resolveDuplicationScope(selectedOptimization), + 'data-ctfl-empty-variant': resolvedData.isEmptyVariant === true ? true : undefined, 'data-ctfl-entry-id': entryId, 'data-ctfl-hover-duration-update-interval-ms': hoverDurationUpdateIntervalMs, 'data-ctfl-optimization-id': selectedOptimization?.experienceId,