Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
})
Expand Down
2 changes: 1 addition & 1 deletion packages/universal/core-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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),
)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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

@fmamud Felipe Mamud (fmamud) Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we can represent the variant emptiness by using a state/type property (e.g. kind) on resolved data instead of an optional boolean flag. WDYT?

For example:

type ResolvedData<S, M, L> =
  | { kind: 'baseline'; entry: Entry<S, M, L>; selectedOptimization?: SelectedOptimization }
  | { kind: 'variant';  entry: Entry<S, M, L>; selectedOptimization: SelectedOptimization }
  | { kind: 'empty';    entry: Entry<S, M, L>; selectedOptimization: SelectedOptimization }

Renderers become if (resolved.kind === 'empty') return <...>, switches could became exhaustive so future outcomes surface as compile errors, and selectedOptimization narrows to non-optional where it's actually guaranteed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like that could require unexpected maintenance over time, as well as require extra logic to determine whether each is entry is of a specific type, and that type isn't always straightforward (baseline can be thought of as a variant in many cases). The current solution fix the exact case without affecting any other feature, and it's a bit of a unique case as it is. I could be wrong though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The kind discriminated union is appealing but the Experience API gives us no extra information to justify the extra variants

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, but I was thinking in a way that customers would need to know if a variant is empty or not by checking the type/kind, otherwise no need to care about it. I feel like having a optional boolean flag a bit dubious as part of a public API.

}

/**
Expand Down Expand Up @@ -183,13 +204,18 @@ function resolveWithContext<
const resolveTo = (
resolvedEntry: Entry<S, M, L>,
selectedVariant?: EntryReplacementVariant,
isEmptyVariant?: true,
): ResolvedDataWithOptimizationContext<S, M, L> => {
const audienceEntry = isResolvedAudienceEntry(maybeAudienceEntry)
? maybeAudienceEntry
: undefined

return {
resolvedData: { entry: resolvedEntry, selectedOptimization },
resolvedData: {
entry: resolvedEntry,
selectedOptimization,
...(isEmptyVariant ? { isEmptyVariant } : {}),
},
optimizationContext: {
selectedOptimization,
optimizationEntry,
Expand Down Expand Up @@ -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<S, M, L>({
optimizedEntry: entry,
optimizationEntry,
Expand Down
4 changes: 2 additions & 2 deletions packages/web/frameworks/react-web-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@
"buildTools": {
"bundleSize": {
"gzipBudgets": {
"index.cjs": 4700,
"index.mjs": 4000
"index.cjs": 4800,
"index.mjs": 4100
}
}
},
Expand Down
Loading
Loading