From fbd9c235a7d8f0559cd6ee1339ca2a24d33edd18 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:36:06 +0700 Subject: [PATCH 01/14] =?UTF-8?q?=E2=9C=A8=20feat(optimization):=20Impleme?= =?UTF-8?q?nt=20empty=20variant=20handling=20in=20entry=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced support for an **empty variant** (id: "") in the entry personalization and variant resolution process. - Updated the resolver to return an `isEmptyVariant` flag in `ResolvedData`, allowing renderers to suppress content while still emitting component view impressions. - Enhanced documentation to clarify the behavior of empty variants and their distinction from baseline selections and resolution errors. - Added tests to ensure correct handling of empty variants, including logging behavior and tracking context preservation. This update improves the personalization experience by allowing content authors to intentionally show no content for specific audiences while maintaining tracking capabilities. --- ...-personalization-and-variant-resolution.md | 56 ++++--- .../src/contentful/OptimizationConfig.ts | 19 ++- .../resolvers/OptimizedEntryResolver.test.ts | 156 ++++++++++++++++++ .../src/resolvers/OptimizedEntryResolver.ts | 28 +++- .../optimized-entry/OptimizedEntry.test.tsx | 93 +++++++++++ .../src/optimized-entry/OptimizedEntry.tsx | 4 +- .../presentation/OptimizedEntryController.ts | 8 + .../OptimizedEntryTrackingAttributes.ts | 1 + 8 files changed, 339 insertions(+), 26 deletions(-) diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index e5c0837ef..18dfd3750 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -258,14 +258,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. @@ -318,20 +323,31 @@ 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. A content author deliberately selected the +**Empty variant** option in the Personalization UI, which stores `id: ""` in the variant config +(with `hidden: false` — the `hidden` flag is always `false` on variants in Contentful-sourced data +and is not the detection signal). The SDK detects an empty variant by `id === ""` and returns +`isEmptyVariant: true` in `ResolvedData`. Renderers must not display any content when +`isEmptyVariant` is `true`, but 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 212d0593f..61ffebe86 100644 --- a/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts +++ b/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts @@ -6,17 +6,30 @@ import * as z from 'zod/mini' * @remarks * Each variant is identified by an `id` and can be marked as `hidden`. * + * An **empty variant** — the content author's deliberate choice to show nothing for an + * audience — is encoded as `id: ""`. The `hidden` field is always `false` (or absent) + * in Contentful-sourced data; it is not the detection signal. 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 the **baseline** (`EntryReplacementComponent.baseline`) has a + * different meaning: `true` excludes the entire component from variant resolution and + * allocation. This is a baseline-level "hidden from traffic" flag, 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. */ 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: always `false` (or absent) in Contentful-sourced data. + * Not used for empty-variant detection — use `id === ""` instead. */ hidden: z.optional(z.boolean()), }) diff --git a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.test.ts index 153634839..a1df93813 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 5e242e148..6b7341f25 100644 --- a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts +++ b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts @@ -40,6 +40,20 @@ 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 === ''`. In the Ninetailed + * data model, an empty-string variant ID means no replacement entry exists for that + * slot — the content author deliberately chose to show nothing for this audience. + * The `hidden` field on the variant config is always `false` (or absent) in practice + * and is not used for detection. + */ + isEmptyVariant?: true } /** @@ -183,13 +197,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 +241,13 @@ function resolveWithContext< return resolveTo(entry) } + 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/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index 377547656..672bacdc7 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 @@ -680,4 +680,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 9660743fa..8801b6129 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 @@ -168,7 +168,7 @@ export function OptimizedEntry({ const resolvedLoadingFallback = hasCustomLoadingFallback ? resolveLoadingFallback(loadingFallback) : undefined - const { entry, hostAttributes, loadingPresentation } = snapshot + const { entry, hostAttributes, isEmptyVariant, loadingPresentation } = snapshot const { hideLoadingLayoutTarget, shouldRenderBaselineWhileLoading, @@ -205,7 +205,7 @@ export function OptimizedEntry({ return ( - {resolveChildren(children, entry, renderContext)} + {isEmptyVariant ? null : resolveChildren(children, entry, renderContext)} ) diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index f14bc8a8f..bc9df9f02 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -58,6 +58,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. */ @@ -263,6 +269,7 @@ function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySn return ( left.canOptimize === right.canOptimize && left.entry === right.entry && + left.isEmptyVariant === right.isEmptyVariant && left.isLoading === right.isLoading && left.isPresentationReady === right.isPresentationReady && left.selectedOptimization === right.selectedOptimization && @@ -483,6 +490,7 @@ export class OptimizedEntryController { resolvedData, this.options, ), + isEmptyVariant: resolvedData.isEmptyVariant === true, isLoading, isPresentationReady: this.options.isPresentationReady, loadingPresentation: { diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts index 1ece32a87..a515e078a 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, From 0d715dd67ae6bfb3b6f0962c0fec5beb30571700 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:44:48 +0700 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=94=A7=20chore(core-sdk):=20Update?= =?UTF-8?q?=20bundle=20size=20for=20index.mjs=20to=20reflect=20new=20optim?= =?UTF-8?q?ization=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/universal/core-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index bdbc85aeb..6e3849607 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -102,7 +102,7 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 18700, - "index.mjs": 18200, + "index.mjs": 18300, "bridge-support.cjs": 1200, "bridge-support.mjs": 2100, "runtime.cjs": 4000, From 6185abe77f205e5df485316aac0ccdb4f2b88e29 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:57:09 +0700 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8=20feat(documentation,=20api-sch?= =?UTF-8?q?emas,=20core-sdk):=20Clarify=20empty=20variant=20handling=20in?= =?UTF-8?q?=20personalization=20and=20variant=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated documentation to specify the distinction between empty variants and data errors, detailing how empty variants are represented in the Personalization UI and their detection mechanism. - Enhanced the Zod schema to reflect the dual states of the `hidden` flag in Contentful-sourced data and emphasized the use of `id === ""` for detecting empty variants. - Revised the resolver comments to clarify the handling of empty variants and their implications for rendering and tracking. These changes improve the understanding of empty variant behavior, ensuring accurate implementation and measurement in personalization scenarios. --- ...-personalization-and-variant-resolution.md | 22 +++++++---- .../src/contentful/OptimizationConfig.ts | 38 +++++++++++++------ .../src/resolvers/OptimizedEntryResolver.ts | 22 ++++++++--- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index 18dfd3750..bc9b009a4 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -340,14 +340,20 @@ a resolution miss: | 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. A content author deliberately selected the -**Empty variant** option in the Personalization UI, which stores `id: ""` in the variant config -(with `hidden: false` — the `hidden` flag is always `false` on variants in Contentful-sourced data -and is not the detection signal). The SDK detects an empty variant by `id === ""` and returns -`isEmptyVariant: true` in `ResolvedData`. Renderers must not display any content when -`isEmptyVariant` is `true`, but 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. +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 61ffebe86..7f5e4e79e 100644 --- a/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts +++ b/packages/universal/api-schemas/src/contentful/OptimizationConfig.ts @@ -4,32 +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 encoded as `id: ""`. The `hidden` field is always `false` (or absent) - * in Contentful-sourced data; it is not the detection signal. 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. + * 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 a baseline-level "hidden from traffic" flag, unrelated to the - * empty-variant concept above. + * allocation. This is unrelated to the empty-variant concept above. * * @public */ export const EntryReplacementVariant = z.object({ /** - * Unique identifier for the variant. Empty string (`""`) for an Empty 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(), /** - * On a baseline: `true` excludes the whole component from allocation. - * On a variant: always `false` (or absent) in Contentful-sourced data. - * Not used for empty-variant detection — use `id === ""` instead. + * 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/src/resolvers/OptimizedEntryResolver.ts b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts index 6b7341f25..5df910111 100644 --- a/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts +++ b/packages/universal/core-sdk/src/resolvers/OptimizedEntryResolver.ts @@ -47,11 +47,18 @@ export interface ResolvedData< * 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 === ''`. In the Ninetailed - * data model, an empty-string variant ID means no replacement entry exists for that - * slot — the content author deliberately chose to show nothing for this audience. - * The `hidden` field on the variant config is always `false` (or absent) in practice - * and is not used for detection. + * 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 } @@ -241,6 +248,11 @@ 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`, From 35c1c6315721db2756a0684276282f210ab905ac Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Tue, 7 Jul 2026 10:29:24 +0200 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=9A=80=20ci(slack):=20Fixing=20issu?= =?UTF-8?q?e=20with=20the=20Slack=20Notification=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/notify-slack.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/notify-slack.yml b/.github/workflows/notify-slack.yml index 8e08f3908..7f5a8b4ad 100644 --- a/.github/workflows/notify-slack.yml +++ b/.github/workflows/notify-slack.yml @@ -28,6 +28,7 @@ jobs: REPOSITORY: ${{ github.repository }} run: | set -euo pipefail + trap 'echo "Classify changes failed at line ${LINENO}: ${BASH_COMMAND}" >&2' ERR changed_files="$( gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \ @@ -71,7 +72,9 @@ jobs: esac done <<< "$package_manifests" - [ -n "$best_name" ] && printf '%s\n' "$best_name" + if [ -n "$best_name" ]; then + printf '%s\n' "$best_name" + fi } while IFS=$'\t' read -r file status; do From 2ba1b87762d2c0f9abf51620733b8d16f6fcc91f Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Tue, 7 Jul 2026 12:26:08 +0200 Subject: [PATCH 05/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(web-sdk=5Fa?= =?UTF-8?q?ngular):=20Align=20SSR=20with=20isomorphic=20runtime=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-sdk_angular): Align SSR with isomorphic runtime seam * refactor(web-sdk,react-web-sdk): Move WebOptimizationRuntime to optimization-web/runtime * refactor(web-sdk_angular): Resolve merge tags at render time * refactor(web-sdk_angular): Split tracking-log event switch into methods * chore(web-sdk): Raise runtime subpath bundle-size budgets --- .../src/app/components/control-panel/index.ts | 18 +- .../src/app/components/entry-card/index.ts | 79 ++++- .../src/app/components/tracking-log/index.ts | 173 ++++++----- .../src/app/pages/page-two/index.ts | 10 +- .../src/app/services/contentful-client.ts | 19 +- .../web-sdk_angular/src/app/services/entry.ts | 59 +--- .../src/app/services/live-updates.ts | 8 +- .../src/app/services/merge-tags.ts | 51 ---- .../src/app/services/optimization.ts | 284 ++++++++---------- .../src/app/transfer-state-keys.ts | 39 --- .../src/context/OptimizationContext.tsx | 2 +- .../src/provider/OptimizationProvider.tsx | 5 +- .../react-web-sdk/src/runtime/webRuntime.ts | 28 -- packages/web/web-sdk/package.json | 4 +- packages/web/web-sdk/src/runtime.ts | 72 +++++ 15 files changed, 416 insertions(+), 435 deletions(-) delete mode 100644 implementations/web-sdk_angular/src/app/services/merge-tags.ts delete mode 100644 implementations/web-sdk_angular/src/app/transfer-state-keys.ts delete mode 100644 packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts diff --git a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts index 577de2946..41458dc43 100644 --- a/implementations/web-sdk_angular/src/app/components/control-panel/index.ts +++ b/implementations/web-sdk_angular/src/app/components/control-panel/index.ts @@ -25,7 +25,7 @@ export class ControlPanel { // This is an active exposure stream. Core does not mark one-off flag reads as // tracked until a flag-view event is actually accepted. protected readonly booleanFlag = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.flag('boolean')), + this.optimization.runtime().states.flag('boolean'), ) constructor() { @@ -39,22 +39,20 @@ export class ControlPanel { } protected toggleConsent(): void { - this.optimization.ifBrowser((sdk) => { - sdk.consent(this.consent() !== true) - }) + this.optimization.runtime().consent(this.consent() !== true) } protected identify(): void { - this.optimization.ifBrowser((sdk) => { - void sdk.identify({ userId: 'charles', traits: { identified: true } }) + void this.optimization.runtime().identify({ + userId: 'charles', + traits: { identified: true }, }) } protected reset(): void { - this.optimization.ifBrowser((sdk) => { - sdk.reset() - void sdk.page() - }) + const runtime = this.optimization.runtime() + runtime.reset() + void runtime.page() } protected trackConversion(): void { diff --git a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts index 5372f6045..000492fec 100644 --- a/implementations/web-sdk_angular/src/app/components/entry-card/index.ts +++ b/implementations/web-sdk_angular/src/app/components/entry-card/index.ts @@ -2,9 +2,12 @@ import { NgTemplateOutlet } from '@angular/common' import { Component, computed, forwardRef, inject, input } from '@angular/core' import { DomSanitizer, type SafeHtml } from '@angular/platform-browser' import { + isMergeTagEntry, isRecord, isResolvedContentfulEntry, isRichTextDocument, + isUnresolvedEntryLink, + type MergeTagEntry, } from '@contentful/optimization-web/api-schemas' import { BLOCKS, INLINES } from '@contentful/rich-text-types' import { @@ -17,6 +20,7 @@ import { import type { ContentEntrySkeleton, ContentfulEntry } from '../../services/contentful-client' import { injectContentfulEntry } from '../../services/entry' import { NgLiveUpdates } from '../../services/live-updates' +import { NgContentfulOptimization } from '../../services/optimization' // — Badge — @@ -57,8 +61,26 @@ function escape(text: string): string { } type NodeRenderer = (children: () => string, data: Record) => string +type MergeTagValueResolver = (target: MergeTagEntry) => string | undefined + +/** + * Substitution outcome tracked alongside a single rich-text walk. Callers pass + * a fresh cell in; the walker flips `resolved` to true or false depending on + * whether the first encountered merge tag returned a value. Kept out of the + * entry-resolution layer so it lives next to the badge that consumes it — + * mirrors the Next.js reference which resolves at render time via + * `OptimizedEntryRenderContext.getMergeTagValue`. + */ +interface MergeTagRenderState { + resolved: boolean | undefined +} + +// `INLINES.EMBEDDED_ENTRY` is a string enum member; widen it once at module +// scope so the render walk compares plain strings and `@typescript-eslint/no- +// unsafe-enum-comparison` stays quiet without a cast at the call site. +const EMBEDDED_ENTRY_NODE_TYPE: string = INLINES.EMBEDDED_ENTRY -const RENDERERS: Partial> = { +const BLOCK_RENDERERS: Partial> = { [BLOCKS.PARAGRAPH]: (children) => `

${children()}

`, [BLOCKS.HEADING_1]: (children) => `

${children()}

`, [BLOCKS.HEADING_2]: (children) => `

${children()}

`, @@ -79,16 +101,38 @@ const RENDERERS: Partial> = { }, } -function renderNode(node: unknown): string { +function renderMergeTag( + data: Record, + getMergeTagValue: MergeTagValueResolver | undefined, + state: MergeTagRenderState, +): string { + if (!('target' in data)) return '' + const { target } = data + if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return '' + const value = getMergeTagValue?.(target) + if (value !== undefined) { + state.resolved = true + return escape(value) + } + state.resolved ??= false + return typeof target.fields.nt_fallback === 'string' ? escape(target.fields.nt_fallback) : '' +} + +function renderNode( + node: unknown, + getMergeTagValue: MergeTagValueResolver | undefined, + state: MergeTagRenderState, +): string { if (!isRecord(node)) return '' const nodeType = typeof node.nodeType === 'string' ? node.nodeType : '' const value = typeof node.value === 'string' ? node.value : '' const content = Array.isArray(node.content) ? node.content : [] const data = isRecord(node.data) ? node.data : {} - const children = (): string => content.map((c) => renderNode(c)).join('') + const children = (): string => content.map((c) => renderNode(c, getMergeTagValue, state)).join('') if (nodeType === 'text') return escape(value) - const { [nodeType]: renderer } = RENDERERS + if (nodeType === EMBEDDED_ENTRY_NODE_TYPE) return renderMergeTag(data, getMergeTagValue, state) + const { [nodeType]: renderer } = BLOCK_RENDERERS return renderer !== undefined ? renderer(children, data) : children() } @@ -108,6 +152,7 @@ export class EntryCard { private readonly sanitizer = inject(DomSanitizer) private readonly liveUpdatesService = inject(NgLiveUpdates) + private readonly optimization = inject(NgContentfulOptimization) private readonly isLive = computed(() => { if (this.liveUpdatesService.previewPanelVisible()) return true @@ -120,14 +165,29 @@ export class EntryCard { manualTracking: this.manualTracking, }) - protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId) - protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) - protected readonly richTextHtml = computed(() => { + // Rich text and merge-tag detection share a single walk so the badge signal + // reflects the substitution outcome for exactly this render. + private readonly renderedRichText = computed< + { html: SafeHtml; mergeTagResolved: boolean | undefined } | undefined + >(() => { const { entry } = this.resolved() const doc = Object.values(entry.fields).find(isRichTextDocument) if (!doc) return undefined - return this.sanitizer.bypassSecurityTrustHtml(renderNode(doc)) + const profile = this.optimization.profile() + const runtime = this.optimization.runtime() + const getMergeTagValue = profile + ? (target: MergeTagEntry): string | undefined => runtime.getMergeTagValue(target, profile) + : undefined + const state: MergeTagRenderState = { resolved: undefined } + const html = this.sanitizer.bypassSecurityTrustHtml(renderNode(doc, getMergeTagValue, state)) + return { html, mergeTagResolved: state.resolved } }) + + protected readonly effectiveTestId = computed(() => this.testId() ?? this.resolved().baselineId) + protected readonly isVariant = computed(() => this.resolved().optimizationId !== undefined) + protected readonly richTextHtml = computed( + () => this.renderedRichText()?.html, + ) protected readonly entryText = computed(() => { const text: unknown = this.resolved().entry.fields.text return typeof text === 'string' ? text : 'No content' @@ -139,8 +199,7 @@ export class EntryCard { : [] }) protected readonly badges = computed(() => { - const r = this.resolved() - const mergeTag = mergeTagKey(r.mergeTagResolved) + const mergeTag = mergeTagKey(this.renderedRichText()?.mergeTagResolved) const scenario = this.clickScenario() const keys: BadgeKey[] = [ ...(mergeTag ? [mergeTag] : []), diff --git a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts index 2983ad439..4606ca108 100644 --- a/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts +++ b/implementations/web-sdk_angular/src/app/components/tracking-log/index.ts @@ -1,5 +1,6 @@ -import { Component, DestroyRef, computed, inject, signal } from '@angular/core' +import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' +import type { OptimizationEventStreamEvent } from '@contentful/optimization-web/core-sdk' import { interval } from 'rxjs' import { NgContentfulOptimization } from '../../services/optimization' @@ -28,18 +29,29 @@ function timeAgo(firedAt: number, now: number): string { return `${Math.floor(m / MINUTES_PER_HOUR)}h` } +type StreamEvent = OptimizationEventStreamEvent +type EventOfType = Extract + @Component({ selector: 'app-tracking-log', templateUrl: './index.html', }) export class TrackingLog { private readonly optimization = inject(NgContentfulOptimization) + private readonly destroyRef = inject(DestroyRef) private readonly events = signal>(new Map()) private readonly rawEventsCount = signal(0) private readonly tick = toSignal(interval(TICK_INTERVAL_SECONDS * MS_PER_SECOND), { initialValue: 0, }) + + // Per-event-type sequence counters. Externalized from the dispatch so each + // handler stays a straight input→track mapping without threading closure + // state through the switch. + private pageSeq = 0 + private componentSeq = 0 + protected readonly rawEventsDisplay = this.rawEventsCount.asReadonly() protected readonly displayEvents = computed(() => { this.tick() @@ -50,80 +62,99 @@ export class TrackingLog { }) constructor() { - const { optimization } = this - const { context } = optimization - if (context.platform !== 'browser') return - const { sdk } = context - - let pageSeq = 0 - let componentSeq = 0 - const sub = sdk.states.eventStream.subscribe((raw) => { - if (raw != null) { - this.rawEventsCount.update((n) => n + 1) - } - switch (raw?.type) { - case 'page': { - const { - properties: { url }, - } = raw - pageSeq += 1 - const pathname = (() => { - try { - return new URL(url, window.location.origin).pathname - } catch { - return url - } - })() - this.track({ type: 'page', value: pathname, key: `page-${pageSeq}-${url}` }) - break - } - case 'component': { - const { componentId, viewId, viewDurationMs } = raw - if (viewId) { - this.track({ - type: 'view', - value: componentId, - key: `view-${viewId}`, - viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, - }) - } else { - componentSeq += 1 - this.track( - { type: 'comp', value: componentId, key: `component-${componentId}-${componentSeq}` }, - `event-component-${componentId}`, - ) - } - break - } - case 'component_hover': { - const { componentId, hoverId, hoverDurationMs } = raw - if (hoverId) { - this.track({ - type: 'hover', - value: componentId, - key: `component_hover-hover-${hoverId}`, - hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, - hoverId, - }) - } else { - this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) - } - break - } - case 'component_click': { - const { componentId } = raw - this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` }) - break - } - default: - break - } + let sub: { unsubscribe: () => void } | undefined = undefined + + // Re-subscribe when the runtime swaps from the SSR snapshot runtime to + // the live SDK. The snapshot runtime's static eventStream never emits, so + // the initial subscription is a harmless no-op that is torn down on swap. + effect(() => { + const runtime = this.optimization.runtime() + sub?.unsubscribe() + sub = runtime.states.eventStream.subscribe((raw) => { + this.dispatch(raw) + }) }) - inject(DestroyRef).onDestroy(() => { - sub.unsubscribe() + + this.destroyRef.onDestroy(() => { + sub?.unsubscribe() }) } + private dispatch(raw: StreamEvent | undefined): void { + if (raw == null) return + this.rawEventsCount.update((n) => n + 1) + + switch (raw.type) { + case 'page': + this.handlePage(raw) + break + case 'component': + this.handleComponent(raw) + break + case 'component_hover': + this.handleHover(raw) + break + case 'component_click': + this.handleClick(raw) + break + default: + break + } + } + + private handlePage(raw: EventOfType<'page'>): void { + const { + properties: { url }, + } = raw + this.pageSeq += 1 + const pathname = (() => { + try { + return new URL(url, window.location.origin).pathname + } catch { + return url + } + })() + this.track({ type: 'page', value: pathname, key: `page-${this.pageSeq}-${url}` }) + } + + private handleComponent(raw: EventOfType<'component'>): void { + const { componentId, viewId, viewDurationMs } = raw + if (viewId) { + this.track({ + type: 'view', + value: componentId, + key: `view-${viewId}`, + viewDurationMs: typeof viewDurationMs === 'number' ? viewDurationMs : undefined, + }) + return + } + this.componentSeq += 1 + this.track( + { type: 'comp', value: componentId, key: `component-${componentId}-${this.componentSeq}` }, + `event-component-${componentId}`, + ) + } + + private handleHover(raw: EventOfType<'component_hover'>): void { + const { componentId, hoverId, hoverDurationMs } = raw + if (hoverId) { + this.track({ + type: 'hover', + value: componentId, + key: `component_hover-hover-${hoverId}`, + hoverDurationMs: typeof hoverDurationMs === 'number' ? hoverDurationMs : undefined, + hoverId, + }) + return + } + this.track({ type: 'hover', value: componentId, key: `component_hover-${componentId}` }) + } + + private handleClick(raw: EventOfType<'component_click'>): void { + const { componentId } = raw + this.track({ type: 'click', value: componentId, key: `component_click-${componentId}` }) + } + private track( event: Omit, testId?: string, diff --git a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts index 5a1db829c..e1d8685be 100644 --- a/implementations/web-sdk_angular/src/app/pages/page-two/index.ts +++ b/implementations/web-sdk_angular/src/app/pages/page-two/index.ts @@ -30,12 +30,10 @@ export class PageTwo { } protected readonly trackConversion = (): void => { - this.optimization.ifBrowser((sdk) => { - void sdk.trackView({ - componentId: PAGE_TWO_COMPONENT_ID, - viewId: crypto.randomUUID(), - viewDurationMs: 0, - }) + void this.optimization.runtime().trackView({ + componentId: PAGE_TWO_COMPONENT_ID, + viewId: crypto.randomUUID(), + viewDurationMs: 0, }) } } diff --git a/implementations/web-sdk_angular/src/app/services/contentful-client.ts b/implementations/web-sdk_angular/src/app/services/contentful-client.ts index ee103c970..8292a9cfd 100644 --- a/implementations/web-sdk_angular/src/app/services/contentful-client.ts +++ b/implementations/web-sdk_angular/src/app/services/contentful-client.ts @@ -1,7 +1,14 @@ -import { inject, Injectable, resource, TransferState, type ResourceRef } from '@angular/core' +import { + inject, + Injectable, + makeStateKey, + resource, + TransferState, + type ResourceRef, + type StateKey, +} from '@angular/core' import type { ContentfulClientApi, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import { getOrCreateBaseClient, NG_CONTENTFUL_OPTIMIZATION_CONFIG } from '../config' -import { SERVER_BASELINES_KEY } from '../transfer-state-keys' export interface ContentEntryFields { text?: EntryFieldTypes.Text | EntryFieldTypes.RichText @@ -13,6 +20,14 @@ export type ContentfulEntry = Entry const INCLUDE_DEPTH = 10 +/** + * Baseline CDA entries keyed by id. Stamped by the SSR preflight so the + * browser can skip a duplicate CDA fetch on hydration. Lives next to + * {@link NgContentfulClient} because that class is the sole reader. + */ +export const SERVER_BASELINES_KEY: StateKey> = + makeStateKey>('ssr-baselines') + @Injectable({ providedIn: 'root' }) export class NgContentfulClient { private readonly client: ContentfulClientApi diff --git a/implementations/web-sdk_angular/src/app/services/entry.ts b/implementations/web-sdk_angular/src/app/services/entry.ts index 9c706f1f3..8dd41bf33 100644 --- a/implementations/web-sdk_angular/src/app/services/entry.ts +++ b/implementations/web-sdk_angular/src/app/services/entry.ts @@ -6,14 +6,11 @@ import { ElementRef, inject, signal, - TransferState, untracked, type Signal, } from '@angular/core' import type { Entry } from 'contentful' -import { SERVER_RESOLVED_ENTRIES_KEY } from '../transfer-state-keys' -import { resolveEntryMergeTags } from './merge-tags' import { NgContentfulOptimization } from './optimization' export type ObservationMode = 'auto' | 'manual' @@ -25,10 +22,12 @@ export interface ResolvedEntry { optimizationId: string | undefined sticky: boolean | undefined variantIndex: number | undefined - mergeTagResolved: boolean | undefined } function setupManualTracking(result: Signal, manualTracking: Signal): void { + // `runtime().tracking.*` is a NOOP on the server snapshot runtime and the + // real web SDK after hydration, so the wiring below can run unconditionally. + // `afterNextRender` already guards DOM access — it never fires on the server. const optimization = inject(NgContentfulOptimization) const elementRef = inject>(ElementRef) const destroyRef = inject(DestroyRef) @@ -40,18 +39,14 @@ function setupManualTracking(result: Signal, manualTracking: Sign }) function track(): void { - optimization.ifBrowser((sdk) => { - const { entryId, optimizationId, sticky, variantIndex } = result() - sdk.tracking.enableElement('views', elementRef.nativeElement, { - data: { entryId, optimizationId, sticky, variantIndex }, - }) + const { entryId, optimizationId, sticky, variantIndex } = result() + optimization.runtime().tracking.enableElement('views', elementRef.nativeElement, { + data: { entryId, optimizationId, sticky, variantIndex }, }) } function clear(): void { - optimization.ifBrowser((sdk) => { - sdk.tracking.clearElement('views', elementRef.nativeElement) - }) + optimization.runtime().tracking.clearElement('views', elementRef.nativeElement) } effect(() => { @@ -74,7 +69,6 @@ export function injectContentfulEntry({ manualTracking?: Signal }): Signal { const optimization = inject(NgContentfulOptimization) - const transferState = inject(TransferState) function liveRead(sig: Signal): T { if (isLive()) return sig() @@ -85,48 +79,21 @@ export function injectContentfulEntry({ return untracked(sig) ?? sig() } - const variant = computed(() => { + const result = computed(() => { + const runtime = optimization.runtime() const raw = entry() - if (optimization.context.platform === 'browser') { - return { - raw, - resolved: optimization.context.sdk.resolveOptimizedEntry( - raw, - liveRead(optimization.selectedOptimizations), - ), - } - } - // Server render: lift the server-resolved entry from TransferState if present - // so the initial HTML reflects the personalized variant. Falls back to the - // baseline when no handoff exists (e.g. consent denied — server skipped resolve). - const handoff = transferState.get(SERVER_RESOLVED_ENTRIES_KEY, undefined) - return { + const resolved = runtime.resolveOptimizedEntry( raw, - resolved: handoff?.[raw.sys.id] ?? { entry: raw, selectedOptimization: undefined }, - } - }) - - const result = computed(() => { - const { raw, resolved } = variant() - const profile = liveRead(optimization.profile) - let mergeTagResolved: boolean | undefined = undefined - const entry = resolveEntryMergeTags(resolved.entry, (target) => { - const value = profile - ? optimization.ifBrowser((sdk) => sdk.getMergeTagValue(target, profile)) - : undefined - if (value !== undefined) mergeTagResolved = true - else mergeTagResolved ??= false - return value ?? target.fields.nt_fallback - }) + liveRead(optimization.selectedOptimizations), + ) return { - entry, + entry: resolved.entry, baselineId: raw.sys.id, entryId: resolved.entry.sys.id, optimizationId: resolved.selectedOptimization?.experienceId, sticky: resolved.selectedOptimization?.sticky, variantIndex: resolved.selectedOptimization?.variantIndex, - mergeTagResolved, } }) diff --git a/implementations/web-sdk_angular/src/app/services/live-updates.ts b/implementations/web-sdk_angular/src/app/services/live-updates.ts index c23025e27..b1083cb94 100644 --- a/implementations/web-sdk_angular/src/app/services/live-updates.ts +++ b/implementations/web-sdk_angular/src/app/services/live-updates.ts @@ -14,11 +14,11 @@ export class NgLiveUpdates { private readonly optimization = inject(NgContentfulOptimization) private readonly globalLiveUpdatesSignal = signal(false) - private readonly previewPanelAttached = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.previewPanelAttached), + private readonly previewPanelAttached = fromSdkState( + () => this.optimization.runtime().states.previewPanelAttached, ) - private readonly previewPanelOpen = fromSdkState(() => - this.optimization.ifBrowser((sdk) => sdk.states.previewPanelOpen), + private readonly previewPanelOpen = fromSdkState( + () => this.optimization.runtime().states.previewPanelOpen, ) readonly globalLiveUpdates = this.globalLiveUpdatesSignal.asReadonly() diff --git a/implementations/web-sdk_angular/src/app/services/merge-tags.ts b/implementations/web-sdk_angular/src/app/services/merge-tags.ts deleted file mode 100644 index ac4dfebea..000000000 --- a/implementations/web-sdk_angular/src/app/services/merge-tags.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - isMergeTagEntry, - isRecord, - isRichTextDocument, - type MergeTagEntry, -} from '@contentful/optimization-web/api-schemas' -import { INLINES, type Text } from '@contentful/rich-text-types' -import type { Entry } from 'contentful' - -/** - * Resolves the substitution value for a single merge-tag entry. Returning - * `undefined` lets the walker fall back to `target.fields.nt_fallback`. - */ -export type MergeTagResolver = (target: MergeTagEntry) => string | undefined - -function resolveNode(node: unknown, resolveMergeTag: MergeTagResolver): unknown { - if (!isRecord(node)) return node - const { data } = node - if (node.nodeType === INLINES.EMBEDDED_ENTRY && isRecord(data)) { - const { target } = data - if (isMergeTagEntry(target)) { - return { - nodeType: 'text', - value: resolveMergeTag(target) ?? '', - marks: [], - data: {}, - } satisfies Text - } - } - if (Array.isArray(node.content)) { - return { ...node, content: node.content.map((child) => resolveNode(child, resolveMergeTag)) } - } - return node -} - -/** - * Walk every rich-text field on the entry and substitute merge-tag inline - * entries with text nodes produced by `resolveMergeTag`. Non-rich-text fields - * pass through unchanged. Runtime-agnostic — used by both the browser-side - * component layer and the SSR preflight. - */ -export function resolveEntryMergeTags(entry: Entry, resolveMergeTag: MergeTagResolver): Entry { - return Object.assign({}, entry, { - fields: Object.fromEntries( - Object.entries(entry.fields).map(([key, value]) => [ - key, - isRichTextDocument(value) ? resolveNode(value, resolveMergeTag) : value, - ]), - ), - }) as Entry -} diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index dc0fb3440..e3d29bc4a 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -3,6 +3,7 @@ import { DestroyRef, inject, Injectable, + makeStateKey, PLATFORM_ID, provideAppInitializer, REQUEST, @@ -11,22 +12,25 @@ import { TransferState, type EnvironmentProviders, type Signal, + type StateKey, + type WritableSignal, } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import type NodeContentfulOptimizationType from '@contentful/optimization-node' -import type { OptimizationData } from '@contentful/optimization-node/api-schemas' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import type { CoreStatelessRequest, UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' import ContentfulOptimization from '@contentful/optimization-web' -import { - isResolvedContentfulEntry, - type Profile, - type SelectedOptimizationArray, -} from '@contentful/optimization-web/api-schemas' +import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' import { createScopedLogger } from '@contentful/optimization-web/logger' +import { + createWebSnapshotRuntime, + type OptimizationSnapshot, + type WebOptimizationRuntime, +} from '@contentful/optimization-web/runtime' import type { Entry } from 'contentful' import { PAGES } from 'e2e-web' import { filter } from 'rxjs/operators' @@ -36,35 +40,19 @@ import { NG_CONTENTFUL_OPTIMIZATION_CONFIG, resolveLogLevel, } from '../config' -import { - SERVER_BASELINES_KEY, - SERVER_OPTIMIZATION_KEY, - SERVER_RESOLVED_ENTRIES_KEY, - type ResolvedEntryData, - type ServerHandoff, -} from '../transfer-state-keys' import { fromSdkState } from '../utils' import { readConsentFromRequest } from './consent' -import { NgContentfulClient } from './contentful-client' -import { resolveEntryMergeTags } from './merge-tags' - -type NgContentfulOptimizationInstance = ContentfulOptimization +import { NgContentfulClient, SERVER_BASELINES_KEY } from './contentful-client' /** - * Runtime context exposed by the {@link NgContentfulOptimization} service. - * The `sdk` here is the **browser** SDK (`@contentful/optimization-web`), - * which reads `localStorage` at construction time and therefore cannot be - * instantiated server-side. Discriminating on `platform` lets callers branch - * on the runtime without dereferencing an optional chain. - * - * The **Node** SDK (`@contentful/optimization-node`) does run server-side, - * but it is intentionally not surfaced through this service — it is owned by - * the preflight at the bottom of this file and only its **results** cross - * into the browser bundle via `TransferState`. + * SSR handoff for the personalization runtime. Stamped by the server preflight, + * read on the browser to seed the initial snapshot runtime before the live SDK + * takes over. The shape matches {@link OptimizationSnapshot} so the same + * request-scoped payload backs `createSnapshotRuntime` on both sides of the + * hydration boundary. */ -type NgContentfulOptimizationContext = - | { readonly platform: 'server' } - | { readonly platform: 'browser'; readonly sdk: NgContentfulOptimizationInstance } +const SERVER_OPTIMIZATION_KEY: StateKey = + makeStateKey('ssr-optimization') /** * Shared SDK-config mapping used by both the browser Web SDK constructor and @@ -92,11 +80,12 @@ function toSdkConstructorArgs(config: NgContentfulOptimizationConfig): { } } -let instance: NgContentfulOptimizationInstance | undefined = undefined +let instance: ContentfulOptimization | undefined = undefined const previewPanelLogger = createScopedLogger('AngularReference:PreviewPanel') +const hydrationLogger = createScopedLogger('AngularReference:SsrHydration') async function attachPreviewPanel( - sdk: NgContentfulOptimizationInstance, + sdk: ContentfulOptimization, config: NgContentfulOptimizationConfig, ): Promise { const contentfulClient = getOrCreateBaseClient(config) @@ -108,9 +97,38 @@ async function attachPreviewPanel( }) } -function getOrCreateInstance( +// Kept as module-scope helpers (rather than instance methods) so SonarQube +// typescript:S7059 does not fire on in-constructor async work. + +function hydrateSnapshotAndPromote( + sdk: ContentfulOptimization, + snapshot: OptimizationSnapshot | undefined, + runtimeSignal: WritableSignal, +): void { + if (!snapshot?.data) { + runtimeSignal.set(sdk) + return + } + hydrateOptimizationData(sdk, snapshot.data) + .then(() => { + runtimeSignal.set(sdk) + }) + .catch((error: unknown) => { + hydrationLogger.warn('Failed to hydrate live SDK from SSR snapshot.', error) + runtimeSignal.set(sdk) + }) +} + +function attachPreviewPanelSafely( + sdk: ContentfulOptimization, config: NgContentfulOptimizationConfig, -): NgContentfulOptimizationInstance { +): void { + attachPreviewPanel(sdk, config).catch((error: unknown) => { + previewPanelLogger.warn('Failed to attach the Contentful Optimization preview panel.', error) + }) +} + +function getOrCreateInstance(config: NgContentfulOptimizationConfig): ContentfulOptimization { instance ??= new ContentfulOptimization({ ...toSdkConstructorArgs(config), autoTrackEntryInteraction: config.autoTrackEntryInteraction ?? { @@ -123,14 +141,18 @@ function getOrCreateInstance( } /** - * Single SDK service exposed to components. On the browser it owns a real - * {@link ContentfulOptimization} instance; on the server it surfaces the SSR - * handoff so templates render the personalised state without ever touching the - * Web SDK (which would crash on `localStorage`). + * Single SDK service exposed to components. Both server and browser see the + * same {@link WebOptimizationRuntime}: on the server (and during the initial + * client render) it is a read-only {@link createWebSnapshotRuntime} backed by + * the SSR handoff, with `tracking.*` and `trackCurrentPage` as inert no-ops; + * on the browser after construction it swaps to the live + * {@link ContentfulOptimization}. Every member — resolvers, `states`, event + * actions, and even the browser-only tracking imperatives — is safe to call + * unconditionally in components. */ @Injectable({ providedIn: 'root' }) export class NgContentfulOptimization { - readonly context: NgContentfulOptimizationContext + readonly runtime: Signal readonly consent: Signal readonly profile: Signal readonly selectedOptimizations: Signal @@ -141,45 +163,44 @@ export class NgContentfulOptimization { const destroyRef = inject(DestroyRef) const transferState = inject(TransferState) const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) - const handoff = transferState.get(SERVER_OPTIMIZATION_KEY, undefined) + const snapshot = transferState.get( + SERVER_OPTIMIZATION_KEY, + undefined, + ) + + const runtimeSignal = signal(createWebSnapshotRuntime(snapshot)) + this.runtime = runtimeSignal.asReadonly() + this.consent = fromSdkState(() => runtimeSignal().states.consent) + this.profile = fromSdkState(() => runtimeSignal().states.profile) + this.selectedOptimizations = fromSdkState(() => runtimeSignal().states.selectedOptimizations) if (!isBrowser) { - // Seed the read-only signals from the SSR handoff so server-rendered - // templates reflect the same consent/profile state the server preflight - // observed. Without this, JS-disabled clients would see "undefined" / - // "0 active optimizations" in the Utilities panel even though the entry - // markup is fully personalised. - this.context = { platform: 'server' } - this.consent = signal(handoff?.consent).asReadonly() - this.profile = signal(handoff?.profile).asReadonly() - this.selectedOptimizations = signal( - handoff?.selectedOptimizations, - ).asReadonly() + // Server render: the snapshot runtime satisfies the full seam. Reads + // flow through `states.*`, resolvers/getMergeTagValue are pure, event + // actions are inert dev-warn no-ops, and `tracking.*` is a NOOP object. return } const sdk = getOrCreateInstance(config) - this.context = { platform: 'browser', sdk } + + // Prime the live SDK with the server-computed snapshot before promoting + // it to the runtime signal, so the first live render matches the SSR + // HTML (same selectedOptimizations, same profile, same merge tags). + // With no server data (consent denied or preflight skipped), the snapshot + // runtime and the fresh live SDK already share the same initial state, so + // we can swap immediately. + hydrateSnapshotAndPromote(sdk, snapshot, runtimeSignal) if (config.previewPanel !== undefined) { - void attachPreviewPanel(sdk, config).catch((error: unknown) => { - previewPanelLogger.warn( - 'Failed to attach the Contentful Optimization preview panel.', - error, - ) - }) + attachPreviewPanelSafely(sdk, config) } - this.consent = fromSdkState(sdk.states.consent) - this.profile = fromSdkState(sdk.states.profile) - this.selectedOptimizations = fromSdkState(sdk.states.selectedOptimizations) - // Page events fire on every route change. The first NavigationEnd after // hydration is skipped when the server preflight already emitted page() // for the same route (consent was granted server-side) — without this // skip, analytics double-counts the SSR landing page. Subsequent // navigations always emit. - let skipNextPage = handoff?.consent ?? false + let skipNextPage = snapshot?.consent ?? false const routerSubscription = router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => { @@ -198,16 +219,6 @@ export class NgContentfulOptimization { instance = undefined }) } - - /** - * Run an SDK side-effect on the browser. Returns the callback's value on the - * browser branch, and `undefined` on the server (where there is no SDK to - * call). Lets call sites avoid an `if (context.platform === 'browser')` - * narrowing dance for fire-and-forget toggles. - */ - ifBrowser(fn: (sdk: NgContentfulOptimizationInstance) => T): T | undefined { - return this.context.platform === 'browser' ? fn(this.context.sdk) : undefined - } } // ── Server-side preflight ────────────────────────────────────────────────── @@ -242,20 +253,6 @@ async function createServerOptimization( return new NodeContentfulOptimization(toSdkConstructorArgs(config)) } -/** - * Outcome of the server-side preflight for one SSR request. Discriminated on - * `consentGranted` so callers either get the full personalization context or - * a "no SDK work happened" branch — never a half-populated value. - */ -type ServerOptimizationData = - | { readonly consentGranted: false } - | { - readonly consentGranted: true - readonly data: OptimizationData - readonly profileId: string - readonly canPersistProfile: boolean - } - /** * Build an event context for the SSR `forRequest()` call so the server-side * page event carries the current route. Without this, route-targeted @@ -277,13 +274,25 @@ function createServerEventContext(request: Request, locale: string): UniversalEv } } -async function getServerOptimizationData( +interface ServerPreflightOutcome { + readonly snapshot: OptimizationSnapshot + readonly profileId: string | undefined + readonly canPersistProfile: boolean +} + +async function computeSnapshot( sdk: NodeContentfulOptimizationType, request: Request, consentGranted: boolean, locale: string, -): Promise { - if (!consentGranted) return { consentGranted: false } +): Promise { + if (!consentGranted) { + return { + snapshot: { consent: false, locale }, + profileId: undefined, + canPersistProfile: false, + } + } const anonymousId = readAnonymousId(request) const requestOptimization: CoreStatelessRequest = sdk.forRequest({ @@ -293,55 +302,26 @@ async function getServerOptimizationData( ...(anonymousId === undefined ? {} : { profile: { id: anonymousId } }), }) const pageResult = await requestOptimization.page() - if (!pageResult.accepted) return { consentGranted: false } - const data: OptimizationData | undefined = pageResult.data - if (!data) return { consentGranted: false } + if (!pageResult.accepted || !pageResult.data) { + return { + snapshot: { consent: false, locale }, + profileId: undefined, + canPersistProfile: false, + } + } return { - consentGranted: true, - data, - profileId: data.profile.id, + snapshot: { + consent: true, + persistenceConsent: requestOptimization.canPersistProfile, + locale, + data: pageResult.data, + }, + profileId: pageResult.data.profile.id, canPersistProfile: requestOptimization.canPersistProfile, } } -/** - * Resolve baselines against the SSR `selectedOptimizations`, then walk each - * rich-text field and substitute inline merge-tag entries using the SDK's - * `getMergeTagValue`. Shipping fully-resolved entries through `TransferState` - * lets JS-disabled clients see the variant content AND the personalised merge - * tags on first paint, not just the placeholder text. - * - * Nested entries (`fields.nested[]`) often appear only on the *resolved* - * variant rather than on the baseline, so we recurse after each - * `resolveOptimizedEntry` call to cover per-level Personalization - * assignments inside the chosen variant. - */ -function resolveServerEntries( - sdk: NodeContentfulOptimizationType, - baselines: readonly Entry[], - selectedOptimizations: OptimizationData['selectedOptimizations'], - profile: Profile | undefined, -): Record { - const resolved: Record = {} - const queue: Entry[] = [...baselines] - for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) { - if (Object.hasOwn(resolved, entry.sys.id)) continue - const result = sdk.resolveOptimizedEntry(entry, selectedOptimizations) - const entryWithMergeTags = profile - ? resolveEntryMergeTags(result.entry, (target) => sdk.getMergeTagValue(target, profile)) - : result.entry - resolved[entry.sys.id] = { ...result, entry: entryWithMergeTags } - const nested: unknown = entryWithMergeTags.fields.nested - if (Array.isArray(nested)) { - for (const child of nested) { - if (isResolvedContentfulEntry(child)) queue.push(child) - } - } - } - return resolved -} - function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { const headers = responseInit.headers instanceof Headers @@ -351,28 +331,6 @@ function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string) responseInit.headers = headers } -function stampServerHandoff( - transferState: TransferState, - serverData: ServerOptimizationData, - baselines: readonly Entry[], - resolvedEntries: Record, -): void { - const handoff: ServerHandoff = serverData.consentGranted - ? { - consent: true, - profile: serverData.data.profile, - profileId: serverData.profileId, - selectedOptimizations: serverData.data.selectedOptimizations, - } - : { consent: false } - transferState.set(SERVER_OPTIMIZATION_KEY, handoff) - transferState.set>( - SERVER_BASELINES_KEY, - Object.fromEntries(baselines.map((baseline) => [baseline.sys.id, baseline])), - ) - transferState.set>(SERVER_RESOLVED_ENTRIES_KEY, resolvedEntries) -} - async function runServerPreflight(): Promise { const request = inject(REQUEST, { optional: true }) if (!request) return @@ -387,19 +345,17 @@ async function runServerPreflight(): Promise { const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] const baselines = await contentful.fetchEntries(baselineIds) - const serverData = await getServerOptimizationData(sdk, request, consentGranted, config.locale) + const outcome = await computeSnapshot(sdk, request, consentGranted, config.locale) - if (serverData.consentGranted && serverData.canPersistProfile && responseInit) { - persistAnonymousIdCookie(responseInit, serverData.profileId) + if (outcome.canPersistProfile && outcome.profileId && responseInit) { + persistAnonymousIdCookie(responseInit, outcome.profileId) } - const resolvedEntries = resolveServerEntries( - sdk, - baselines, - serverData.consentGranted ? serverData.data.selectedOptimizations : [], - serverData.consentGranted ? serverData.data.profile : undefined, + transferState.set(SERVER_OPTIMIZATION_KEY, outcome.snapshot) + transferState.set>( + SERVER_BASELINES_KEY, + Object.fromEntries(baselines.map((baseline) => [baseline.sys.id, baseline])), ) - stampServerHandoff(transferState, serverData, baselines, resolvedEntries) } /** diff --git a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts b/implementations/web-sdk_angular/src/app/transfer-state-keys.ts deleted file mode 100644 index a3d719e90..000000000 --- a/implementations/web-sdk_angular/src/app/transfer-state-keys.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { makeStateKey, type StateKey } from '@angular/core' -import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { ResolvedData } from '@contentful/optimization-web/core-sdk' -import type { Entry, EntrySkeletonType } from 'contentful' - -export type ResolvedEntryData = ResolvedData - -/** - * Snapshot of the personalization context resolved server-side. Stamped into - * `TransferState` during SSR and read by browser code on hydration. The - * personalization fields are present only when consent was granted server-side - * and the preflight successfully fetched `OptimizationData`. - */ -export interface ServerHandoff { - readonly consent: boolean - readonly profile?: Profile - readonly profileId?: string - readonly selectedOptimizations?: SelectedOptimizationArray -} - -export const SERVER_OPTIMIZATION_KEY: StateKey = - makeStateKey('ssr-optimization') - -/** - * Server-resolved entries keyed by baseline entry id. The value is the raw - * `sdk.resolveOptimizedEntry()` output (`{ entry, selectedOptimization? }`) - * — no additional restructuring — so browser hydration can consume the SDK's - * native shape directly. - */ -export const SERVER_RESOLVED_ENTRIES_KEY: StateKey> = - makeStateKey>('ssr-resolved-entries') - -/** - * Baseline CDA entries keyed by id. Carried separately from the resolved - * payload so the browser can skip a duplicate CDA fetch on hydration without - * conflating the original entry with the resolved variant. - */ -export const SERVER_BASELINES_KEY: StateKey> = - makeStateKey>('ssr-baselines') diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 7eb29da7a..8273fac8f 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -1,6 +1,6 @@ import { createContext } from 'react' -import type { WebOptimizationRuntime } from '../runtime/webRuntime' +import type { WebOptimizationRuntime } from '@contentful/optimization-web/runtime' export type OptimizationSdk = WebOptimizationRuntime diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 900d7f8ff..e03406f70 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -19,8 +19,11 @@ import { type ReactElement, } from 'react' +import { + createWebSnapshotRuntime, + type WebOptimizationRuntime, +} from '@contentful/optimization-web/runtime' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' -import { createWebSnapshotRuntime, type WebOptimizationRuntime } from '../runtime/webRuntime' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. diff --git a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts deleted file mode 100644 index e7a3b2a6a..000000000 --- a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type ContentfulOptimization from '@contentful/optimization-web' -import { - createSnapshotRuntime, - type OptimizationRuntime, - type OptimizationSnapshot, -} from '@contentful/optimization-web/runtime' - -type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' - -export interface WebOptimizationRuntime - extends OptimizationRuntime, Pick {} - -const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { - enable: () => undefined, - disable: () => undefined, - enableElement: () => undefined, - disableElement: () => undefined, - clearElement: () => undefined, -} - -export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { - const runtime = createSnapshotRuntime(snapshot) - - return Object.assign(runtime, { - tracking: NOOP_TRACKING, - trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), - }) -} diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 0f8e846a1..79087cb89 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -135,8 +135,8 @@ "index.mjs": 12500, "bridge-support.cjs": 1200, "bridge-support.mjs": 1200, - "runtime.cjs": 800, - "runtime.mjs": 200, + "runtime.cjs": 900, + "runtime.mjs": 320, "contentful-optimization-web-components.umd.js": 39000, "presentation.cjs": 3200, "presentation.mjs": 3200, diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index d75894f49..c16273c63 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -1,7 +1,79 @@ /** * Framework-neutral runtime contracts for browser and snapshot-backed presentation layers. * + * Re-exports the universal {@link OptimizationRuntime} / {@link createSnapshotRuntime} from + * `@contentful/optimization-core/runtime` and adds the web-only composition + * ({@link WebOptimizationRuntime} / {@link createWebSnapshotRuntime}) that stitches in + * browser-only imperative APIs (`tracking`, `trackCurrentPage`) so framework layers can + * bind a single runtime object across server rendering and browser hydration. + * * @packageDocumentation */ +import { + createSnapshotRuntime, + type OptimizationRuntime, + type OptimizationSnapshot, +} from '@contentful/optimization-core/runtime' +import type ContentfulOptimization from './ContentfulOptimization' + export * from '@contentful/optimization-core/runtime' + +/** + * Members of the live web SDK that go beyond the universal + * {@link OptimizationRuntime} surface: browser-only imperative APIs used inside + * effects (never during render). + * + * @internal + */ +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' + +/** + * The single runtime object framework layers (React Web, Angular, etc.) can consume. + * + * @remarks + * Composes the universal {@link OptimizationRuntime} (pure resolvers, read `states`, event + * actions — safe on server and client) with the browser-only web SDK surface + * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} instance + * satisfies it by construction; a server / initial-render backing is produced by + * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops. + * + * Every member is safe to reference in any environment: render-time members behave + * correctly on the server, and effect-only members are no-ops there (the server never + * runs effects, so this only matters defensively). + * + * @public + */ +export interface WebOptimizationRuntime + extends OptimizationRuntime, Pick {} + +const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { + enable: () => undefined, + disable: () => undefined, + enableElement: () => undefined, + disableElement: () => undefined, + clearElement: () => undefined, +} + +/** + * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves and reads from the snapshot, with browser-only + * tracking APIs as inert no-ops. + * + * @remarks + * Used by framework providers during server rendering and the initial client render, + * before the live SDK exists. Delegates the universal surface to + * {@link createSnapshotRuntime} and adds no-op `tracking` / `trackCurrentPage`. + * + * @public + */ +export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { + const runtime = createSnapshotRuntime(snapshot) + + return Object.assign(runtime, { + tracking: NOOP_TRACKING, + trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + }) +} From c0800a20f2ff01918d251d895c6f13978afbee42 Mon Sep 17 00:00:00 2001 From: Charles Hudson <265039+phobetron@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:15:38 +0200 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=94=A5=20feat(cda):=20Add=20managed?= =?UTF-8?q?=20entry=20fetching=20(#360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Core `contentful` configuration, cached `fetchContentfulEntry()`, and `fetchOptimizedEntry()` APIs for stateful and request-bound stateless SDK flows. Introduce shared entry-source lifecycle support, optimized-entry metadata, and managed-entry keying so Web Components, React Web, React Native, and Next.js helpers can resolve entries from `entryId`/`entryQuery` while preserving the manual `baselineEntry` path. Add React Web SSR and hybrid entry handoff support with `serverOptimizedEntries`, prefetch helpers, snapshot-runtime fetch guards, and hydration-safe managed entry lookup. Add Next.js App Router and Pages Router managed-entry support, including server-rendered ``, Pages Router `prefetchOptimizedEntries`, server-fetched entry handoffs, and client-safe provider config. Refresh docs, guides, reference implementations, and tests for managed entry fetching across Core, Web Components, React Web, React Native, and Next.js. Adjust the Web Components UMD gzip budget for the managed-entry surface while keeping event constants readable. [[NT-3558](https://contentful.atlassian.net/browse/NT-3558)] --- AGENTS.md | 10 + CONTRIBUTING.md | 18 +- STYLE_GUIDE.md | 4 + documentation/AGENTS.md | 3 + documentation/concepts/README.md | 5 +- ...anagement-in-the-optimization-sdk-suite.md | 4 + .../concepts/core-state-management.md | 35 +- ...-personalization-and-variant-resolution.md | 120 +++++-- ...king-in-node-and-stateless-environments.md | 11 +- .../interaction-tracking-in-web-sdks.md | 24 +- ...-handling-in-the-optimization-sdk-suite.md | 84 +++-- ...nchronization-between-client-and-server.md | 16 +- documentation/guides/AGENTS.md | 6 +- documentation/guides/README.md | 4 + ...-custom-javascript-optimization-adapter.md | 208 +++++++++++ .../guides/choosing-the-right-sdk.md | 41 ++- ...t-to-analytics-and-tag-management-tools.md | 23 +- .../integrating-the-node-sdk-in-a-node-app.md | 159 +++++---- ...mization-sdk-in-a-nextjs-app-router-app.md | 20 +- ...zation-sdk-in-a-nextjs-pages-router-app.md | 13 +- ...-react-native-sdk-in-a-react-native-app.md | 168 +++++---- ...rating-the-react-web-sdk-in-a-react-app.md | 125 ++++--- .../integrating-the-web-sdk-in-a-web-app.md | 105 +++--- ...k-reference-implementation-requirements.md | 29 +- .../product/optimization-sdk-suite-fprd.md | 20 +- implementations/AGENTS.md | 16 +- implementations/node-sdk+web-sdk/README.md | 7 + implementations/node-sdk/AGENTS.md | 2 +- implementations/node-sdk/README.md | 7 + implementations/react-web-sdk/README.md | 11 +- implementations/web-sdk/AGENTS.md | 4 +- implementations/web-sdk/README.md | 7 + implementations/web-sdk_angular/README.md | 7 + implementations/web-sdk_react/AGENTS.md | 2 +- implementations/web-sdk_react/README.md | 7 + packages/AGENTS.md | 9 +- packages/node/node-sdk/README.md | 48 ++- packages/node/node-sdk/src/core-sdk.ts | 6 + packages/react-native-sdk/README.md | 53 ++- .../src/components/OptimizedEntry.test.tsx | 130 ++++++- .../src/components/OptimizedEntry.tsx | 323 ++++++++++++------ .../src/hooks/useOptimizedEntry.test.tsx | 211 ++++++++++++ .../src/hooks/useOptimizedEntry.ts | 259 ++++++++++++++ packages/react-native-sdk/src/index.ts | 3 + packages/universal/api-schemas/README.md | 8 +- packages/universal/core-sdk/README.md | 61 +++- packages/universal/core-sdk/package.json | 16 +- packages/universal/core-sdk/rslib.config.ts | 1 + .../universal/core-sdk/src/CoreBase.test.ts | 274 ++++++++++++++- packages/universal/core-sdk/src/CoreBase.ts | 238 ++++++++++++- .../core-sdk/src/CoreStateful.test.ts | 29 +- .../core-sdk/src/CoreStateless.test.ts | 130 +++++++ .../core-sdk/src/CoreStatelessRequest.ts | 75 +++- .../core-sdk/src/OptimizedEntryMetadata.ts | 31 ++ .../OptimizedEntrySourceController.test.ts | 204 +++++++++++ .../src/OptimizedEntrySourceController.ts | 227 ++++++++++++ .../universal/core-sdk/src/entry-source.ts | 13 + packages/universal/core-sdk/src/index.ts | 1 + packages/web/frameworks/nextjs-sdk/README.md | 25 +- .../web/frameworks/nextjs-sdk/package.json | 8 +- .../nextjs-sdk/src/app-router-client.test.tsx | 43 ++- .../nextjs-sdk/src/app-router-client.ts | 28 +- .../nextjs-sdk/src/app-router-server.test.tsx | 35 +- .../nextjs-sdk/src/app-router-server.tsx | 70 +++- .../nextjs-sdk/src/bound-component-types.ts | 12 +- .../src/pages-router-server.test.ts | 64 +++- .../nextjs-sdk/src/pages-router-server.ts | 18 + .../nextjs-sdk/src/pages-router.test.tsx | 44 +++ .../frameworks/nextjs-sdk/src/pages-router.ts | 16 +- .../frameworks/nextjs-sdk/src/server.test.tsx | 92 +++++ .../web/frameworks/nextjs-sdk/src/server.tsx | 75 ++++ .../web/frameworks/react-web-sdk/README.md | 92 +++-- .../web/frameworks/react-web-sdk/package.json | 4 +- .../src/context/OptimizationContext.tsx | 3 + .../web/frameworks/react-web-sdk/src/index.ts | 7 + .../optimized-entry/OptimizedEntry.test.tsx | 176 ++++++++++ .../src/optimized-entry/OptimizedEntry.tsx | 234 +++++++++++-- .../optimized-entry/optimizedEntryUtils.ts | 21 +- .../useOptimizedEntry.test.tsx | 207 ++++++++++- .../src/optimized-entry/useOptimizedEntry.ts | 161 ++++++++- ...ptimizationProvider.onStatesReady.test.tsx | 5 +- .../src/provider/OptimizationProvider.tsx | 33 +- .../src/server-optimized-entries.ts | 6 + .../react-web-sdk/src/test/sdkTestUtils.tsx | 15 +- packages/web/web-sdk/README.md | 50 ++- packages/web/web-sdk/package.json | 2 +- .../web/web-sdk/src/ContentfulOptimization.ts | 2 +- .../entry-tracking/EntryInteractionRuntime.ts | 2 +- .../OptimizedEntryController.test.ts | 43 ++- .../presentation/OptimizedEntryController.ts | 61 +++- .../web/web-sdk/src/presentation/index.ts | 13 + .../presentation/optimizationRootRuntime.ts | 4 +- packages/web/web-sdk/src/runtime.ts | 37 +- .../ContentfulOptimizationRootElement.ts | 70 ++-- .../ContentfulOptimizedEntryElement.ts | 175 +++++++--- .../web-sdk/src/web-components/index.test.ts | 165 ++++++++- 96 files changed, 4998 insertions(+), 795 deletions(-) create mode 100644 documentation/guides/building-a-custom-javascript-optimization-adapter.md create mode 100644 packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx create mode 100644 packages/react-native-sdk/src/hooks/useOptimizedEntry.ts create mode 100644 packages/universal/core-sdk/src/OptimizedEntryMetadata.ts create mode 100644 packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts create mode 100644 packages/universal/core-sdk/src/OptimizedEntrySourceController.ts create mode 100644 packages/universal/core-sdk/src/entry-source.ts create mode 100644 packages/web/frameworks/react-web-sdk/src/server-optimized-entries.ts diff --git a/AGENTS.md b/AGENTS.md index 37c4b9490..43a078ce3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,16 @@ Repository-wide baseline. Child files add local constraints; the nearest child f - Implementations consume local package tarballs from `pkgs/` after `pnpm build:pkgs` plus the implementation install step. +## Reference implementations + +- Treat `implementations/**` as first-class product artifacts: maintained SDK integration contracts, + customer-facing evidence, and required validation targets for the SDK surfaces they exercise. +- Treat broken, stale, incomplete, or needlessly reduced reference implementations as SDK-suite + quality issues unless evidence shows the affected behavior is no longer supported. +- Do not treat reference implementations as optional demos, disposable samples, or places to reduce + behavior for convenience. When public SDK behavior changes, identify affected reference + implementations before concluding validation. + ## Code discipline - Treat [`eslint.config.ts`](./eslint.config.ts) as an upfront design constraint. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d64eb7c42..e1ac68fb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,14 +85,14 @@ pnpm version:pnpm ## Repository map -| Path | Purpose | -| -------------------- | ----------------------------------------------------------------------------------- | -| `lib/` | Internal shared tooling and mock services, such as `build-tools` and `mocks` | -| `packages/` | Workspace packages, including the published SDKs and framework layers | -| `implementations/` | Reference applications used for integration testing, local demos, and E2E coverage | -| `pkgs/` | Generated tarballs created by `pnpm build:pkgs`; implementations install from these | -| `docs/` | Generated TypeDoc output | -| `dist/`, `coverage/` | Generated build and test artifacts inside individual workspaces | +| Path | Purpose | +| -------------------- | ------------------------------------------------------------------------------------------ | +| `lib/` | Internal shared tooling and mock services, such as `build-tools` and `mocks` | +| `packages/` | Workspace packages, including the published SDKs and framework layers | +| `implementations/` | Reference applications used for integration testing, validation evidence, and E2E coverage | +| `pkgs/` | Generated tarballs created by `pnpm build:pkgs`; implementations install from these | +| `docs/` | Generated TypeDoc output | +| `dist/`, `coverage/` | Generated build and test artifacts inside individual workspaces | The most important repository-specific mechanic is this: @@ -335,7 +335,7 @@ category: caveats, and links to guides, reference implementations, and generated API reference. - Lower-level package READMEs explain the package's role in the SDK stack, who uses it directly, common setup options where useful, and where exhaustive API details live. -- Reference implementation READMEs stay procedural: what the implementation demonstrates, +- Reference implementation READMEs stay procedural: what the implementation validates, prerequisites, setup, run/test commands, environment notes, and related package links. - Internal and placeholder READMEs stay short, explicit, and status-oriented. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 081645b71..6e0d9d60c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -116,6 +116,10 @@ Use the repository-standard product names and terms from `AGENTS.md`, including: - reference implementation - exact package names, such as `@contentful/optimization-web` +When referring to in-repo apps under `implementations/`, use `reference implementation` or +`reference app`. Do not use `demo`, `sample`, or `example` to describe their status; reserve +`example` for code snippets or package-local harnesses when accurate. + Use Contentful product terms consistently when they apply: - `app` - An HTML5 application that extends the functionality of the Contentful web app or diff --git a/documentation/AGENTS.md b/documentation/AGENTS.md index 1c558976c..61ad62a81 100644 --- a/documentation/AGENTS.md +++ b/documentation/AGENTS.md @@ -34,6 +34,9 @@ Applies to authored documentation under `documentation/`. - In guides, do not place concept links in the opening before quick-start material unless the concept is required for safe action. Put deeper mechanics links after the relevant step or in a `## Learn more` section. +- Treat reference implementations as maintained validation evidence and comparison targets for + supported integration paths. Do not frame them as optional examples, disposable samples, or + lower-stakes app code. - Guides, concepts, and product documents may link to docs, package READMEs, implementation READMEs, and generated reference docs, but not directly to source code, tests, generated outputs, or source line numbers. diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md index e0d7c52b3..bc301b0e6 100644 --- a/documentation/concepts/README.md +++ b/documentation/concepts/README.md @@ -29,8 +29,9 @@ they are not the first stop for installation or setup commands. explains how SDK consent state, event allow-lists, blocked-event diagnostics, persistence, and application-owned CMP policy work together to support consent-aware integrations. - [Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md) - - explains how the SDK resolves a Contentful baseline entry to the selected entry variant, including - data model expectations, fallback behavior, resolution paths, and preview overrides. + explains how the SDK resolves a manual `baselineEntry` or SDK-managed Contentful fetch with + `contentful: { client }` to the selected entry variant, including single-locale CDA shape + expectations, fallback behavior, framework `entryId` paths, and preview overrides. - [Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md) - explains how application-owned Contentful locales differ from SDK Experience/event locales. - [Interaction tracking in Web SDKs](./interaction-tracking-in-web-sdks.md) - explains how diff --git a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md index 541866ab8..a38ce4b5d 100644 --- a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md @@ -130,6 +130,10 @@ Account for these constraints before wiring lifecycle details: - **Storage availability** - Platform storage is a durability layer, not the live source of truth. If browser storage, AsyncStorage, UserDefaults, or SharedPreferences is unavailable or blocked, design the application to continue from runtime state. +- **Managed entry fetching** - SDK-managed entry fetching still uses the application-configured + `contentful.js` client and does not change consent ownership. Apply the same CMP, routing, locale, + and cache policy before choosing manual `baselineEntry` resolution or managed + `fetchOptimizedEntry()`. - **Offline queue purge** - Withdrawing event consent with `consent(false)` purges queued SDK Experience and Insights events. Blocked events are not replayed when consent later becomes true. - **Preview mode** - Preview panels and preview overrides change live-update and preview behavior; diff --git a/documentation/concepts/core-state-management.md b/documentation/concepts/core-state-management.md index c1fabd5d4..58ce1f604 100644 --- a/documentation/concepts/core-state-management.md +++ b/documentation/concepts/core-state-management.md @@ -79,12 +79,14 @@ Swift or Kotlin published state in native runtimes, and request objects in Node/ ### Key state definitions **Locale state** is the SDK Experience API and event locale. It is not a Contentful Delivery API -locale and it does not fetch localized entries for your application. In stateful JavaScript -runtimes, `locale` is available as `sdk.locale` and `sdk.states.locale`; `setLocale()` updates the -state signal, future Experience API requests, and default event context. React providers can update -provider-owned SDK instances from their `locale` prop. iOS and Android expose the same value as -native `locale` state from the bridge. Node and stateless runtimes bind locale per request with -`forRequest({ locale })`. +locale policy and changing it does not by itself refetch localized entries for your application. In +stateful JavaScript runtimes, `locale` is available as `sdk.locale` and `sdk.states.locale`; +`setLocale()` updates the state signal, future Experience API requests, and default event context. +JavaScript managed fetching can use this value only as a fallback `getEntry()` query locale when no +Contentful query locale is provided. Request-bound Node clients use `forRequest({ locale })` as that +fallback for managed Contentful entry fetching. React providers can update provider-owned SDK +instances from their `locale` prop. iOS and Android expose the same value as native `locale` state +from the bridge. Node and stateless runtimes bind locale per request with `forRequest({ locale })`. **`experienceRequestState`** tells you what happened to the most recent Experience API request. It starts as `{ status: 'idle' }`, changes to `{ status: 'pending' }` when a request starts, changes to @@ -98,6 +100,11 @@ available. `states.optimizationPossible` can be `true` before `states.selectedOp contains variants. Use `states.canOptimize` when you need to know whether variant selection data is available for entry resolution. +**Selected optimization state** is the current Experience API selection array used by stateful entry +resolution. In stateful JavaScript runtimes, `resolveOptimizedEntry(entry)` uses +`states.selectedOptimizations.current` when explicit selections are omitted. In stateful Core and +Web runtimes, `fetchOptimizedEntry(entryId)` follows the same default. + ### Signals and observables `CoreStateful` stores runtime state in [Preact Signals](https://github.com/preactjs/signals), a @@ -156,11 +163,14 @@ still current and has not already produced an accepted event. The SDK locale affects default Experience API requests and event context. It does not modify your Contentful client, router, native localization, or server i18n state. Fetch Contentful entries with the application-owned CDA locale, and pass SDK locale separately when Experience API responses and -events need that locale. +events need that locale. JavaScript managed fetching can use SDK locale only as the fallback +Contentful `getEntry()` query locale when `contentful.defaultQuery` and the per-call query omit +`locale`. `setLocale(locale)` validates and normalizes explicit locale values. Invalid explicit values throw without changing locale state. In stateful SDKs, changing locale affects future requests and events; -it does not automatically refetch profile state or selected optimizations. +it does not automatically refetch profile state, selected optimizations, or localized Contentful +entries. ### Defaults, storage, offline, and preview state @@ -275,6 +285,10 @@ instead of writing signal values directly. Think about state changes as a few fl - **Experience-producing events** - `identify`, `page`, `screen`, `track`, and sticky `trackView` send an Experience API request when consent or the allow-list permits it. A successful response publishes profile, selected optimizations, changes, and request status in one state batch. +- **SDK-managed entry fetching** - `fetchContentfulEntry(entryId)` and + `fetchOptimizedEntry(entryId)` call the configured consumer-owned `contentful.js` client. They do + not publish profile, selected optimizations, changes, or diagnostics events. Resolution reads + current selected optimization state only when options omit explicit selections. - **Insights and diagnostics events** - `trackView`, `trackClick`, `trackHover`, and `trackFlagView` can update `eventStream` when the event is accepted. Sticky `trackView` also uses the Experience path before sending its paired Insights event. @@ -288,6 +302,11 @@ Flag reads only produce accepted flag-view delivery when event consent is `true` `allowedEventTypes` permits `flag` or `component`, and an active Optimization profile ID exists. Reads before either condition is true do not update the accepted flag-view dedupe signature. +The SDK-managed Contentful entry cache is separate from optimization state. It caches CDA entries +returned by the configured `contentful.js` client and does not store profile or selected +optimization data. Use `clearContentfulEntryCache()` when application locale, preview mode, +environment, or cache policy changes make those cached entries invalid. + The package also exports raw `signals` and `signalFns` references for SDK layers and first-party preview tooling. Those exports are not application consumer APIs. Application code must treat them as read-only implementation details and use the methods, observables, defaults, and interceptors diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index bc9b009a4..3affd0dc6 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -35,6 +35,7 @@ Contentful and SDK Experience/event locale handling, see - [Multiple attached optimizations](#multiple-attached-optimizations) - [Where resolution happens](#where-resolution-happens) - [Resolve directly with SDK methods](#resolve-directly-with-sdk-methods) + - [Manage entry sources in custom adapters](#manage-entry-sources-in-custom-adapters) - [Render with framework components](#render-with-framework-components) - [Preview selected variants](#preview-selected-variants) - [Related documentation](#related-documentation) @@ -57,12 +58,14 @@ The Experience API owns profile evaluation and returns the selected experience a Contentful owns entry delivery and link resolution. The SDK joins those two data sets in memory and returns either the baseline entry or a resolved variant entry. -Applications still own consent, identity, routing, Contentful fetching, and component rendering -policy. After those inputs exist, entry resolution provides the content decision for the current -profile or request. +Applications still own Contentful client configuration, locale choice, cache policy, consent, +identity, routing, and component rendering policy. JavaScript runtimes can either receive a manual +`baselineEntry` or call the app-provided `contentful.js` client through SDK-managed fetching. After +those inputs exist, entry resolution provides the content decision for the current profile or +request. ```text -Application fetches Contentful baseline entry +Application provides a Contentful baseline entry or SDK fetches one by entry ID -> Application or SDK emits an Experience event -> SDK event result exposes data.selectedOptimizations -> SDK resolver matches selectedOptimizations to entry.fields.nt_experiences @@ -78,15 +81,15 @@ it to entry resolution. Entry resolution is available across the Optimization SDK Suite, but the public surface depends on the runtime: -| Runtime | Direct API | Component or native rendering API | -| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| Core | `CoreStateful.resolveOptimizedEntry()` and `CoreStateless.resolveOptimizedEntry()` | None | -| Web | `ContentfulOptimization.resolveOptimizedEntry()` | `ctfl-optimized-entry` Web Component | -| React Web | `useEntryResolver()` and the underlying Web SDK method | `useOptimizedEntry()` and `OptimizedEntry` | -| React Native | `useEntryResolver()` and the underlying React Native SDK method | `OptimizedEntry` | -| Node | `ContentfulOptimization.resolveOptimizedEntry()` | None | -| iOS | `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` | SwiftUI `OptimizedEntry`; UIKit can call the client directly | -| Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` | +| Runtime | Direct API | Component or native rendering API | +| ------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Core | `resolveOptimizedEntry()` and managed `fetchOptimizedEntry()` | None | +| Web | `resolveOptimizedEntry()` and managed `fetchOptimizedEntry()` | `ctfl-optimized-entry` Web Component | +| React Web | `useEntryResolver()` and the underlying Web SDK method | `useOptimizedEntry()` and `OptimizedEntry` | +| React Native | `useEntryResolver()`, `useOptimizedEntry()`, and the underlying React Native SDK method | `OptimizedEntry` | +| Node | `resolveOptimizedEntry()` and managed `fetchOptimizedEntry()` | None | +| iOS | `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` | SwiftUI `OptimizedEntry`; UIKit can call the client directly | +| Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` | For Next.js App Router integrations, prefer the app-local bound `OptimizedEntry` returned by `createNextjsAppRouterOptimization()` from `@contentful/optimization-nextjs/app-router`. In Server @@ -94,7 +97,9 @@ Components it resolves through the Node SDK and server data; in Client Component name resolves through React Web. Pages Router integrations use `createNextjsPagesRouterOptimization()` from `@contentful/optimization-nextjs/pages-router` for client rendering, and routes that resolve entries manually can call `getServerTrackingAttributes()` -when they need SSR tracking attributes. +or `ServerOptimizedEntry` when they need SSR tracking attributes. `ServerOptimizedEntry` accepts +either manual `baselineEntry` and `resolvedData` props or the result returned by managed +`fetchOptimizedEntry()`. ## Inputs and constraints @@ -116,8 +121,10 @@ Usable entry selections depend on runtime state before the resolver runs: - **Preview overrides** - Preview tooling mutates `selectedOptimizations` by applying audience or variant overrides. The resolver still receives an ordinary selection array and follows the same local matching rules. -- **Resolution boundary** - Entry resolution stays local and fail-soft. It does not retry events, - fetch Contentful entries, override consent policy, or throw for personalization misses. +- **Resolution boundary** - Synchronous entry resolution stays local and fail-soft. SDK-managed + fetching is an additive JavaScript path that calls the configured `contentful.js` client before + running the same resolver. Neither path retries Experience events, overrides consent policy, or + throws for personalization misses. ## Single-locale CDA entry contract @@ -132,13 +139,36 @@ and raw CDA `locale=*` return locale-keyed field maps instead of direct values, time, so locale-keyed maps do not match the `OptimizedEntry` schema and resolution falls back to the baseline entry. -Fetch the entry with a single CDA locale and enough include depth for optimization links: +JavaScript managed fetching is the preferred path when the application already owns a configured +`contentful.js` client. Configure the SDK with that client and a concrete single CDA locale: JavaScript runtimes / TypeScript: ```ts const appLocale = getAppLocale() +const optimization = new ContentfulOptimization({ + clientId, + contentful: { + client: contentfulClient, + defaultQuery: { locale: appLocale }, + }, + environment, + locale: appLocale, +}) + +const { baselineEntry, entry } = await optimization.fetchOptimizedEntry(entryId, { + selectedOptimizations, + query: { + locale: appLocale, + }, +}) +``` + +Manual `baselineEntry` fetching remains supported and unchanged. Fetch the entry with a single CDA +locale and enough include depth for optimization links: + +```ts const optimization = new ContentfulOptimization({ clientId, environment, @@ -151,9 +181,16 @@ const baselineEntry = await contentfulClient.getEntry(entryId, { }) ``` -Use an application-owned Contentful locale for CDA entry fetches that feed entry resolution. The SDK -top-level `locale` or Node `forRequest({ locale })` configures the SDK Experience/event locale, but -it does not modify Contentful client requests for you. For the full locale model, see +Managed fetching merges `contentful.defaultQuery`, per-call query overrides, the SDK `locale` +fallback, and `include: 10`. Request-bound Node clients use `forRequest({ locale })` as the locale +fallback for managed Contentful entry fetching. Managed fetching also keeps a small per-SDK-instance +entry cache by default. Set `contentful.cache: false` when the host application must own all +Contentful entry caching. + +Use an application-owned Contentful locale for manual CDA entry fetches that feed entry resolution. +The SDK top-level `locale` or Node `forRequest({ locale })` configures the SDK Experience/event +locale, but it does not modify manual Contentful client requests for you. For the full locale model, +see [Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md). For entries that will be passed to the Optimization SDK resolver, use a concrete locale instead of @@ -413,6 +450,10 @@ const { entry, selectedOptimization } = optimization.resolveOptimizedEntry( ) ``` +For managed fetching, root stateless SDKs still need explicit selections for personalized results. +Request-bound `forRequest()` clients store the latest accepted Experience response and use its +`selectedOptimizations` when `fetchOptimizedEntry(entryId)` omits the option. + The Web, React Web, and React Native SDKs are stateful. If callers omit `selectedOptimizations`, those SDKs resolve from their `selectedOptimizations` state. Passing an explicit array is still useful for server-provided or request-local data because it avoids depending on ambient SDK state. @@ -435,6 +476,19 @@ Call it from a coroutine when resolving directly, or use Compose `OptimizedEntry resolved `entry` and optional `selectedOptimization` metadata and `optimizationContextId`, and returns the baseline unchanged when no selected optimization matches the entry. +### Manage entry sources in custom adapters + +`fetchOptimizedEntry(entryId)` is the one-shot JavaScript path for callers that can await a managed +Contentful fetch and immediate variant resolution. It returns the fetched `baselineEntry` plus the +resolved entry and optimization metadata. + +`OptimizedEntrySourceController` from `@contentful/optimization-core/entry-source` is the adapter +primitive for mounted components, hooks, custom elements, or other runtime wrappers that accept +either `baselineEntry` or `entryId`. It manages source changes, SDK readiness, loading and error +snapshots, stale fetch protection, and disconnect cleanup before resolution. It does not resolve or +render entries. After a snapshot contains `baselineEntry`, the adapter still calls +`resolveOptimizedEntry()`, renders the result, and attaches any tracking metadata for its runtime. + ### Render with framework components Use framework components or native view adapters when rendering is already inside a supported @@ -442,16 +496,20 @@ framework tree or native view adapter. These surfaces subscribe to SDK state, ca resolver, and pass the resolved entry to your rendering code. The Web SDK `ctfl-optimized-entry` custom element uses the same `OptimizedEntryController` as the -framework adapters. Assign the structured Contentful entry through the `baselineEntry` property, and -provide an SDK through either an ancestor or explicit `ctfl-optimization-root` binding or the -element's `sdk` property. The element honors `live-updates`, `track-clicks`, `track-hovers`, and -`track-views` attributes, applies `data-ctfl-*` tracking attributes to the host, and emits -`ctfl-entry-loading` and `ctfl-entry-resolved` events as the controller state changes. The Web SDK -README owns setup details for defining the custom elements and assigning property-only values. +framework adapters. Assign the structured Contentful entry through the `baselineEntry` property, or +configure the SDK with `contentful: { client }` and set `entry-id`/`entryId` plus optional +`entryQuery`. `baselineEntry` takes precedence when both are set. Provide an SDK through either an +ancestor or explicit `ctfl-optimization-root` binding or the element's `sdk` property. The element +honors `live-updates`, `track-clicks`, `track-hovers`, and `track-views` attributes, applies +`data-ctfl-*` tracking attributes to the host, and emits `ctfl-entry-loading`, +`ctfl-entry-resolved`, and `ctfl-entry-error` events as entry fetching and controller state changes. +The Web SDK README owns setup details for defining the custom elements and assigning property-only +values. React Web wraps resolution in `useOptimizedEntry()` and `OptimizedEntry`. `OptimizedEntry`: - Subscribes to `sdk.states.selectedOptimizations`. +- Accepts either `baselineEntry` or managed `entryId` with optional `entryQuery`. - Locks to the first non-`undefined` selected optimization set by default. - Re-resolves when `liveUpdates` is enabled globally, per component, or by the preview panel. - Shows a loading fallback for optimized entries until optimization state is available. @@ -463,9 +521,12 @@ React Web wraps resolution in `useOptimizedEntry()` and `OptimizedEntry`. `Optim React Web also exposes `useEntryResolver()` for components that need manual resolution without the `OptimizedEntry` wrapper. -React Native uses the same resolver inside its `OptimizedEntry` component. The component: +React Native wraps resolution in `useOptimizedEntry()` and `OptimizedEntry`. `OptimizedEntry`: - Passes non-optimized entries through unchanged. +- Accepts either `baselineEntry` or managed `entryId` with optional `entryQuery`. +- Renders `loadingFallback` or `null` while a managed entry is fetching. +- Renders `errorFallback` or `null` and calls `onEntryError` when managed entry fetching fails. - Subscribes to `states.selectedOptimizations` only for optimized entries. - Locks to the first selected optimization set by default. - Re-resolves when `liveUpdates` is enabled globally, per component, or by the preview panel. @@ -473,7 +534,9 @@ React Native uses the same resolver inside its `OptimizedEntry` component. The c - Sends view and tap tracking metadata from the resolved entry and `selectedOptimization`. React Native does not render DOM data attributes. It passes the resolved entry and optimization -metadata directly into its viewport and tap tracking hooks. +metadata directly into its viewport and tap tracking hooks after a real entry exists. +`useEntryResolver()` remains the manual-only helper when a component already owns its baseline +entry. SwiftUI uses the same resolver inside `OptimizedEntry`. The component passes non-optimized entries through unchanged, resolves optimized entries from the client's selected optimizations, can lock to @@ -523,6 +586,7 @@ Use these guides for SDK-specific integration paths: - [Integrating the Optimization React Web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md) - [Integrating the React Native SDK in a React Native app](../guides/integrating-the-react-native-sdk-in-a-react-native-app.md) - [Integrating the Node SDK in a Node app](../guides/integrating-the-node-sdk-in-a-node-app.md) +- [Building a custom JavaScript Optimization adapter](../guides/building-a-custom-javascript-optimization-adapter.md) - [Integrating the Optimization iOS SDK in a SwiftUI app](../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) - [Integrating the Optimization iOS SDK in a UIKit app](../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md) - [Integrating the Optimization Android SDK in a Jetpack Compose app](../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md) diff --git a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md index 91f078670..2f36e5d42 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -53,7 +53,7 @@ interaction decides which facts are available. | Path | Runtime responsibility | Use when | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. | +| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries with app-fetched `baselineEntry` or request-bound `fetchOptimizedEntry()`; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. | | `@contentful/optimization-web` | Own browser consent state, profile state, storage, automatic DOM observation, browser queues, and Insights delivery. | Non-React or custom browser code needs view, click, hover, route, or manual element tracking after HTML reaches the page. | | `@contentful/optimization-react-web` | Wrap the Web SDK with React browser providers, hooks, router trackers, and `OptimizedEntry` from `@contentful/optimization-react-web`. | React browser apps need framework-owned state, route page tracking, entry wrappers, or browser-side entry personalization. | | `@contentful/optimization-nextjs` | Own Next.js adapter surfaces: `/app-router` returns app-local App Router roots, providers, route trackers, and `OptimizedEntry`; `/pages-router` returns Pages Router client components; lower-level server, request, tracking-attribute, and client helpers remain available for manual paths. | Next.js apps need server-owned personalization, automatic profile handoff, SSR tracking attributes, and client tracking boundaries. | @@ -215,6 +215,15 @@ if (!pageResponse) { } ``` +When the Node SDK is configured with a consumer-owned `contentful.js` client, prefer +`requestOptimization.fetchOptimizedEntry(entryId)` after an accepted Experience call. The +request-bound client uses the latest `selectedOptimizations` when they are omitted. Manual +`baselineEntry` plus `resolveOptimizedEntry()` remains supported when the application owns CDA +fetching or caching directly. + +Both managed `fetchOptimizedEntry()` and manual `resolveOptimizedEntry()` expect single-locale CDA +entry shapes. Avoid `withAllLocales` and `locale=*` for optimization surfaces. + Use server-side `track()` for server-known business events: ```ts diff --git a/documentation/concepts/interaction-tracking-in-web-sdks.md b/documentation/concepts/interaction-tracking-in-web-sdks.md index 43ef77b26..f16e6aab8 100644 --- a/documentation/concepts/interaction-tracking-in-web-sdks.md +++ b/documentation/concepts/interaction-tracking-in-web-sdks.md @@ -67,8 +67,10 @@ allowed, which event type it becomes, and which queue receives it. | `@contentful/optimization-web` | Initializes Core for a browser runtime. Persists consent and, when persistence consent permits it, profile data, selected optimizations, and anonymous IDs. Discovers tracked DOM elements and observes interactions. | | `@contentful/optimization-react-web` | Creates and tears down the Web SDK instance, resolves entries in React, emits `data-ctfl-*` attributes, exposes the SDK instance, and emits router-driven `page()` calls. | -The application still owns Contentful fetching, rendering policy, consent UX, identity policy, route -ownership, and any business event taxonomy passed to `track()`. +The application owns rendering policy, consent UX, identity policy, route ownership, and any +business event taxonomy passed to `track()`. Contentful entry fetching is application-owned unless +the SDK is explicitly configured with a consumer-owned `contentful.js` client for managed entry +fetching. ## Runtime prerequisites and defaults @@ -376,7 +378,9 @@ SDK configuration values pass through to the underlying instance. `OptimizedEntry` does three tracking-related things: -- Resolves the baseline entry with `useOptimizedEntry()`. +- Resolves a provided baseline entry, or fetches the baseline first when `OptimizedEntry` or + `useOptimizedEntry()` receives an `entryId` and the SDK is configured with a `contentful.js` + client. - Renders a wrapper element with `display: contents`. - Adds tracking attributes for the resolved entry when resolved content is ready. @@ -397,6 +401,9 @@ The wrapper receives: `data-ctfl-duplication-scope` is emitted by React Web for optimization metadata, but the Web SDK entry interaction payload does not use it. +Managed entry fetching expects the same single-locale CDA entry shape as manual `baselineEntry` +resolution. Do not use `withAllLocales` or `locale=*` for Web or React Web optimization surfaces. + During loading, `OptimizedEntry` does not emit resolved entry tracking attributes. Loading UI is therefore not tracked as the resolved Contentful entry. If `children` is a direct `ReactNode` instead of a render prop, the wrapper still receives tracking attributes, but the child content does @@ -406,6 +413,12 @@ not change based on the resolved entry. a component uses `useOptimizedEntry()` directly, it must either render the `data-ctfl-*` attributes itself or use `sdk.tracking.enableElement(...)`. +> [!IMPORTANT] +> +> `OptimizedEntrySourceController` does not emit Web tracking attributes. Custom Web adapters that +> use it must render `data-ctfl-*` attributes after resolution or call +> `optimization.tracking.enableElement(...)` with equivalent data. + React Web router adapters emit `page()` calls when supported routers change route. They are page event helpers, not entry interaction detectors. Entry views, clicks, and hovers still come from the Web SDK runtime. @@ -466,7 +479,8 @@ provider children can emit router `page()` events or entry interactions. The Web SDKs do not own every part of tracking: -- They do not fetch Contentful entries. +- They do not infer Contentful entries from tracking metadata. Managed entry fetching requires an + explicitly configured `contentful.js` client. - They do not decide whether a user has granted consent. - They do not infer a browser view from server rendering alone. - They do not make non-clickable markup clickable. @@ -494,5 +508,7 @@ application. Step-by-step browser integration flow. - [Integrating the Optimization React Web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md) - Step-by-step React integration flow. +- [Building a custom JavaScript Optimization adapter](../guides/building-a-custom-javascript-optimization-adapter.md) - + Low-level entry-source lifecycle guidance for custom adapter authors. - [Forwarding Optimization SDK context to analytics and tag-management tools](../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) - Consent-aware forwarding, sticky-view dedupe, and Custom Flag analytics handoff. diff --git a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md index 106229ca6..eb0702e91 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -6,12 +6,12 @@ title: Locale handling in the Optimization SDK Suite Use this document to keep the application Contentful locale separate from the SDK Experience/event locale across Web, React Web, Next.js, Node, React Native, iOS, and Android applications. For -app-owned content fetching and entry resolution, the SDKs do not resolve Contentful locales, wrap +app-owned content fetching and entry resolution, the SDKs do not resolve Contentful locales, create Contentful Delivery API clients, or infer browser, device, or request locales. Applications choose -their own locale from routing, i18n, native state, or request logic and pass it to each system that -needs it. Preview and debug tooling is separate: preview-panel APIs can use Contentful clients or -pre-fetched entries to load Optimization definitions, but they do not choose or fetch locales for -application content. +their own locale from routing, i18n, native state, or request logic and pass it to manual Contentful +calls or SDK-managed entry fetching. Preview and debug tooling is separate: preview-panel APIs can +use Contentful clients or pre-fetched entries to load Optimization definitions, but they do not +choose or fetch locales for application content. For entry replacement mechanics, see [Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md). For @@ -64,23 +64,48 @@ locale is supported by Contentful. ## Application Contentful locale -Applications fetch Contentful entries directly. Choose an `appLocale` with application-owned logic, -then pass it to CDA or CPA calls. +Choose an `appLocale` with application-owned logic, then pass it to CDA or CPA calls. JavaScript +managed fetching uses the application-owned `contentful.js` client from `contentful: { client }`; +the SDK does not create clients, discover Contentful locales, infer browser or request locales, or +own locale policy. -Contentful fetch, JavaScript runtimes (TypeScript): +SDK-managed Contentful fetch, JavaScript runtimes (TypeScript): ```ts const appLocale = getAppLocale() +const optimization = new ContentfulOptimization({ + clientId, + contentful: { + client: contentfulClient, + defaultQuery: { locale: appLocale }, + }, + locale: appLocale, +}) + +const entry = await optimization.fetchContentfulEntry(entryId, { + locale: appLocale, +}) +``` + +Per-call `entryQuery` or `query` values override `contentful.defaultQuery`. If no Contentful query +locale is provided, managed fetching falls back to the SDK `locale` before calling +`contentful.js getEntry()`. Request-bound Node clients use `forRequest({ locale })` as that +fallback. Use a concrete locale such as `en-US`; do not use `withAllLocales` or `locale=*` for +entries that the SDK will resolve. + +Manual Contentful fetch, JavaScript runtimes (TypeScript): + +```ts const entry = await contentfulClient.getEntry(entryId, { include: 10, locale: appLocale, }) ``` -Do this anywhere Contentful content is fetched: browser data loaders, React hooks, server routes, -React Native services, and native app content clients. If the app omits `locale`, Contentful uses -the space default locale. +Pass the same `appLocale` anywhere Contentful content is fetched: browser data loaders, React hooks, +server routes, React Native services, and native app content clients. If the app omits `locale`, +Contentful uses the space default locale. Use the same `appLocale` in cache keys when localized content can differ. @@ -137,8 +162,10 @@ On iOS and Android, call `setLocale` only after the client has initialized; set through `OptimizationConfig` before mounting or initializing. `setLocale(locale)` validates and normalizes the SDK Experience/event locale. It does not refetch -Contentful content, update routes, or clear application caches. Application code must refetch -Contentful entries with its chosen Contentful locale. +Contentful content, update routes, or clear application caches. JavaScript managed fetching can use +the SDK locale only as the fallback `getEntry()` query locale when neither `contentful.defaultQuery` +nor a per-call query provides one. Application code must refetch Contentful entries with its chosen +Contentful locale. Web or React Web client runtime (TypeScript): @@ -247,21 +274,32 @@ const [entry, data] = await Promise.all([ `forRequest({ locale })` sets the request-bound Experience API locale and default event context locale. If both `locale` and `experienceOptions.locale` are supplied, `locale` wins. Use `experienceOptions.locale` only as an advanced low-level pass-through when `locale` is not supplied. +When a Node SDK is configured with `contentful: { client }`, root `fetchOptimizedEntry(entryId)` +needs explicit `selectedOptimizations` for personalized results. A request-bound `forRequest()` +client uses the latest accepted Experience response selections by default when +`fetchOptimizedEntry(entryId)` omits `selectedOptimizations`. It also uses the request `locale` as +the managed Contentful query locale when neither `contentful.defaultQuery` nor the per-call query +sets `locale`. ## Entry resolution and localized Contentful content Entry resolution expects one localized view of a baseline entry and linked optimization entries. Pass direct single-locale field values to the runtime-specific entry resolution surface: -- Web and Node `resolveOptimizedEntry()`. -- React Web and React Native `OptimizedEntry` and `useEntryResolver()`. -- React Web and manually wired Next.js client `useOptimizedEntry()`. +- Core, Web, and Node `fetchContentfulEntry()` and `fetchOptimizedEntry()` for JavaScript + SDK-managed fetching through an app-owned `contentful.js` client. +- Web and Node `resolveOptimizedEntry()` for manual baseline entries. +- React Web `OptimizedEntry` and `useOptimizedEntry()` with either `baselineEntry` or managed + `entryId` plus optional `entryQuery`. +- Web Component `ctfl-optimized-entry` with either `baselineEntry` or managed `entry-id`/`entryId` + plus optional `entryQuery`. - Next.js bound `OptimizedEntry` from `createNextjsAppRouterOptimization()` for App Router Server and Client Components, or from `createNextjsPagesRouterOptimization()` for Pages Router client - rendering. -- Manual Next.js server `resolveOptimizedEntry()`; pass the baseline entry and returned - `ResolvedData` to `getServerTrackingAttributes()` when server-rendered tracking attributes are - needed. + rendering. Lower-level server flows can use `resolveOptimizedEntry()` or managed + `fetchOptimizedEntry()`, then pass manual `baselineEntry` and `resolvedData` props or the managed + result to `ServerOptimizedEntry` when server-rendered tracking attributes are needed. +- React Native `OptimizedEntry` and `useOptimizedEntry()` with either `baselineEntry` or managed + `entryId` plus optional `entryQuery`; `useEntryResolver()` remains manual-only. - iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI `OptimizedEntry(entry:)`. - Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML @@ -271,7 +309,8 @@ Do not pass all-locale CDA responses from `withAllLocales` or `locale=*`. The SDK does not mutate application Contentful clients or infer when a content refetch is needed. When route or language state changes, the application must update SDK locale state, refetch -Contentful content with the app locale, and invalidate app caches as needed. +Contentful content with the app locale, clear SDK-managed Contentful entry cache entries when those +cached CDA results are no longer valid, and invalidate app caches as needed. ## Application responsibilities @@ -279,7 +318,8 @@ Applications own: - Choosing the application Contentful locale from routes, request context, i18n state, or native app state. -- Passing the Contentful locale to CDA and CPA requests. +- Passing the Contentful locale to manual CDA and CPA requests or SDK-managed `contentful.js` + fetching. - Passing the SDK Experience/event locale through top-level SDK `locale`, provider `locale`, Next.js `createNextjsAppRouterOptimization({ locale })`, Pages Router `getServerSideOptimizationProps()`, lower-level Next.js `getNextjsServerOptimizationData({ locale })`, Next.js ESR diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 11dd7f48c..dfc217cc3 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -120,12 +120,12 @@ For state shape and observable state mechanics, see The server, browser, and API each own different parts of the profile lifecycle: -| Runtime or layer | Owns | Does not own | -| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| **Experience API** | Profile creation, profile updates, audience evaluation, selected optimizations, and changes. | Application consent policy, cookie settings, Contentful fetching, rendering, or response caching. | -| **Node SDK** | Stateless Experience and Insights calls using request-scoped profile IDs and request options. | Cookies, sessions, long-lived profile state, browser storage, or consent state. | -| **Web SDK and React Web SDK** | Browser state, consent state, localStorage caches, readable anonymous-ID cookie, and queues. | Server sessions, server response caching, server-rendered hydration data, or Contentful fetching. | -| **Application** | Consent policy, identity policy, cookie attributes, request context, and cache boundaries. | The internal profile aggregation rules of the Experience API. | +| Runtime or layer | Owns | Does not own | +| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Experience API** | Profile creation, profile updates, audience evaluation, selected optimizations, and changes. | Application consent policy, cookie settings, Contentful fetching, rendering, or response caching. | +| **Node SDK** | Stateless Experience and Insights calls using request-scoped profile IDs and request options. | Cookies, sessions, long-lived profile state, browser storage, or consent state. | +| **Web SDK and React Web SDK** | Browser state, consent state, localStorage caches, readable anonymous-ID cookie, and queues. | Server sessions, server response caching, server-rendered hydration data, or Contentful entry fetching unless explicitly configured with a consumer-owned `contentful.js` client for managed fetching. | +| **Application** | Consent policy, identity policy, cookie attributes, request context, and cache boundaries. | The internal profile aggregation rules of the Experience API. | The React Web SDK uses the Web SDK under its providers and hooks, so profile synchronization follows the same browser mechanics. @@ -147,7 +147,7 @@ same evaluated data before its first client-side Experience response. | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. | | **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For App Router bound components, the bound server root or provider hands off the server `OptimizationData`. For Pages Router, pass the server `OptimizationData` from `pageProps` through `serverOptimizationState`. For React Web and manual Next.js provider or root handoff, pass the server `OptimizationData` through `serverOptimizationState`. | -| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. | +| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries in the app layer, or use SDK-managed fetching only when configured with a `contentful.js` client, after selected optimizations are available. | Direct Web SDK bootstrapping must use the same `OptimizationData` response that drove the server render: @@ -533,6 +533,8 @@ Use this checklist when implementing a hybrid Node and browser profile flow: match client-side resolution before the first browser Experience response. - Clear both browser state and server persistence when consent revocation must end profile continuity. +- Use single-locale CDA entry payloads for both manual `baselineEntry` resolution and SDK-managed + `fetchOptimizedEntry()`; avoid `withAllLocales` and `locale=*`. - Cache raw Contentful delivery payloads, not profile-evaluated SDK responses or personalized HTML unless the cache key varies on the full personalization context. diff --git a/documentation/guides/AGENTS.md b/documentation/guides/AGENTS.md index 55c124d05..088dce80d 100644 --- a/documentation/guides/AGENTS.md +++ b/documentation/guides/AGENTS.md @@ -368,8 +368,10 @@ When supported by the SDK/runtime, cover: ## Reference implementations -- Link only to monorepo reference implementation READMEs, not external demos or implementation - source files. +- Link only to monorepo reference implementation READMEs, not external demos, sample apps, or + implementation source files. +- Frame reference implementations as maintained comparison and validation targets for SDK behavior. + Do not present them as optional examples or lower-stakes sample code. - Mention relevant implementations briefly near the top only when they clarify the quick start or first required setup path; otherwise, expand links in `Reference implementations to compare against`. diff --git a/documentation/guides/README.md b/documentation/guides/README.md index d962e22c2..34499e148 100644 --- a/documentation/guides/README.md +++ b/documentation/guides/README.md @@ -12,6 +12,7 @@ children: - ./integrating-the-optimization-ios-sdk-in-a-uikit-app.md - ./integrating-the-optimization-android-sdk-in-a-compose-app.md - ./integrating-the-optimization-android-sdk-in-a-views-app.md + - ./building-a-custom-javascript-optimization-adapter.md - ./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md --- @@ -53,6 +54,9 @@ Native iOS and Android guides route to pre-release alpha surfaces. ## Supplemental guides +- [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) - + Build a low-level adapter only when no official SDK package fits your JavaScript runtime or + framework. - [Forwarding Optimization SDK context to analytics and tag-management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) - Forward optimization context to analytics, tag-management, customer-data, or product-analytics tools after SDK integration. diff --git a/documentation/guides/building-a-custom-javascript-optimization-adapter.md b/documentation/guides/building-a-custom-javascript-optimization-adapter.md new file mode 100644 index 000000000..32effbafc --- /dev/null +++ b/documentation/guides/building-a-custom-javascript-optimization-adapter.md @@ -0,0 +1,208 @@ +--- +title: Building a custom JavaScript Optimization adapter +--- + +# Building a custom JavaScript Optimization adapter + +Use this guide when you are building a JavaScript runtime or framework adapter and no official +Optimization SDK package fits that surface. + +## Do you need this? + +Do not build a custom adapter when Web, React Web, Next.js, Node, React Native, iOS, or Android +matches the application runtime. Those packages own rendering conventions, tracking integration, +consent defaults, route or screen lifecycle, preview behavior, and platform cleanup. + +Build here only when you own an adapter layer that must compose Core primitives into a runtime the +Optimization SDK Suite does not already cover. + +## Quick start + +**Adapt this to your use case:** + +```ts +import { CoreStateful } from '@contentful/optimization-core' +import { OptimizedEntrySourceController } from '@contentful/optimization-core/entry-source' + +const optimization = new CoreStateful({ + clientId, + environment, + locale: appLocale, + contentful: { + client: contentfulClient, + defaultQuery: { locale: appLocale }, + }, +}) + +const source = new OptimizedEntrySourceController() + +source.setSnapshotListener((snapshot) => { + if (snapshot.isLoading) { + renderLoading() + return + } + + if (snapshot.error) { + renderError(snapshot.error) + return + } + + if (!snapshot.baselineEntry) return + + const resolved = optimization.resolveOptimizedEntry(snapshot.baselineEntry) + renderResolvedEntry(resolved.entry, resolved.selectedOptimization) +}) + +source.updateOptions({ + entryId: 'hero-entry', + entryQuery: { locale: appLocale }, + sdk: optimization, + isSdkStateReady: true, +}) +``` + +Verify that the adapter renders a loading state, then renders either the selected variant or the +baseline fallback for one entry. + +
+ Table of Contents + + +- [Default recipe](#default-recipe) + - [Configure Core](#configure-core) + - [Drive source controller lifecycle](#drive-source-controller-lifecycle) + - [Resolve and render](#resolve-and-render) + - [Handle selected optimizations](#handle-selected-optimizations) +- [Runtime or vendor variants](#runtime-or-vendor-variants) + - [Browser adapters](#browser-adapters) + - [Core-only non-Web adapters](#core-only-non-web-adapters) + - [One-shot server paths](#one-shot-server-paths) +- [Validate the integration](#validate-the-integration) +- [Governance notes](#governance-notes) +- [Related guides and concepts](#related-guides-and-concepts) + + +
+ +## Default recipe + +### Configure Core + +Create the Contentful Delivery client in your application or adapter host, then pass it to Core: + +```ts +const optimization = new CoreStateful({ + clientId, + environment, + locale: appLocale, + contentful: { + client: contentfulClient, + defaultQuery: { locale: appLocale }, + cache: { maxEntries: 100, ttlMs: 300_000 }, + }, +}) +``` + +`contentful.defaultQuery` applies to every SDK-managed `getEntry()` call. Set +`contentful.cache: false` only when the host application must own all entry caching. + +### Drive source controller lifecycle + +Create one `OptimizedEntrySourceController` for each adapter instance that owns one entry source. +Call `updateOptions()` whenever adapter inputs change: + +- Pass `baselineEntry` when the host already fetched the entry. It takes precedence over `entryId`. +- Pass `entryId`, optional `entryQuery`, an SDK with `fetchContentfulEntry()`, and + `isSdkStateReady: true` when the adapter wants Core-managed fetching. +- Use `setSnapshotListener()` to schedule renders from loading, error, or `baselineEntry` snapshots. +- Use `getSnapshot()` when the runtime needs a synchronous current value. +- Call `disconnect()` when the adapter unmounts or disposes. + +The controller keys managed fetches by `entryId + entryQuery`, waits in loading state until the SDK +is ready, ignores stale fetch results after source changes, and clears in-flight ownership on +disconnect. `createOptimizedEntryLoadingEntry(entryId)` is available when a framework needs a stable +placeholder `Entry` shape during loading. + +### Resolve and render + +The source controller only produces a baseline entry. After a snapshot contains `baselineEntry`, +call `resolveOptimizedEntry()` and render the returned `entry`: + +```ts +const { entry, selectedOptimization, optimizationContextId } = optimization.resolveOptimizedEntry( + snapshot.baselineEntry, +) +``` + +Render the baseline entry when no variant resolves. Missing selections, unmatched optimization +metadata, unresolved Contentful links, and out-of-range variants are fallback cases, not adapter +errors. + +### Handle selected optimizations + +Stateful Core can resolve from its current `selectedOptimizations` state when you omit the second +argument. Emit a profile-producing event such as `page()` or `identify()` before expecting fresh +personalization, and subscribe to `states.selectedOptimizations` when the adapter supports live +updates. + +Stateless Core needs request-local selections. Use a request-bound `forRequest()` client when +available; it stores the latest accepted Experience response for that request. Root stateless +callers pass explicit `selectedOptimizations` to `resolveOptimizedEntry()` or +`fetchOptimizedEntry()`. + +## Runtime or vendor variants + +### Browser adapters + +For browser adapters that build on `@contentful/optimization-web`, prefer +`@contentful/optimization-web/presentation` when you also need Web presentation helpers such as +tracking attribute generation. For Core-only browser adapters, +`@contentful/optimization-core/entry-source` manages only the entry source lifecycle. + +After resolution, render Web tracking metadata yourself or register the element manually with +`optimization.tracking.enableElement(...)`. The entry-source controller does not emit `data-ctfl-*` +attributes. + +### Core-only non-Web adapters + +Non-Web adapters own their rendering and interaction model. Convert runtime view, click, hover, tap, +or screen behavior into the appropriate Core event calls, and pass the resolved entry plus +`selectedOptimization` metadata to your runtime-specific tracking layer. + +### One-shot server paths + +Use `fetchOptimizedEntry(entryId, options?)` instead of the source controller when there is no +mounted adapter lifecycle. It fetches the baseline entry through the configured `contentful.js` +client, resolves immediately, and returns `{ baselineEntry, entry, selectedOptimization }`. + +## Validate the integration + +- Confirm the adapter renders loading and error states without tracking placeholder content as the + resolved entry. +- Confirm `baselineEntry` takes precedence over `entryId`. +- Confirm `entryId` changes or `entryQuery` changes do not render stale fetch results. +- Confirm stateful adapters re-resolve when selected optimizations change, if live updates are part + of the adapter contract. +- Confirm custom Web adapters render valid `data-ctfl-*` attributes or call + `tracking.enableElement(...)` after resolution. + +## Governance notes + +Use one concrete Contentful CDA locale for entries passed to Optimization resolvers. Do not use +`contentful.js` `withAllLocales` or raw CDA `locale=*`; those responses produce locale-keyed field +maps instead of the direct field values the resolver expects. + +The adapter owns rendering, tracking, consent UI, route or screen events, Experience API event +timing, Contentful client creation, and teardown policy. Core owns shared optimization state, +events, managed entry fetching when configured, and local entry resolution. + +## Related guides and concepts + +- [Choosing the right SDK](./choosing-the-right-sdk.md) - Package selection before building a custom + adapter. +- [Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md) - + Resolver inputs, fallback behavior, and single-locale entry constraints. +- [Interaction tracking in Web SDKs](../concepts/interaction-tracking-in-web-sdks.md) - Web + `data-ctfl-*` attributes and `enableElement(...)` mechanics. +- [Optimization Core SDK README](../../packages/universal/core-sdk/README.md) - Core package surface + and entry-source subpath summary. diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index 038471fb5..b8166236f 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -38,6 +38,17 @@ Angular, Vue, Svelte, Web Components, and custom browser framework apps use `@contentful/optimization-node` unless the app is a Next.js App Router or Pages Router app covered by the Next.js adapter. +For JavaScript SDKs, we recommend the consumer-owned `contentful.js` path when the app already uses +that client. Create the delivery client in your app, pass it to the Optimization SDK as +`contentful: { client, defaultQuery?, cache? }`, then fetch optimized entries by entry ID through +SDK helpers or framework entry props. Manual baseline-entry fetching plus `resolveOptimizedEntry()` +remains supported when the app needs full delivery control or a non-`contentful.js` flow. + +For custom JavaScript runtimes or framework adapters where no official package fits, use Core plus +the `@contentful/optimization-core/entry-source` subpath for managed `baselineEntry | entryId` +lifecycle. Keep using the highest-level SDK when one fits; Core does not provide rendering, +runtime-specific tracking, consent UI, or framework integration. + For mobile apps, choose `@contentful/optimization-react-native` when the mobile app is built with JavaScript or TypeScript in React Native. Choose the native iOS or Android SDK only for platform-native apps that can accept alpha native API and setup changes. @@ -51,18 +62,19 @@ platform-native apps that can accept alpha native API and setup changes. Use this table to choose the primary package and the next integration guide: -| Reader need | Choose | Why | Next guide | -| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | -| Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | -| React browser app outside Next.js integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | -| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | -| React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | -| Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | -| Native iOS app built with UIKit or direct client ownership that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It exposes the same native iOS runtime through direct client APIs and UIKit-compatible preview, screen tracking, and entry-rendering patterns. | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) | -| Native Android app built with Jetpack Compose that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The Android AAR includes the stateful Kotlin client, Compose UI helpers, screen tracking, entry optimization, preview controls, and offline event delivery. | [Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) | -| Native Android app built with Android Views or XML layouts that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The same Android AAR includes Android Views helpers such as `OptimizationManager`, `OptimizedEntryView`, `ScreenTracker`, preview controls, and the stateful client. | [Integrating the Optimization Android SDK in an Android Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md) | +| Reader need | Choose | Why | Next guide | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, managed Contentful entry fetching by ID, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | +| Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, managed Contentful entry fetching by ID, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | +| React browser app outside Next.js integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering by entry ID, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | +| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution primitives. The entry-source subpath manages baseline-entry or entry-ID source lifecycle while the adapter owns rendering, tracking, and runtime policy. | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | +| React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | +| Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | +| Native iOS app built with UIKit or direct client ownership that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It exposes the same native iOS runtime through direct client APIs and UIKit-compatible preview, screen tracking, and entry-rendering patterns. | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) | +| Native Android app built with Jetpack Compose that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The Android AAR includes the stateful Kotlin client, Compose UI helpers, screen tracking, entry optimization, preview controls, and offline event delivery. | [Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) | +| Native Android app built with Android Views or XML layouts that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The same Android AAR includes Android Views helpers such as `OptimizationManager`, `OptimizedEntryView`, `ScreenTracker`, preview controls, and the stateful client. | [Integrating the Optimization Android SDK in an Android Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md) | ## Alternatives @@ -72,7 +84,9 @@ Use this table to choose the primary package and the next integration guide: experience entries; it is not a standalone SDK. - **Core SDK** - Use `@contentful/optimization-core` when building or maintaining an SDK layer that needs the shared state machine, event builders, queues, resolvers, interceptors, or preview - support. Application integrations start with a platform SDK. + support. Use `@contentful/optimization-core/entry-source` only when building an adapter that must + manage `baselineEntry | entryId` source lifecycle before resolution. Application integrations + start with a platform SDK. - **API client** - Use `@contentful/optimization-api-client` when building SDK layers, tooling, tests, or first-party integrations that need direct Experience API or Insights API transport without SDK state, consent handling, event builders, entry resolution, tracking, or platform @@ -95,6 +109,7 @@ After choosing the package, follow the matching guide: | React browser apps | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | | Next.js App Router apps | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | | Next.js Pages Router apps | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Custom JavaScript adapter | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | | React Native apps | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | | iOS SwiftUI apps | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | | iOS UIKit apps | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) | diff --git a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md index af773b28f..3c30d27a9 100644 --- a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md +++ b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md @@ -326,8 +326,10 @@ by `createNextjsAppRouterOptimization()`. Use the config-bound `getServerSideOpt helper for Pages Router `getServerSideProps`; it returns the same `OptimizationData` shape in its `data` field and serializable `props.contentfulOptimization`. Use `getNextjsServerOptimizationData()` only when you intentionally build a lower-level/manual `/server` -flow. Browser state streams cannot explain a server-rendered first paint unless you intentionally -hydrate the browser with the same Optimization data. +flow. When the SDK is configured with `contentful: { client }`, prefer the request-bound managed +entry helper so the entry decision and analytics context share the same request data. Browser state +streams cannot explain a server-rendered first paint unless you intentionally hydrate the browser +with the same Optimization data. **Adapt this to your use case:** @@ -338,11 +340,12 @@ const pageResult = await requestOptimization.page({ const optimizationData = pageResult.accepted ? pageResult.data : undefined -// Resolve the entry and analytics context from the same request-local Optimization data. -const { entry: resolvedHeroEntry, selectedOptimization } = optimization.resolveOptimizedEntry( - baselineHeroEntry, - optimizationData?.selectedOptimizations, -) +// Fetch and resolve the entry with the same request-local Optimization data. +const { + baselineEntry, + entry: resolvedHeroEntry, + selectedOptimization, +} = await requestOptimization.fetchOptimizedEntry('hero-entry-id') if (appPolicyAllowsThirdPartyAnalytics()) { // The server event owner decides which Contentful fields belong on this business event. @@ -356,12 +359,16 @@ if (appPolicyAllowsThirdPartyAnalytics()) { contentful_experience_id: selectedOptimization?.experienceId, contentful_variant_index: selectedOptimization?.variantIndex, contentful_variant_entry_id: selectedOptimization ? resolvedHeroEntry.sys.id : undefined, - contentful_baseline_entry_id: baselineHeroEntry.sys.id, + contentful_baseline_entry_id: baselineEntry.sys.id, }), ) } ``` +For manual Contentful fetching, keep passing an app-fetched `baselineEntry` and +`optimizationData?.selectedOptimizations` to `resolveOptimizedEntry()`, then forward the same fields +from the returned result. + In stateless runtimes, Insights-only calls such as non-sticky `trackView()`, `trackClick()`, `trackHover()`, and `trackFlagView()` need a request-bound profile. Sticky `trackView()` returns Optimization data from the Experience path before sending the paired Insights event. diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index d0b98e759..776b56879 100644 --- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md @@ -137,31 +137,33 @@ The JSON response contains a `profileId` when `page()` is accepted. Use this setup inventory before you move beyond the quick start: -| Setup item | Category | Required for quick start | Where to configure | -| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------- | -| `@contentful/optimization-node` package | Required for first integration | Yes | Node app package dependencies | -| Express package for the quick-start route | Required for first integration | Yes | Quick-start app dependencies, or your equivalent Node request framework | -| Optimization client ID | Required for first integration | Yes | `ContentfulOptimization({ clientId })` from environment configuration | -| Optimization environment and API endpoints | Required for first integration | Conditional | `environment` and `api` SDK options when not using defaults or local mocks | -| Application Contentful delivery client | Required for first integration | Conditional | App-owned `contentful.js`, REST, or GraphQL client used before entry resolution | -| Single-locale Contentful entry payloads with optimization links | Required for first integration | Conditional | CDA request options such as `locale: appLocale` and `include` depth | -| Request route or handler integration | Required for first integration | Yes | Express routes, server functions, or custom Node request handlers | -| Request event context | Required for first integration | Yes | `forRequest({ eventContext })` per incoming request | -| Application locale decision | Required for first integration | Yes | Router, i18n layer, request policy, CDA requests, and `forRequest({ locale })` | -| Consent policy | Common but policy-dependent | Yes | Application policy, consent cookie, CMP callback, session, or preference store | -| Profile ID persistence | Common but policy-dependent | Conditional | Application session or first-party cookie such as `ANONYMOUS_ID_COOKIE` | -| Known-user identity source | Common but policy-dependent | No | Authentication middleware, session, JWT, or account service used before `identify()` | -| Rich Text merge-tag renderer | Optional | No | Application Rich Text rendering pipeline | -| Custom Flag reads | Optional | No | Server render logic that consumes `getFlag()` | -| Server-side interaction or business event tracking | Optional | No | App-owned event collector, route action, or rendered-entry exposure path | -| Third-party analytics forwarding | Optional | No | Server-side analytics, customer-data, or tag-management integration | -| `@contentful/optimization-web` package and continuity | Optional | No | Browser package dependencies, shared anonymous-ID cookie, and browser SDK initialization | -| Strict pre-consent allowlist and request options | Advanced or production-only | No | SDK `allowedEventTypes`, `experienceOptions`, `insightsOptions`, and `onEventBlocked` | -| Personalized response caching policy | Advanced or production-only | No | Application cache keys, CDN rules, and render cache boundaries | +| Setup item | Category | Required for quick start | Where to configure | +| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------ | +| `@contentful/optimization-node` package | Required for first integration | Yes | Node app package dependencies | +| Express package for the quick-start route | Required for first integration | Yes | Quick-start app dependencies, or your equivalent Node request framework | +| Optimization client ID | Required for first integration | Yes | `ContentfulOptimization({ clientId })` from environment configuration | +| Optimization environment and API endpoints | Required for first integration | Conditional | `environment` and `api` SDK options when not using defaults or local mocks | +| Application Contentful delivery client | Required for first integration | Conditional | App-owned `contentful.js`, REST, or GraphQL client; SDK-managed fetching can use `contentful.js` | +| Single-locale Contentful entry payloads with optimization links | Required for first integration | Conditional | CDA request options such as `locale: appLocale` and `include` depth | +| Request route or handler integration | Required for first integration | Yes | Express routes, server functions, or custom Node request handlers | +| Request event context | Required for first integration | Yes | `forRequest({ eventContext })` per incoming request | +| Application locale decision | Required for first integration | Yes | Router, i18n layer, request policy, CDA requests, and `forRequest({ locale })` | +| Consent policy | Common but policy-dependent | Yes | Application policy, consent cookie, CMP callback, session, or preference store | +| Profile ID persistence | Common but policy-dependent | Conditional | Application session or first-party cookie such as `ANONYMOUS_ID_COOKIE` | +| Known-user identity source | Common but policy-dependent | No | Authentication middleware, session, JWT, or account service used before `identify()` | +| Rich Text merge-tag renderer | Optional | No | Application Rich Text rendering pipeline | +| Custom Flag reads | Optional | No | Server render logic that consumes `getFlag()` | +| Server-side interaction or business event tracking | Optional | No | App-owned event collector, route action, or rendered-entry exposure path | +| Third-party analytics forwarding | Optional | No | Server-side analytics, customer-data, or tag-management integration | +| `@contentful/optimization-web` package and continuity | Optional | No | Browser package dependencies, shared anonymous-ID cookie, and browser SDK initialization | +| Strict pre-consent allowlist and request options | Advanced or production-only | No | SDK `allowedEventTypes`, `experienceOptions`, `insightsOptions`, and `onEventBlocked` | +| Personalized response caching policy | Advanced or production-only | No | Application cache keys, CDN rules, and render cache boundaries | The Node SDK is stateless. It does not manage cookies, sessions, consent state, long-lived profile -state, Contentful fetching, or HTML rendering. Your application provides those inputs per request, -and the SDK evaluates or emits events, resolves entries, and returns request-local data. +state, Contentful credentials, or HTML rendering. Your application owns the Contentful delivery +client and its credentials; when configured with that client, the SDK can call `client.getEntry()` +for managed entry fetching. Your application provides request inputs, and the SDK evaluates or emits +events, fetches configured entries, resolves variants, and returns request-local data. ## Core integration @@ -172,7 +174,7 @@ and the SDK evaluates or emits events, resolves entries, and returns request-loc Create the SDK once for the Node process or module, then reuse that singleton across requests. Bind request-specific inputs later with `forRequest()`. -1. Install `@contentful/optimization-node`. +1. Install `@contentful/optimization-node` and `contentful` when the SDK will fetch entries by ID. 2. Read the Optimization client ID and environment from your runtime configuration. 3. Configure default locale and API endpoint overrides only when your app needs them. 4. Export the singleton so route handlers can create request-bound SDK clients. @@ -180,13 +182,14 @@ request-specific inputs later with `forRequest()`. **Copy this:** ```sh -pnpm add @contentful/optimization-node +pnpm add @contentful/optimization-node contentful ``` **Copy this:** ```ts import ContentfulOptimization from '@contentful/optimization-node' +import * as contentful from 'contentful' function required(name: string): string { const value = process.env[name] @@ -198,9 +201,20 @@ function required(name: string): string { return value } +const contentfulClient = contentful.createClient({ + accessToken: required('CONTENTFUL_DELIVERY_TOKEN'), + environment: required('CONTENTFUL_ENVIRONMENT'), + space: required('CONTENTFUL_SPACE_ID'), +}) + // Create this once per process; use forRequest() inside route handlers. export const optimization = new ContentfulOptimization({ clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'), + contentful: { + client: contentfulClient, + // Include linked optimization entries and variants for SDK-managed entry fetches. + defaultQuery: { include: 10 }, + }, environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', app: { name: 'my-express-app', @@ -475,24 +489,24 @@ For the lower-level mechanics, see **Integration category:** Required for first integration -The Node SDK does not replace your Contentful delivery client. Fetch the baseline Contentful entry -with the application locale and enough include depth, then pass that entry and request-local -`selectedOptimizations` to `resolveOptimizedEntry()`. +Your app owns the Contentful delivery client, credentials, and delivery policy. The preferred +Contentful path passes an app-owned `contentful.js` client to the SDK, then calls the request-bound +`requestOptimization.fetchOptimizedEntry(entryId)` helper after `page()` or `identify()`. After verifying the first `profileId` response, this section is where you add Contentful rendering: -pass the `selectedOptimizations` returned by `page()` to `resolveOptimizedEntry()` before rendering -the response. +call `requestOptimization.fetchOptimizedEntry(entryId)` before rendering the response. The helper +fetches the baseline entry, resolves the selected variant, and uses the latest accepted Experience +response selections when `selectedOptimizations` is omitted. -1. Fetch a single-locale Contentful entry from the application layer. -2. Include linked optimization entries and variant entries in the Contentful response. -3. Call `resolveOptimizedEntry()` with the request's `selectedOptimizations`. +1. Configure the SDK with `contentful: { client, defaultQuery?, cache? }`. +2. Call `page()` or `identify()` before resolving entries for the response. +3. Call `requestOptimization.fetchOptimizedEntry(entryId)` inside the request handler. 4. Render the returned `entry`. If resolution cannot find a matching optimization or variant, the resolver returns the baseline entry. **Adapt this to your use case:** ```ts -import type { Entry } from 'contentful' import * as contentful from 'contentful' const contentfulClient = contentful.createClient({ @@ -501,16 +515,16 @@ const contentfulClient = contentful.createClient({ space: required('CONTENTFUL_SPACE_ID'), }) -type ArticleEntry = Entry - -async function getArticle(entryId: string, locale: string): Promise { - return await contentfulClient.getEntry(entryId, { +const optimization = new ContentfulOptimization({ + clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'), + contentful: { + client: contentfulClient, // Include linked optimization entries and variants before SDK resolution. - include: 10, - // Fetch one CDA locale; all-locale payloads cannot be resolved by the SDK. - locale, - }) -} + defaultQuery: { include: 10 }, + }, + environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', + locale: 'en-US', +}) app.get('/article/:entryId', async (req, res) => { const appLocale = getAppLocale(req) @@ -523,13 +537,11 @@ app.get('/article/:entryId', async (req, res) => { // Evaluate the request before resolving entry variants for this response. const pageResult = await requestOptimization.page() const pageResponse = pageResult.accepted ? pageResult.data : undefined - const article = await getArticle(req.params.entryId, appLocale) - - // The resolver returns article when no matching selected optimization exists. - const { entry: optimizedArticle, selectedOptimization } = optimization.resolveOptimizedEntry( - article, - pageResponse?.selectedOptimizations, - ) + const { + baselineEntry: article, + entry: optimizedArticle, + selectedOptimization, + } = await requestOptimization.fetchOptimizedEntry(req.params.entryId) if (requestOptimization.canPersistProfile) { persistProfile(res, pageResponse?.profile.id) @@ -543,8 +555,29 @@ app.get('/article/:entryId', async (req, res) => { }) ``` -Do not pass all-locale CDA responses from `contentful.js` `withAllLocales` or raw CDA `locale=*` -into `resolveOptimizedEntry()`. The resolver expects direct single-locale field values such as +Use `requestOptimization.fetchOptimizedEntry()` in request handlers. If you call +`optimization.fetchOptimizedEntry()` on the singleton for personalized content, pass +`selectedOptimizations` explicitly. + +Manual baseline-entry fetching plus `resolveOptimizedEntry()` remains supported when the app needs +custom delivery behavior, GraphQL, REST without `contentful.js`, or an already-fetched baseline +entry: + +**Adapt this to your use case:** + +```ts +const baselineEntry = await contentfulClient.getEntry(req.params.entryId, { + include: 10, + locale: appLocale, +}) +const { entry: optimizedArticle } = optimization.resolveOptimizedEntry( + baselineEntry, + pageResponse?.selectedOptimizations, +) +``` + +Do not configure SDK-managed fetches or manual fetches with `contentful.js` `withAllLocales` or raw +CDA `locale=*` responses. The resolver expects direct single-locale field values such as `fields.nt_experiences` and `fields.nt_variants`. For the entry contract, see [Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). @@ -582,7 +615,9 @@ const html = documentToHtmlString(richTextField, { If MergeTags reference localized profile fields such as `location.city` or `location.country`, pass the same application locale to Contentful fetches and `forRequest({ locale })` so profile values and -entry language line up. +entry language line up. For SDK-managed entry fetching on a request-bound client, +`forRequest({ locale })` supplies the managed Contentful query locale when neither +`contentful.defaultQuery` nor the per-call query sets `locale`. ### Read Custom Flags @@ -813,20 +848,22 @@ cache varies on every personalization input. environment, host, and delivery mode. 2. Treat cached Contentful entries as immutable, or clone them before request-specific transforms such as merge-tag rendering. -3. Resolve variants from the current request's `selectedOptimizations`. +3. Resolve variants from the current request's `selectedOptimizations`, or use + `requestOptimization.fetchOptimizedEntry()` so the request-bound helper supplies them. 4. Render MergeTags against the current request's `profile`. 5. Do not memoize `page()`, `identify()`, `screen()`, `track()`, or `trackView()` results as if they were pure reads. Use this cache-safety table when planning production caching: -| Artifact | Shared-cache safe? | Notes | -| -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------- | -| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode | -| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint | -| Merge-tag-rendered rich text | No | Depends on the current request `profile` | -| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs | -| `page()`, `identify()`, `screen()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized | +| Artifact | Shared-cache safe? | Notes | +| -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- | +| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode | +| SDK-managed entry cache | Yes | Caches baseline `getEntry()` results only; configure with `contentful.cache` or disable with `cache: false` | +| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint | +| Merge-tag-rendered rich text | No | Depends on the current request `profile` | +| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs | +| `page()`, `identify()`, `screen()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized | ## Production checks diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md index 703dc7f96..8d5e36eaa 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md @@ -8,7 +8,8 @@ This pattern uses `@contentful/optimization-nextjs`. The `/app-router` factory b components that compose the stateless Node SDK in Server Components and the React Web SDK in Client Components. The request handler forwards sanitized Next.js proxy or middleware request context headers. Your application still owns Contentful fetching, consent policy, identity policy, routing, -caching, and component rendering. +caching, and component rendering. When configured with an app-owned `contentful.js` client, the SDK +can fetch entries by ID for managed entry resolution. If your application still uses the Pages Router, use the [Next.js Pages Router guide](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) @@ -180,7 +181,7 @@ Use this table as the setup inventory for the full App Router integration: | `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | | App-local bound components from `/app-router` | Required for first integration | Yes | `lib/optimization.ts` with `createNextjsAppRouterOptimization()` | | Optimization client ID, environment, locale, and API endpoints | Required for first integration | Yes | Bound component factory config with browser-safe environment variables | -| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | +| Contentful CDA credentials and fetch policy | Required for first integration | Yes | Application Contentful client or SDK `contentful` config | | Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with one `locale` and enough `include` depth, commonly `include: 10` | | Next.js proxy or middleware hook | Common but policy-dependent | Yes | `proxy.ts` or `middleware.ts` for request context forwarding | | Bound `OptimizationRoot` | Required for first integration | Yes | App Router layout | @@ -199,7 +200,7 @@ Use this table as the setup inventory for the full App Router integration: | Personalized response caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, CDN rules, bound component placement, and tracker settings | Use one application Contentful locale for entries that feed SDK resolution. The SDK Experience and -event locale often uses the same string, but the SDK does not fetch Contentful content or change CDA +event locale often uses the same string, but the SDK does not infer the CDA locale or change CDA requests for you. ## Core integration @@ -345,9 +346,10 @@ handoff or an explicit server persistence flow. For deeper mechanics, see **Integration category:** Required for first integration -The SDK does not fetch Contentful entries. Fetch baseline entries in the application layer with one -Contentful locale and resolved optimization links before passing them to bound server or client -entry primitives. +This quick start fetches baseline entries in the application layer with one Contentful locale and +resolved optimization links before passing them to bound server or client entry primitives. For +managed entry fetching, configure the SDK with `contentful: { client }` and pass `entryId` plus an +optional `entryQuery` to supported entry helpers. 1. Choose the application Contentful locale in routing, i18n, request policy, or app configuration. 2. Pass that locale to CDA requests. @@ -834,13 +836,13 @@ import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect } from 'react' export function PreviewPanelAttachment({ nonce }: { nonce?: string }) { - const { isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const previewPanelEnabled = process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true' useEffect(() => { // Keep authoring tooling opt-in. if (!previewPanelEnabled) return - if (!isReady) return + if (!sdk) return void Promise.all([ import('@contentful/optimization-web-preview-panel'), @@ -853,7 +855,7 @@ export function PreviewPanelAttachment({ nonce }: { nonce?: string }) { }) }) .catch(() => undefined) - }, [isReady, nonce, previewPanelEnabled]) + }, [nonce, previewPanelEnabled, sdk]) return null } diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md index 9d21e8d97..e61135764 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md @@ -7,7 +7,8 @@ from that state after hydration. This pattern uses `@contentful/optimization-nextjs`. The `/pages-router` factory binds app-local client components, and `/pages-router/server` prepares serializable `pageProps` for `getServerSideProps`. Your application still owns Contentful fetching, consent policy, identity -policy, routing, caching, and component rendering. +policy, routing, caching, and component rendering. When configured with an app-owned `contentful.js` +client, the SDK can fetch entries by ID for managed entry resolution. If your application uses the App Router, use the [Next.js App Router guide](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) @@ -196,7 +197,7 @@ Use this table as the setup inventory for the full Pages Router integration: | App-local bound components from `/pages-router` | Required for first integration | Yes | `lib/optimization.ts` with `createNextjsPagesRouterOptimization()` | | Optimization client ID, environment, locale, and API endpoints | Required for first integration | Yes | Bound component factory and server SDK config with browser-safe environment values | | Server SDK helper from `/pages-router/server` | Required for first integration | Yes | `getServerSideProps` helper module | -| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | +| Contentful CDA credentials and fetch policy | Required for first integration | Yes | Application Contentful client or SDK `contentful` config | | Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with one `locale` and enough `include` depth, commonly `include: 10` | | Bound `OptimizationRoot` | Required for first integration | Yes | `pages/_app.tsx` | | Pages Router page tracker | Required for first integration | Yes | `NextPagesAutoPageTracker` under the bound `OptimizationRoot` | @@ -212,7 +213,7 @@ Use this table as the setup inventory for the full Pages Router integration: | Personalized response caching and duplicate-event policy | Advanced or production-only | No | `getServerSideProps`, CDN rules, response headers, and tracker settings | Use one application Contentful locale for entries that feed SDK resolution. The SDK Experience and -event locale often uses the same string, but the SDK does not fetch Contentful content or change CDA +event locale often uses the same string, but the SDK does not infer the CDA locale or change CDA requests for you. ## Core integration @@ -277,8 +278,10 @@ clears the anonymous ID cookie on the response, and returns serializable **Integration category:** Required for first integration -The SDK does not fetch Contentful entries. Fetch baseline entries in the application layer with one -Contentful locale and resolved optimization links before passing them to bound entry primitives. +This quick start fetches baseline entries in the application layer with one Contentful locale and +resolved optimization links before passing them to bound entry primitives. For managed entry +fetching, configure the SDK with `contentful: { client }` and pass `entryId` plus an optional +`entryQuery` to supported entry helpers. 1. Choose the application Contentful locale in routing, i18n, request policy, or app configuration. 2. Pass that locale to CDA requests. diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md index f34d337b2..0c25fab1f 100644 --- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md +++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md @@ -6,8 +6,8 @@ optional preview tooling to a React Native or Expo application with The React Native SDK builds on the Optimization Core SDK and adds React Native providers, hooks, entry rendering, viewport and tap tracking, AsyncStorage persistence, optional offline delivery, and -an in-app preview panel. Your application still owns Contentful Delivery API fetching, consent -policy, identity policy, navigation, and final rendering. +an in-app preview panel. Your application still owns Contentful Delivery API client configuration, +Contentful locale policy, consent policy, identity policy, navigation, and final rendering. ## Quick start @@ -24,21 +24,19 @@ events or rendering personalized content. pnpm add @contentful/optimization-react-native @react-native-async-storage/async-storage contentful ``` -2. Mount `OptimizationRoot`, emit one screen event for profile context, fetch one single-locale - Contentful entry with linked optimization data, and render the resolved entry ID through - `OptimizedEntry`. +2. Mount `OptimizationRoot` with your app-owned Contentful client, emit one screen event for profile + context, and render one single-locale Contentful entry by ID through `OptimizedEntry`. **Copy this:** ```tsx - import { useEffect, useState } from 'react' import { Text } from 'react-native' import { OptimizationRoot, OptimizedEntry, useScreenTracking, } from '@contentful/optimization-react-native' - import { createClient, type Entry } from 'contentful' + import { createClient } from 'contentful' const APP_LOCALE = 'en-US' @@ -49,25 +47,12 @@ events or rendering personalized content. }) function HomeScreen() { - const [entry, setEntry] = useState() - // Automatic screen tracking uses current-screen dedupe. useScreenTracking({ name: 'Home' }) - useEffect(() => { - void contentfulClient - .getEntry('hero-entry-id', { - include: 10, // Resolve optimization and variant links before SDK resolution. - locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale. - }) - .then(setEntry) - }, []) - - if (!entry) return null - // OptimizedEntry passes the selected variant or baseline fallback to the renderer. return ( - + {(resolvedEntry) => {`Resolved entry: ${resolvedEntry.sys.id}`}} ) @@ -80,6 +65,13 @@ events or rendering personalized content. clientId="your-optimization-client-id" environment="main" locale={APP_LOCALE} + contentful={{ + client: contentfulClient, + defaultQuery: { + include: 10, // Resolve optimization and variant links before SDK resolution. + locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale. + }, + }} defaults={{ consent: true }} > @@ -125,33 +117,34 @@ events or rendering personalized content. Use this setup inventory before you move beyond the quick start: -| Setup item | Category | Required for quick start | Where to configure | -| ------------------------------------------------------------------------- | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------- | -| React Native app with compatible React and React Native peer dependencies | Required for first integration | Yes | Application package dependencies | -| `@contentful/optimization-react-native` package | Required for first integration | Yes | Application package dependencies | -| `@react-native-async-storage/async-storage` peer dependency | Required for first integration | Yes | Application package dependencies and native install flow | -| Contentful Delivery API client | Required for first integration | Yes | Application package dependencies and app-owned Contentful client factory | -| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot`, `OptimizationProvider`, or `ContentfulOptimization.create(...)` | -| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` SDK config for staging, mock, or non-default hosts | -| Contentful space, environment, access token, and CDA host | Required for first integration | Yes | Application-owned Contentful fetching layer | -| Optimized Contentful entries with resolved `nt_experiences` and variants | Required for first integration | Yes | Contentful content model and CDA `include` depth | -| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | Yes | App locale policy, Contentful `getEntry()` calls, and SDK `locale` | -| `OptimizationRoot` mounted once around SDK consumers | Required for first integration | Yes | React Native app root or navigation root | -| Screen, route, or lifecycle integration | Required for first integration | Yes | `useScreenTracking`, `useScreenTrackingCallback`, or `OptimizationNavigationContainer` | -| Entry rendering through `OptimizedEntry` or `useEntryResolver` | Required for first integration | Yes | React Native components that render Contentful entries | -| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | SDK `defaults`, `allowedEventTypes`, and application consent UI or CMP callbacks | -| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `trackEntryInteraction` on `OptimizationRoot` and per-entry tracking props | -| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Authentication, account, or settings flows that call `identify()` and `reset()` | -| React Navigation integration | Optional | No | App navigation dependencies and `OptimizationNavigationContainer` | -| `@react-native-community/netinfo` for offline detection | Optional | No | Application package dependencies and native install flow | -| Preview peer dependencies | Optional | No | `@react-native-clipboard/clipboard` and `react-native-safe-area-context` | -| Merge tag and Custom Flag rendering | Optional | No | App-owned Rich Text, flag, or feature-rendering components | -| Analytics forwarding destination | Optional | No | `onStatesReady` subscriptions and application-owned analytics code | -| Strict pre-consent allowlist, queue policy, and diagnostics | Advanced or production-only | No | SDK `allowedEventTypes`, `queuePolicy`, `onEventBlocked`, and `logLevel` | -| Preview release gating and custom native builds | Advanced or production-only | No | Build flags, Expo custom dev builds, and release configuration | - -The React Native SDK does not fetch Contentful entries. Fetch entries in your application layer, -then pass single-locale entry objects to SDK components and hooks. +| Setup item | Category | Required for quick start | Where to configure | +| ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------- | +| React Native app with compatible React and React Native peer dependencies | Required for first integration | Yes | Application package dependencies | +| `@contentful/optimization-react-native` package | Required for first integration | Yes | Application package dependencies | +| `@react-native-async-storage/async-storage` peer dependency | Required for first integration | Yes | Application package dependencies and native install flow | +| Contentful Delivery API client | Required for first integration | Yes | Application package dependencies, app-owned Contentful client factory, and SDK config | +| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot`, `OptimizationProvider`, or `ContentfulOptimization.create(...)` | +| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` SDK config for staging, mock, or non-default hosts | +| Contentful space, environment, access token, and CDA host | Required for first integration | Yes | Application-owned Contentful client and SDK `contentful` config | +| Optimized Contentful entries with resolved `nt_experiences` and variants | Required for first integration | Yes | Contentful content model and CDA `include` depth | +| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | Yes | App locale policy, SDK `contentful.defaultQuery` or `entryQuery`, and SDK `locale` | +| `OptimizationRoot` mounted once around SDK consumers | Required for first integration | Yes | React Native app root or navigation root | +| Screen, route, or lifecycle integration | Required for first integration | Yes | `useScreenTracking`, `useScreenTrackingCallback`, or `OptimizationNavigationContainer` | +| Entry rendering through `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver` | Required for first integration | Yes | React Native components that render Contentful entries | +| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | SDK `defaults`, `allowedEventTypes`, and application consent UI or CMP callbacks | +| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `trackEntryInteraction` on `OptimizationRoot` and per-entry tracking props | +| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Authentication, account, or settings flows that call `identify()` and `reset()` | +| React Navigation integration | Optional | No | App navigation dependencies and `OptimizationNavigationContainer` | +| `@react-native-community/netinfo` for offline detection | Optional | No | Application package dependencies and native install flow | +| Preview peer dependencies | Optional | No | `@react-native-clipboard/clipboard` and `react-native-safe-area-context` | +| Merge tag and Custom Flag rendering | Optional | No | App-owned Rich Text, flag, or feature-rendering components | +| Analytics forwarding destination | Optional | No | `onStatesReady` subscriptions and application-owned analytics code | +| Strict pre-consent allowlist, queue policy, and diagnostics | Advanced or production-only | No | SDK `allowedEventTypes`, `queuePolicy`, `onEventBlocked`, and `logLevel` | +| Preview release gating and custom native builds | Advanced or production-only | No | Build flags, Expo custom dev builds, and release configuration | + +Prefer SDK-managed entry fetching by configuring `contentful: { client }` and passing `entryId`. +Manual single-locale `baselineEntry` objects remain supported when your application layer must own +the individual Contentful request. ## Core integration @@ -219,7 +212,7 @@ accepted. 5. Subscribe to `states.blockedEventStream` during development when you need to verify blocked calls. -**Copy this:** +**Adapt this to your use case:** ```tsx // Use this only when policy allows Optimization to start accepted. @@ -260,27 +253,52 @@ For cross-SDK policy details, see **Integration category:** Required for first integration -Your app owns Contentful fetching. The SDK resolver expects a standard single-locale Contentful CDA -entry payload where optimized fields are direct values, not locale-keyed maps. +Your app owns the Contentful client and locale policy. The React Native SDK can call that client +when `contentful: { client }` is configured, or your app can fetch a manual `baselineEntry`. Both +paths must produce a standard single-locale Contentful CDA entry payload where optimized fields are +direct values, not locale-keyed maps. 1. Choose the application Contentful locale in your app configuration, i18n layer, or navigation layer. -2. Pass that locale to Contentful CDA requests that feed SDK entry resolution. +2. Configure `contentful.defaultQuery` on the SDK, pass per-entry `entryQuery`, or pass the locale + to manual Contentful CDA requests. 3. Request enough link depth for `nt_experiences`, optimization config, and linked variant entries. `include: 10` is the repository reference implementation's pattern. 4. Pass the same locale to SDK `locale` when Experience API responses and event context must use the same language. 5. Do not pass `contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to - `OptimizedEntry` or `useEntryResolver`. + `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver`. -**Copy this:** +**Adapt this to your use case:** ```tsx const APP_LOCALE = 'en-US' + + + {(resolvedEntry) => } + + +``` + +Manual fetching remains supported when your app needs request ownership around one entry: + +**Adapt this to your use case:** + +```tsx const entry = await contentfulClient.getEntry('hero-entry-id', { - include: 10, // Resolve optimization and variant links before SDK resolution. - locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale. + include: 10, + locale: APP_LOCALE, }) ``` @@ -302,27 +320,49 @@ For the broader locale model, see non-optimized entries through unchanged. Invalid, incomplete, or unmatched optimization data falls back to the baseline entry instead of throwing. -1. Pass the baseline Contentful entry to `OptimizedEntry`. -2. Use a render prop when the child needs the resolved baseline or variant entry. -3. Use static children only when you need entry tracking but not variant data in the child. -4. Use `useEntryResolver()` when a component needs the same resolution behavior without the wrapper +1. Pass `entryId` to let the SDK fetch the baseline entry through the configured Contentful client. +2. Pass `baselineEntry` when your application already fetched the entry and must keep manual request + ownership. +3. Use `loadingFallback`, `errorFallback`, and `onEntryError` when the managed fetch needs visible + loading or error handling. +4. Use a render prop when the child needs the resolved baseline or variant entry. +5. Use static children only when you need entry tracking but not variant data in the child. +6. Use `useOptimizedEntry()` for the same managed or manual source model without the wrapper component. +7. Use `useEntryResolver()` when a component needs manual-only resolution helpers. **Adapt this to your use case:** ```tsx -import { OptimizedEntry, useEntryResolver } from '@contentful/optimization-react-native' +import { + OptimizedEntry, + useEntryResolver, + useOptimizedEntry, +} from '@contentful/optimization-react-native' import type { Entry } from 'contentful' -function HeroSection({ baselineEntry }: { baselineEntry: Entry }) { +function HeroSection() { return ( - + diagnostics.report(error)} + > {(resolvedEntry) => } ) } -function HeroData({ baselineEntry }: { baselineEntry: Entry }) { +function HeroData() { + const { entry, isPresentationReady } = useOptimizedEntry({ entryId: 'hero-entry-id' }) + + if (!isPresentationReady || !entry) return null + + return +} + +function HeroManualData({ baselineEntry }: { baselineEntry: Entry }) { const { resolveEntry } = useEntryResolver() // resolveEntry uses current selected optimizations and falls back to baseline content. const resolvedEntry = resolveEntry(baselineEntry) diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index ef2a229b4..bf94143f0 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -4,8 +4,8 @@ Use this guide when you want to add browser-side personalization and analytics t application with `@contentful/optimization-react-web`. The React Web SDK wraps `@contentful/optimization-web` with React providers, hooks, entry-rendering -components, live-update state, and router adapters. Your application still owns Contentful entry -fetching, consent policy, identity policy, routing, and final rendering. +components, live-update state, and router adapters. Your application still owns the Contentful +client and credentials, consent policy, identity policy, routing, and final rendering. Use the lower-level Web SDK guide instead when your app is not React-based or when you want to own the browser SDK lifecycle without React abstractions. @@ -24,9 +24,8 @@ explicit opt-in, wire the consent section before you emit events or render perso pnpm add @contentful/optimization-react-web contentful ``` -2. Mount `OptimizationRoot` once, emit a page event so the SDK can evaluate route-based - optimizations, fetch one single-locale Contentful entry with linked optimization data, and render - it through `OptimizedEntry`. +2. Mount `OptimizationRoot` once with your app-owned `contentful.js` client, emit a page event so + the SDK can evaluate route-based optimizations, and render one entry through `OptimizedEntry`. Set `PUBLIC_HERO_ENTRY_ID` to the baseline entry ID for the first optimized entry, or replace `HERO_ENTRY_ID` with an app-owned constant. @@ -39,8 +38,8 @@ explicit opt-in, wire the consent section before you emit events or render perso OptimizedEntry, useOptimizationActions, } from '@contentful/optimization-react-web' - import { createClient, type Entry } from 'contentful' - import { useEffect, useState } from 'react' + import { createClient } from 'contentful' + import { useEffect } from 'react' const APP_LOCALE = 'en-US' const HERO_ENTRY_ID = import.meta.env.PUBLIC_HERO_ENTRY_ID @@ -54,27 +53,14 @@ explicit opt-in, wire the consent section before you emit events or render perso function HomePage() { const { trackPageView } = useOptimizationActions() - const [entry, setEntry] = useState() useEffect(() => { // Emit after the provider is ready so the SDK can resolve route-based optimizations. void trackPageView() }, [trackPageView]) - useEffect(() => { - void contentfulClient - .getEntry(HERO_ENTRY_ID, { - // Resolve linked optimization and variant entries before passing the entry to React Web. - include: INCLUDE_DEPTH, - locale: APP_LOCALE, - }) - .then(setEntry) - }, []) - - if (!entry) return null - return ( - + {(resolvedEntry) => (

{String(resolvedEntry.fields.title ?? '')}

@@ -90,6 +76,14 @@ explicit opt-in, wire the consent section before you emit events or render perso clientId={import.meta.env.PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID} environment={import.meta.env.PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'} locale={APP_LOCALE} + contentful={{ + client: contentfulClient, + defaultQuery: { + // Managed fetching expects one CDA locale and resolved optimization links. + include: INCLUDE_DEPTH, + locale: APP_LOCALE, + }, + }} // Use accepted startup consent only when your application policy permits it. defaults={{ consent: true }} > @@ -110,7 +104,7 @@ explicit opt-in, wire the consent section before you emit events or render perso - [Core integration](#core-integration) - [Install and initialize the React provider](#install-and-initialize-the-react-provider) - [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) - - [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape) + - [Contentful client configuration and locale shape](#contentful-client-configuration-and-locale-shape) - [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) - [Page events and route tracking](#page-events-and-route-tracking) - [Entry interaction tracking](#entry-interaction-tracking) @@ -140,10 +134,10 @@ Use this table as the setup inventory for the guide: | `@contentful/optimization-react-web` plus app-owned React and React DOM peer dependencies | Required for first integration | Yes | Application package dependencies | | Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot` props, usually from runtime environment variables | | Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` prop when using non-default production, staging, or mock endpoints | -| Contentful Delivery API client, space, environment, and access token | Required for first integration | Yes | Application-owned Contentful fetching layer | +| Contentful Delivery API client, space, environment, and access token | Required for first integration | Yes | Application-owned `contentful.js` client passed through `OptimizationRoot` | | Contentful optimized entry ID used by the first rendered entry | Required for first integration | Yes | Runtime environment variable such as `PUBLIC_HERO_ENTRY_ID`, or an app-owned entry ID constant | | Contentful entries with linked optimization and variant data | Required for first integration | Yes | Contentful content model and entries rendered by the app | -| Single Contentful CDA locale and `include: 10` for optimized entries | Required for first integration | Yes | `getEntry()` or `getEntries()` calls before passing entries to the SDK | +| Single Contentful CDA locale and `include: 10` for optimized entries | Required for first integration | Yes | `contentful.defaultQuery`, per-entry `entryQuery`, or manual `getEntry()` calls | | `OptimizationRoot` mounted once around the React tree that uses SDK hooks | Required for first integration | Yes | React app root, layout, or router root | | Page event emission on initial render, plus route changes for routed apps | Required for first integration | Yes | Router adapter under `OptimizationRoot`, or an app-owned `page()` effect | | Entry rendering through `OptimizedEntry` or `useOptimizedEntry` | Required for first integration | Yes | React components that render Contentful entries | @@ -157,8 +151,9 @@ Use this table as the setup inventory for the guide: | Strict pre-consent event policy, cookie settings, queue policy, and CSP nonce | Advanced or production-only | No | `OptimizationRoot` config and preview-panel attach options | | Externally owned Web SDK instance | Advanced or production-only | No | `OptimizationProvider sdk={...}` with `LiveUpdatesProvider` | -The React Web SDK does not fetch Contentful entries. Fetch entries in your application layer, then -pass the resulting single-locale entry objects to the SDK components and hooks. +For the preferred JavaScript path, create the `contentful.js` client in your app and pass it to +`OptimizationRoot` through `contentful: { client, defaultQuery?, cache? }`. Manual `baselineEntry` +rendering remains supported when the app fetches entries outside the SDK. ## Core integration @@ -226,7 +221,7 @@ Do not destructure methods from the object returned by `useOptimization()`. Thos the SDK instance binding. `useOptimizationActions()` returns bound actions that are safe to destructure, including `setConsent`, `flushEvents`, `identifyUser`, `trackPageView`, `resetUser`, `trackScreen`, and `trackEvent`. Use `useOptimizationContext()` when a component needs -`{ sdk, isReady, error }` for diagnostics or error rendering before the SDK is ready. +`{ sdk, error }` for diagnostics or error rendering before the SDK is available. ### Consent and privacy-policy handoff @@ -284,16 +279,18 @@ default, the Web SDK permits only `identify` and `page` before consent is explic For the cross-SDK policy model, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). -### Contentful entry fetching and locale shape +### Contentful client configuration and locale shape **Integration category:** Required for first integration -The SDK resolves entries after your app fetches them from Contentful. It expects the standard -single-locale CDA entry shape with direct field values, including linked optimization fields such as -`fields.nt_experiences` and `fields.nt_variants`. +The preferred React Web path uses an app-owned `contentful.js` client configured on +`OptimizationRoot`. The SDK-managed entry fetch expects the standard single-locale CDA entry shape +with direct field values, including linked optimization fields such as `fields.nt_experiences` and +`fields.nt_variants`. 1. Choose the application Contentful locale in your router, i18n layer, or app configuration. -2. Pass that locale to Contentful CDA requests. +2. Pass that locale through `contentful.defaultQuery` on `OptimizationRoot`, per-entry `entryQuery`, + or manual CDA requests. 3. Pass the same locale to `OptimizationRoot` when Experience API responses and event context need to match rendered content. 4. Fetch optimized entries with `include: 10` so linked optimization and variant entries are @@ -301,9 +298,10 @@ single-locale CDA entry shape with direct field values, including linked optimiz 5. Do not pass `withAllLocales` or raw CDA `locale=*` responses to `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver()`. -**Copy this:** +**Adapt this to your use case:** ```tsx +import { OptimizationRoot } from '@contentful/optimization-react-web' import { createClient } from 'contentful' const APP_LOCALE = 'en-US' @@ -315,13 +313,20 @@ const contentfulClient = createClient({ space: import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID, }) -export async function fetchOptimizedEntry(entryId: string) { - return await contentfulClient.getEntry(entryId, { - // Resolve linked optimization and variant entries before rendering. - include: INCLUDE_DEPTH, - // Keep CDA locale aligned with the OptimizationRoot locale. - locale: APP_LOCALE, - }) +export function AppRoot() { + return ( + + + + ) } ``` @@ -335,15 +340,37 @@ needed, and rerender localized content. For the full locale model, see **Integration category:** Required for first integration -`OptimizedEntry` resolves a baseline Contentful entry against selected optimization state and -renders either the selected variant or the baseline entry. +`OptimizedEntry` resolves a Contentful entry against selected optimization state and renders either +the selected variant or the baseline entry. -1. Pass the baseline entry fetched by your application. -2. Use a render prop when the rendered UI depends on the resolved entry. -3. Use `loadingFallback` when you want temporary custom loading UI while optimization state is +1. Use `entryId` for SDK-managed fetching when `OptimizationRoot` has `contentful: { client }`. +2. Use `entryQuery` for per-entry query overrides such as a route-specific locale. +3. Use `errorFallback` and `onEntryError` for managed CDA failures. +4. Pass `baselineEntry` when the app fetches entries outside the SDK. +5. Use a render prop when the rendered UI depends on the resolved entry. +6. Use `loadingFallback` when you want temporary custom loading UI while optimization state is unresolved. -4. Use `useOptimizedEntry()` only when a component needs direct access to loading, readiness, or - selected-optimization metadata. +7. Use `useOptimizedEntry()` only when a component needs direct access to loading, presentation + readiness, or selected-optimization metadata. + +Replace `reportContentfulEntryError` in the example with your app-owned logging or monitoring +function. + +**Adapt this to your use case:** + +```tsx + } + loadingFallback={() => } + onEntryError={(error) => reportContentfulEntryError(error)} +> + {(resolvedEntry) => } + +``` + +For manual fetching, keep passing the app-fetched baseline entry: **Adapt this to your use case:** @@ -1010,8 +1037,8 @@ Before releasing a React Web SDK integration, verify these checks: click, hover, flag, or custom tracking event appear in the expected SDK event stream or destination debugger. - Content fallback behavior: entries without optimization references render baseline content, - optimized entries stop showing loading fallback after readiness or the 5-second baseline reveal, - and all-locale CDA responses are not passed to entry resolvers. + optimized entries stop showing loading fallback after presentation readiness or the 5-second + baseline reveal, and all-locale CDA responses are not passed to entry resolvers. - Duplicate tracking prevention: one page tracker is mounted per router tree, Strict Mode remounts do not duplicate page events, analytics forwarding deduplicates exact records by `messageId`, sticky-view exposure forwarding uses semantic dedupe when the destination wants one exposure, and diff --git a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md index 7f6f9bd3a..1d5bc0fde 100644 --- a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md +++ b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md @@ -22,8 +22,8 @@ events. pnpm add @contentful/optimization-web contentful ``` -2. Create one Web SDK instance for the page or SPA runtime, then emit one `page()` event, fetch one - single-locale Contentful entry, resolve the selected variant, and render it. +2. Create one Contentful delivery client and one Web SDK instance for the page or SPA runtime, then + emit one `page()` event, fetch one optimized entry by ID, and render it. **Adapt this to your use case:** @@ -41,6 +41,11 @@ events. const optimization = new ContentfulOptimization({ clientId: 'your-optimization-client-id', + contentful: { + client: contentfulClient, + // Include linked optimization entries and variants for SDK-managed entry fetches. + defaultQuery: { include: 10 }, + }, environment: 'main', locale: APP_LOCALE, // Only use default-on consent when application policy permits it. @@ -52,14 +57,8 @@ events. }) // Emit the page event before resolving entries so selections are current. - const pageResult = await optimization.page() - const baselineEntry = await contentfulClient.getEntry('hero-entry-id', { - include: 10, - locale: APP_LOCALE, - }) - // Passing [] falls back to the baseline when the page event is blocked or has no data. - const selectedOptimizations = pageResult.accepted ? pageResult.data?.selectedOptimizations : [] - const { entry } = optimization.resolveOptimizedEntry(baselineEntry, selectedOptimizations ?? []) + await optimization.page() + const { entry } = await optimization.fetchOptimizedEntry('hero-entry-id') const hero = document.querySelector('#hero') if (hero) { @@ -104,7 +103,7 @@ The full guide uses these setup items: | Setup item | Category | Required for quick start | Where to configure | | ------------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------ | | `@contentful/optimization-web` package | Required for first integration | Yes | Application package manager | -| Contentful delivery client package | Required for first integration | Yes | Application package manager and Contentful client factory | +| Contentful delivery client package | Required for first integration | Yes | Application package manager, Contentful client factory, and SDK `contentful.client` | | Optimization client ID and optional non-`main` environment | Required for first integration | Yes | Runtime configuration passed to `new ContentfulOptimization(...)` | | Contentful space, environment, and access token | Required for first integration | Yes | Application-owned Contentful client configuration | | Non-default Contentful CDA host | Common but policy-dependent | No | Application-owned Contentful client host or endpoint configuration | @@ -125,9 +124,9 @@ The full guide uses these setup items: | Production event, privacy, and cache validation | Advanced or production-only | No | Release checklist, observability, and deployment configuration | Keep the default path single-locale. Fetch entries for SDK resolution with one concrete Contentful -locale and enough include depth for linked optimization entries and variants. Do not pass -`contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to -`resolveOptimizedEntry()`. +locale and enough include depth for linked optimization entries and variants. Do not configure +SDK-managed fetches or manual fetches with `contentful.js` `withAllLocales` results or raw CDA +`locale=*` responses. ## Core integration @@ -175,6 +174,11 @@ export const contentfulClient = contentful.createClient({ // Reuse this singleton across route, render, and tracking handlers. export const optimization = new ContentfulOptimization({ clientId: APP_CONFIG.optimizationClientId, + contentful: { + client: contentfulClient, + // Include linked optimization entries and variants for SDK-managed entry fetches. + defaultQuery: { include: 10 }, + }, environment: APP_CONFIG.optimizationEnvironment, locale: APP_LOCALE, app: { @@ -190,7 +194,9 @@ export const optimization = new ContentfulOptimization({ ``` The Web SDK does not replace the Contentful delivery client. Your application still owns Contentful -credentials, entry fetching, routing, rendering, consent policy, identity policy, and cache policy. +credentials, delivery-client configuration, routing, rendering, consent policy, identity policy, and +cache policy. When configured with `contentful: { client, defaultQuery?, cache? }`, the SDK can call +that app-owned client's `getEntry()` method for managed entry fetching. For locale mechanics, see [Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md). @@ -346,15 +352,15 @@ route. **Integration category:** Required for first integration -The browser app fetches Contentful entries. The Web SDK chooses the current variant after the -baseline entry and Experience API selections exist. +The browser app owns the Contentful delivery client, credentials, and delivery policy. The preferred +path passes that app-owned `contentful.js` client to the SDK as +`contentful: { client, defaultQuery?, cache? }`, then calls `fetchOptimizedEntry(entryId)` after +`page()` or `identify()`. The stateful Web SDK uses current `selectedOptimizations` when omitted. -1. Fetch the baseline Contentful entry with one CDA locale and enough include depth to resolve - optimization entries and variants. +1. Configure the Web SDK with `contentful: { client, defaultQuery?, cache? }`. 2. Call `page()` or `identify()` before rendering optimized content so SDK state has current `selectedOptimizations`. -3. Pass the baseline entry to `resolveOptimizedEntry()`. In a stateful Web SDK integration, the - method uses current SDK state when you omit the second argument. +3. Call `fetchOptimizedEntry(entryId)` to fetch the baseline entry and resolve the selected variant. 4. Render the returned `entry`. If no matching optimization exists, the SDK returns the baseline entry. 5. Store the baseline entry ID separately from the resolved entry ID so later rerenders do not @@ -366,14 +372,11 @@ baseline entry and Experience API selections exist. ```ts async function renderEntry(entryId: string, element: HTMLElement): Promise { - const baselineEntry = await contentfulClient.getEntry(entryId, { - include: 10, - locale: APP_LOCALE, + const resolved = await optimization.fetchOptimizedEntry(entryId, { + query: { locale: APP_LOCALE }, }) - // Omitted selections use current SDK state from the most recent accepted page or identify call. - const resolved = optimization.resolveOptimizedEntry(baselineEntry) - const { entry, optimizationContextId, selectedOptimization } = resolved + const { baselineEntry, entry, optimizationContextId, selectedOptimization } = resolved element.textContent = String(entry.fields.headline ?? '') @@ -399,9 +402,25 @@ async function renderEntry(entryId: string, element: HTMLElement): Promise } ``` +Manual baseline fetching plus `resolveOptimizedEntry()` remains supported when the app needs custom +delivery behavior or already has the baseline entry: + +**Adapt this to your use case:** + +```ts +const baselineEntry = await contentfulClient.getEntry(entryId, { + include: 10, + locale: APP_LOCALE, +}) + +// Omitted selections use current SDK state from the most recent accepted page or identify call. +const { entry } = optimization.resolveOptimizedEntry(baselineEntry) +``` + Entry resolution expects standard single-locale CDA fields such as `fields.nt_experiences` and -`fields.nt_variants`. All-locale CDA responses put field values under locale keys and cause -resolution to fall back to the baseline entry. +`fields.nt_variants`. Do not configure SDK-managed fetches or manual fetches with `contentful.js` +`withAllLocales` or raw CDA `locale=*` responses. All-locale CDA responses put field values under +locale keys and cause resolution to fall back to the baseline entry. For deeper mechanics and fallback behavior, see [Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md). @@ -645,7 +664,9 @@ resolution without a framework adapter. 3. Use one root for entries that share one SDK instance. The root can create the SDK from attributes and assigned properties, or it can reuse an existing `window.contentfulOptimization` instance. 4. Assign structured values such as `defaults`, `api`, `trackEntryInteraction`, `sdk`, and - `baselineEntry` as DOM properties, not string attributes. + `baselineEntry` as DOM properties, not string attributes. Use `entry-id` as SDK-managed fetch + input when the active SDK has `contentful.client`; assign `baselineEntry` only when app code + fetches the entry itself. 5. Listen for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to render application-owned UI. @@ -658,28 +679,21 @@ import { type ContentfulOptimizedEntryEventDetail, defineContentfulOptimizationElements, } from '@contentful/optimization-web/web-components' +import { optimization } from './optimization' defineContentfulOptimizationElements() const root = document.querySelector('ctfl-optimization-root') const entry = document.querySelector( - 'ctfl-optimized-entry[data-entry-id]', + 'ctfl-optimized-entry[entry-id]', ) if (root) { - // Structured SDK options must be assigned as properties, not string attributes. - root.defaults = { consent: true } - root.trackEntryInteraction = { hovers: false } + // Reuse the app-owned SDK configured with contentful: { client }. + root.sdk = optimization } -if (entry?.dataset.entryId) { - const baselineEntry = await contentfulClient.getEntry(entry.dataset.entryId, { - include: 10, - locale: APP_LOCALE, - }) - - // The SDK resolves after app code supplies the structured baseline entry object. - entry.baselineEntry = baselineEntry +if (entry) { entry.addEventListener('ctfl-entry-resolved', (event) => { const { detail } = event as CustomEvent @@ -691,12 +705,13 @@ if (entry?.dataset.entryId) { **Follow this pattern:** ```html - - + + ``` -The `data-entry-id` attribute above is app-owned lookup metadata, not SDK fetch configuration. +The `entry-id` attribute is SDK-managed fetch input when the active SDK has `contentful.client`. +Assign the `baselineEntry` property only when app code fetches the entry itself. `@contentful/optimization-web/web-components` is side-effect-free. Custom elements are registered only when `defineContentfulOptimizationElements()` runs. If the root owns the SDK instance, diff --git a/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md b/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md index 542e4a02c..c14b25731 100644 --- a/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md +++ b/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md @@ -13,13 +13,13 @@ title: Framework and meta-framework reference implementation requirements ## Summary -Reference implementations are executable examples and validation harnesses for supported -Optimization SDK integration patterns. They show SDK maintainers and application engineers how -public SDK APIs fit into realistic framework and meta-framework applications. +Reference implementations are executable validation artifacts for supported Optimization SDK +integration patterns. They show SDK maintainers and application engineers how public SDK APIs fit +into realistic framework and meta-framework applications. -Each reference implementation must demonstrate the intended application workflow, exercise the -relevant SDK surface against shared mocks and fixtures, and provide automated E2E coverage for the -practical happy paths exposed by the demonstrated SDKs. +Each reference implementation must validate the intended application workflow, exercise the relevant +SDK surface against shared mocks and fixtures, and provide automated E2E coverage for the practical +happy paths exposed by the demonstrated SDKs. The product goal is to keep framework SDKs and meta-framework patterns usable, documented, and validated across personalization, tracking, consent, locale handling, preview, offline behavior, @@ -90,10 +90,10 @@ throwaway demos. ## Selection requirements - **REFREQ-1 SDK coverage** - Each public framework adapter SDK must have at least one reference - implementation that demonstrates its primary integration path. + implementation that validates its primary integration path. - **REFREQ-2 Runtime coverage** - Each public runtime SDK must have at least one reference - implementation that demonstrates initialization, consent, personalization, tracking, locale - handling, diagnostics, and preview where the runtime supports those behaviors. + implementation that validates initialization, consent, personalization, tracking, locale handling, + diagnostics, and preview where the runtime supports those behaviors. - **REFREQ-3 Meta-framework coverage** - Each supported meta-framework pattern must have a reference implementation when the pattern involves distinct server/browser, routing, rendering, caching, or hydration decisions. @@ -110,13 +110,13 @@ throwaway demos. - **REFREQ-7 Customer-style structure** - The app must use normal framework application structure: routes, screens, providers, modules, middleware, controllers, components, or views as appropriate. - **REFREQ-8 Minimal domain surface** - The app must include enough content, navigation, controls, - and state display to validate SDK behavior without becoming a broad sample product. + and state display to validate SDK behavior without becoming a broad product shell. - **REFREQ-9 App-owned content fetching** - The app must fetch Contentful entries in application code and pass single-locale CDA entries to SDK resolution helpers. - **REFREQ-10 App-owned consent controls** - The app must include explicit consent controls when the - demonstrated flow depends on user consent. + validated flow depends on user consent. - **REFREQ-11 App-owned identity controls** - The app must include identify and reset controls when - profile transitions are part of the SDK behavior being demonstrated. + profile transitions are part of the SDK behavior being validated. - **REFREQ-12 Real routing or navigation** - The app must use the framework's routing or navigation system when testing page, screen, request, middleware, or navigation tracking. - **REFREQ-13 Stable test identifiers** - The app must expose stable automation identifiers for @@ -294,7 +294,7 @@ pattern. ## README and runbook requirements - **REFREQ-71 README role** - Each reference implementation README must explain what the app - demonstrates, which SDK packages it validates, and which application pattern it represents. + validates, which SDK packages it exercises, and which application pattern it represents. - **REFREQ-72 Setup** - The README must provide setup commands from the monorepo root and document environment variables using `.env.example`. - **REFREQ-73 Running locally** - The README must explain how to start the app and required mock @@ -320,8 +320,7 @@ Use this checklist when adding or evaluating a framework or meta-framework refer - [ ] The implementation has a clear SDK or pattern owner and belongs in the correct `implementations/` subtree. - [ ] The app uses public SDK APIs and does not import package internals or generated outputs. -- [ ] The app demonstrates a realistic framework setup path rather than a synthetic test harness - alone. +- [ ] The app validates a realistic framework setup path rather than a synthetic test harness alone. - [ ] App-owned Contentful fetching uses the resolved Contentful locale and single-locale CDA entry payloads. - [ ] Consent, identify, reset, and profile-continuity flows are observable and testable when the diff --git a/documentation/product/optimization-sdk-suite-fprd.md b/documentation/product/optimization-sdk-suite-fprd.md index 08d895576..d171a537c 100644 --- a/documentation/product/optimization-sdk-suite-fprd.md +++ b/documentation/product/optimization-sdk-suite-fprd.md @@ -22,8 +22,8 @@ The suite is layered: - Framework adapter SDKs provide framework-native integration surfaces on top of runtime SDKs. - Meta-framework reference patterns document supported SDK compositions for application frameworks that do not require a dedicated SDK. -- Reference implementations provide executable examples and validation harnesses for supported - integration patterns. +- Reference implementations provide executable validation artifacts for supported integration + patterns. - Supporting packages provide low-level API transport, schemas, preview tooling, and native bridge infrastructure. @@ -244,22 +244,22 @@ Reference implementations are part of the SDK Suite product surface. They show h fit into realistic applications and provide executable validation for supported integration patterns. -- **REF-1 Public-surface examples** - Each reference implementation must use the public SDK surface - it demonstrates. Reusable SDK behavior belongs in packages, not reference apps. +- **REF-1 Public SDK surface** - Each reference implementation must use the public SDK surface it + validates. Reusable SDK behavior belongs in packages, not reference apps. - **REF-2 SDK coverage** - Each published SDK must have at least one reference implementation that - demonstrates its primary integration path. + validates its primary integration path. - **REF-3 Runtime behavior coverage** - Runtime SDK reference implementations must demonstrate initialization, consent, personalization, tracking, locale handling, local mock API usage, and preview where the runtime supports it. - **REF-4 Framework adapter coverage** - Each framework adapter SDK must have a customer-style - reference implementation that demonstrates framework-native setup, routing or request tracking, + reference implementation that validates framework-native setup, routing or request tracking, optimized rendering, live updates, preview where supported, and analytics handoff. - **REF-5 Meta-framework pattern coverage** - Meta-frameworks such as Nuxt.js and SvelteKit can be supported through reference implementations instead of dedicated SDKs when SDK composition covers the required behavior. When a dedicated adapter exists, such as Next.js, its reference implementations must validate the adapter and the relevant rendering modalities. - **REF-6 Modality coverage** - CSR, SSR, prerendering, and hybrid web application patterns must - have separate reference implementations when combining them would make the example unclear or + have separate reference implementations when combining them would make the reference unclear or technically impractical. This applies whether those modalities are built into a framework or provided by a meta-framework. - **REF-7 Native parity coverage** - Native iOS and Android reference implementations must cover @@ -273,7 +273,7 @@ patterns. - **REF-10 E2E validation** - Reference implementations must define the appropriate E2E runner for the platform, such as Playwright, Detox, XCUITest, or Maestro. - **REF-11 Documentation role** - Reference implementation README files must explain what the app - demonstrates, how to run it, how to validate it, and which SDK or package docs it supports. + validates, how to run it, how to validate it, and which SDK or package docs it supports. - **REF-12 Known gaps** - Reference implementations must document product or handoff gaps when they expose a missing SDK capability, such as server-to-client initial optimization data seeding. @@ -409,8 +409,8 @@ The suite must support these application and validation workflows: - Framework adapter SDKs must reuse runtime SDKs and Core behavior rather than diverging into independent implementations. - Native SDKs must keep bridge behavior aligned with shared Core semantics. -- Reference implementations must stay small and example-oriented. They must not become reusable - application frameworks or hidden SDK layers. +- Reference implementations must stay focused, consumer-oriented, and coverage-preserving. They must + not become reusable application frameworks or hidden SDK layers. - Shared mocks, fixtures, and scenario contracts are internal validation dependencies for reference implementations. - Documentation that describes public SDK status must align with this fPRD. If package docs describe diff --git a/implementations/AGENTS.md b/implementations/AGENTS.md index ac67af45d..a41af3eb3 100644 --- a/implementations/AGENTS.md +++ b/implementations/AGENTS.md @@ -4,12 +4,17 @@ Applies to reference implementations and shared implementation contracts under ` ## Boundaries -- Reference implementations are maintained SDK integration references, E2E targets, and concrete - consumer reference material. They are not demos or package-local `dev/` harnesses; do not dismiss - failures, stale flows, or docs gaps as demo-only. +- Reference implementations are first-class product artifacts: maintained SDK integration contracts, + E2E targets, customer-facing evidence, and concrete consumer reference material. They are not + optional demos or package-local `dev/` harnesses; do not dismiss failures, stale flows, incomplete + coverage, or docs gaps as demo-only. - Reusable SDK behavior belongs in `packages/`; reference implementations should consume the public SDK surface the way customers do. -- Keep apps small, consumer-oriented, and aligned with the public SDK surface they exercise. +- Keep apps focused, consumer-oriented, coverage-preserving, and aligned with the public SDK surface + they exercise. +- Do not remove, reduce, or bypass implementation behavior because it appears app-local. First + verify whether it documents a supported integration path, backs E2E coverage, or exposes an SDK + gap. - CDA entry fetches used for SDK entry resolution must stay single-locale. Do not use `withAllLocales` or `locale=*`; choose the application Contentful locale in the implementation and pass it explicitly to CDA requests. @@ -22,7 +27,8 @@ Applies to reference implementations and shared implementation contracts under ` - Follow root Markdown rules and [`../STYLE_GUIDE.md`](../STYLE_GUIDE.md). - Use the repo-standard header, implementation-specific `

`, Readme/Guides/Reference/Contributing - navigation, pre-release warning, and an introduction naming the SDK packages they integrate. + navigation, pre-release warning, and an introduction naming the SDK packages they integrate and + the customer-style integration path they validate. - Use this default top-level order: header/navigation/warning, introduction naming the integrated SDK package or native status, `## What this covers`, optional near-top architecture notes, `## CDA locale handling`, `## Prerequisites`, `## Setup`, `## Running locally`, diff --git a/implementations/node-sdk+web-sdk/README.md b/implementations/node-sdk+web-sdk/README.md index 51b9458fe..70a3672a0 100644 --- a/implementations/node-sdk+web-sdk/README.md +++ b/implementations/node-sdk+web-sdk/README.md @@ -61,6 +61,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) diff --git a/implementations/node-sdk/AGENTS.md b/implementations/node-sdk/AGENTS.md index e236661f0..b34bcf9fe 100644 --- a/implementations/node-sdk/AGENTS.md +++ b/implementations/node-sdk/AGENTS.md @@ -4,7 +4,7 @@ Node SSR reference implementation for `@contentful/optimization-node`. ## Rules -- Keep this app minimal and documentation-oriented; reusable Node SDK behavior belongs in +- Keep this app focused and reference-oriented; reusable Node SDK behavior belongs in `packages/node/node-sdk`. - Local mock defaults come from `.env.example`. - `serve` uses PM2-managed processes; use `serve:stop` when done. diff --git a/implementations/node-sdk/README.md b/implementations/node-sdk/README.md index 7adb017b6..7e074ebb1 100644 --- a/implementations/node-sdk/README.md +++ b/implementations/node-sdk/README.md @@ -52,6 +52,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) diff --git a/implementations/react-web-sdk/README.md b/implementations/react-web-sdk/README.md index 4e571ec81..1e5ed5e1c 100644 --- a/implementations/react-web-sdk/README.md +++ b/implementations/react-web-sdk/README.md @@ -64,6 +64,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) @@ -233,8 +240,8 @@ Implementation-specific touchpoints: | `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` | | Manual `selectedOptimizations` lock logic | `` | -**What stays the same:** `contentfulClient.ts`, locale config, type definitions, E2E test files, -page/section component structure. +**What stays the same in this reference path:** `contentfulClient.ts`, locale config, type +definitions, `RichTextRenderer`, E2E test files, page/section component structure. **Key architectural difference:** `App.tsx` acts as a persistent layout (contains `AnalyticsEventDisplay` that stays mounted across route changes). Pages are route children that diff --git a/implementations/web-sdk/AGENTS.md b/implementations/web-sdk/AGENTS.md index 96ff5be66..74022093f 100644 --- a/implementations/web-sdk/AGENTS.md +++ b/implementations/web-sdk/AGENTS.md @@ -4,8 +4,8 @@ Vanilla JS reference implementation for `@contentful/optimization-web`. ## Rules -- Keep this app minimal and example-oriented; reusable runtime logic belongs in - `packages/web/web-sdk`. +- Keep this app focused, consumer-oriented, and coverage-preserving; reusable runtime logic belongs + in `packages/web/web-sdk`. - `build` copies Web SDK, preview-panel, and `lib/e2e-web/src/theme.css` assets into `public/dist`. - `server.ts` is a lightweight Node.js HTTP server; it reads `.env` (or `.env.example`), injects env vars as `window.ENVIRONMENT` into the HTML, and serves `public/` with an SPA fallback. No Docker diff --git a/implementations/web-sdk/README.md b/implementations/web-sdk/README.md index ea8dcd7a8..1f006a5c1 100644 --- a/implementations/web-sdk/README.md +++ b/implementations/web-sdk/README.md @@ -41,6 +41,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) diff --git a/implementations/web-sdk_angular/README.md b/implementations/web-sdk_angular/README.md index f154fbc89..28ae66520 100644 --- a/implementations/web-sdk_angular/README.md +++ b/implementations/web-sdk_angular/README.md @@ -54,6 +54,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) diff --git a/implementations/web-sdk_react/AGENTS.md b/implementations/web-sdk_react/AGENTS.md index 153eeccf0..fd0e1605d 100644 --- a/implementations/web-sdk_react/AGENTS.md +++ b/implementations/web-sdk_react/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -React reference implementation demonstrating direct `@contentful/optimization-web` usage in a React +React reference implementation for direct `@contentful/optimization-web` usage in a React application. ## Rules diff --git a/implementations/web-sdk_react/README.md b/implementations/web-sdk_react/README.md index 037f3d13a..d9d35453d 100644 --- a/implementations/web-sdk_react/README.md +++ b/implementations/web-sdk_react/README.md @@ -69,6 +69,13 @@ for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) for the entry contract. +This reference uses the supported manual path: application code fetches single-locale Contentful +entries and passes them to SDK entry resolution. For JavaScript integrations with an +application-owned `contentful.js` client, we recommend configuring the SDK with +`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`, +`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` / +`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping. + ## Prerequisites - Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 2005586d9..f7f0a59ec 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,8 +4,8 @@ Applies to all workspace packages under `packages/`. ## Boundaries -- Published SDK behavior belongs in packages; reference implementations consume it through public - APIs as maintained E2E targets and consumer references. +- Published SDK behavior belongs in packages; reference implementations are first-class downstream + consumers that exercise public APIs as maintained E2E targets and consumer references. - Shared cross-platform behavior usually belongs in `packages/universal/core-sdk` unless it is clearly platform-specific. - Keep package-local `dev/` harnesses aligned with the SDK behavior they exercise. @@ -45,8 +45,9 @@ For pnpm-managed packages with matching scripts, use `pnpm --filter + {(resolvedEntry) => } ) } ``` -Fetch Contentful entries in your app layer. For optimized entries, request linked entries deeply -enough for the baseline and variants, commonly with `include: 10`. +Configure the SDK with `contentful: { client }` on `OptimizationRoot`, `OptimizationProvider`, or +`ContentfulOptimization.create(...)`. SDK-managed fetching merges `contentful.defaultQuery`, +per-entry `entryQuery`, the SDK `locale` fallback, and `include: 10`. Manual baseline entries remain +supported and unchanged: -Use one CDA locale for entries passed to `OptimizedEntry` or `useEntryResolver()`. For localized -apps, derive the application locale from your navigation, i18n, or app configuration layer and pass -it directly to Contentful CDA requests. Do not pass all-locale CDA responses from `withAllLocales` -or `locale=*`; these APIs expect direct single-locale field values. See +```tsx + + {(resolvedEntry) => } + +``` + +Use one CDA locale for entries fetched through `entryId`, passed to `OptimizedEntry`, or resolved +with `useEntryResolver()` or `useOptimizedEntry()`. For localized apps, derive the application +locale from your navigation, i18n, or app configuration layer and pass it to `entryQuery`, +`contentful.defaultQuery`, or manual Contentful CDA requests. Do not pass all-locale CDA responses +from `withAllLocales` or `locale=*`; these APIs expect direct single-locale field values. See [Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) for the entry contract and [Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) for the broader locale model. +Use `useOptimizedEntry()` when a component needs the same managed `entryId` or manual +`baselineEntry` source model without the `OptimizedEntry` wrapper: + +```tsx +import { useOptimizedEntry } from '@contentful/optimization-react-native' + +function HeroData() { + const { entry, isLoading, error } = useOptimizedEntry({ entryId: 'hero-entry-id' }) + + if (isLoading || error || !entry) return null + + return +} +``` + +Use `onEntryResolved`, the render prop metadata, or the hook's `metadata` and `isResolved` fields +when application code needs the baseline ID, resolved entry ID, or optimization context after +tracking is ready. + Use `useEntryResolver()` when a component needs manual entry resolution without the `OptimizedEntry` wrapper: diff --git a/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx b/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx index 3f63c4a84..12f3dc13b 100644 --- a/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx +++ b/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx @@ -15,6 +15,16 @@ const selectedOptimizations = { subscribe: rs.fn(() => ({ unsubscribe: rs.fn() })), } const resolveOptimizedEntry = rs.fn((entry: Entry): ResolvedData => ({ entry })) +const fetchContentfulEntry = rs.fn( + async (entryId: string) => await Promise.resolve(createEntry(entryId)), +) +const optimization = { + fetchContentfulEntry, + resolveOptimizedEntry, + states: { + selectedOptimizations, + }, +} const useViewportTracking = rs.fn((_options: Record) => ({ isVisible: false, onLayout: rs.fn(), @@ -29,12 +39,7 @@ rs.mock('react-native', () => ({ })) rs.mock('../context/OptimizationContext', () => ({ - useOptimization: () => ({ - resolveOptimizedEntry, - states: { - selectedOptimizations, - }, - }), + useOptimization: () => optimization, })) rs.mock('../hooks/useViewportTracking', () => ({ @@ -68,6 +73,21 @@ function createEntry(id: string): Entry { } } +function createDeferred(): { + readonly promise: Promise + readonly reject: (reason?: unknown) => void + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + let rejectDeferred: (reason?: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve + rejectDeferred = reject + }) + + return { promise, reject: rejectDeferred, resolve: resolveDeferred } +} + function getCallOptions( mock: typeof useViewportTracking | typeof useTapTracking, ): Record { @@ -97,6 +117,14 @@ describe('OptimizedEntry', () => { void beforeEach(() => { rs.clearAllMocks() selectedOptimizations.current = undefined + fetchContentfulEntry.mockImplementation( + async (entryId: string) => await Promise.resolve(createEntry(entryId)), + ) + resolveOptimizedEntry.mockImplementation( + (entry: Entry): ResolvedData => ({ + entry, + }), + ) }) void afterEach(() => { @@ -211,4 +239,94 @@ describe('OptimizedEntry', () => { expect(getCallOptions(useViewportTracking).optimizationContextId).toBe('ctx-1') expect(getCallOptions(useTapTracking).optimizationContextId).toBe('ctx-1') }) + + it('passes resolved metadata to render props and onEntryResolved', async () => { + const { OptimizedEntry } = await import('./OptimizedEntry') + const testRenderer = await loadTestRenderer() + const baselineEntry = createEntry('baseline-entry') + const renderedMetadata: string[] = [] + const onEntryResolved = rs.fn() + + act(() => { + renderer = testRenderer.create( + + {(resolved, metadata) => { + renderedMetadata.push( + `${metadata.baselineEntryId}:${metadata.entryId}:${metadata.optimizationContextId}`, + ) + return resolved.sys.id + }} + , + ) + }) + + expect(renderedMetadata).toContain('baseline-entry:baseline-entry:undefined') + expect(onEntryResolved).toHaveBeenCalledWith( + expect.objectContaining({ + baselineEntry, + baselineEntryId: 'baseline-entry', + entry: baselineEntry, + entryId: 'baseline-entry', + }), + ) + }) + + it('renders loadingFallback and skips tracking while managed entryId is loading', async () => { + const { OptimizedEntry } = await import('./OptimizedEntry') + const testRenderer = await loadTestRenderer() + const deferred = createDeferred() + fetchContentfulEntry.mockImplementation(async () => await deferred.promise) + + act(() => { + renderer = testRenderer.create( + + {(resolvedEntry) => resolvedEntry.sys.id} + , + ) + }) + + expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline-entry', { locale: 'de-DE' }) + expect(useViewportTracking).not.toHaveBeenCalled() + expect(useTapTracking).not.toHaveBeenCalled() + + const baselineEntry = createEntry('baseline-entry') + await act(async () => { + deferred.resolve(baselineEntry) + await deferred.promise + }) + + expect(getCallOptions(useViewportTracking).entry).toBe(baselineEntry) + expect(getCallOptions(useTapTracking).entry).toBe(baselineEntry) + }) + + it('renders managed entryId fetch errors and reports each error once', async () => { + const { OptimizedEntry } = await import('./OptimizedEntry') + const testRenderer = await loadTestRenderer() + const error = new Error('CDA failed') + const onEntryError = rs.fn() + fetchContentfulEntry.mockImplementation(async () => await Promise.reject(error)) + + await act(async () => { + renderer = testRenderer.create( + `error: ${entryError.message}`} + onEntryError={onEntryError} + > + {(resolvedEntry) => resolvedEntry.sys.id} + , + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(onEntryError).toHaveBeenCalledTimes(1) + expect(onEntryError).toHaveBeenCalledWith(error) + expect(useViewportTracking).not.toHaveBeenCalled() + expect(useTapTracking).not.toHaveBeenCalled() + }) }) diff --git a/packages/react-native-sdk/src/components/OptimizedEntry.tsx b/packages/react-native-sdk/src/components/OptimizedEntry.tsx index 6a4451030..7828f5d39 100644 --- a/packages/react-native-sdk/src/components/OptimizedEntry.tsx +++ b/packages/react-native-sdk/src/components/OptimizedEntry.tsx @@ -1,41 +1,35 @@ -import type { ResolvedData } from '@contentful/optimization-core' -import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas' -import { isResolvedOptimizedEntry } from '@contentful/optimization-core/api-schemas' +import type { + ContentfulEntryQuery, + OptimizedEntryMetadata, + ResolvedData, +} from '@contentful/optimization-core' import type { Entry, EntrySkeletonType } from 'contentful' -import React, { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import React, { type ReactNode } from 'react' import { View, type StyleProp, type ViewStyle } from 'react-native' import { useInteractionTracking } from '../context/InteractionTrackingContext' -import { useLiveUpdates } from '../context/LiveUpdatesContext' -import { useOptimization } from '../context/OptimizationContext' +import { useOptimizedEntry, type UseOptimizedEntryParams } from '../hooks/useOptimizedEntry' import { useTapTracking } from '../hooks/useTapTracking' import { useViewportTracking } from '../hooks/useViewportTracking' +export type OptimizedEntryLoadingFallback = ReactNode | (() => ReactNode) +export type OptimizedEntryErrorFallback = ReactNode | ((error: Error) => ReactNode) +export type OptimizedEntryRenderProp = ( + resolvedEntry: Entry, + metadata: OptimizedEntryMetadata, +) => ReactNode +export type OptimizedEntryChildren = ReactNode | OptimizedEntryRenderProp + /** - * Props for the {@link OptimizedEntry} component. + * Shared props for the {@link OptimizedEntry} component. * * @public */ -export interface OptimizedEntryProps { - /** - * The baseline Contentful entry to optimize and track. - * For optimized entries (those with `nt_experiences`), the component - * automatically resolves variants. For non-optimized entries, the - * entry is passed through unchanged. - * - * @example - * ```typescript - * const entry = await contentful.getEntry('entry-id', { - * include: 10, - * }) - * ``` - */ - baselineEntry: Entry - +interface OptimizedEntrySharedProps { /** * Content to render. Accepts either a render prop or static children. * - * - **Render prop** `(resolvedEntry: Entry) => ReactNode`: receives the - * resolved entry (variant or baseline) and returns content to render. + * - **Render prop** `(resolvedEntry: Entry, metadata: OptimizedEntryMetadata) => ReactNode`: + * receives the resolved entry plus baseline and optimization metadata. * Use this when you need the resolved entry data. * - **Static children** `ReactNode`: rendered as-is without entry data. * Use this when you only need tracking, not variant resolution. @@ -59,7 +53,27 @@ export interface OptimizedEntryProps { * * ``` */ - children: ReactNode | ((resolvedEntry: Entry) => ReactNode) + children: OptimizedEntryChildren + + /** + * Optional fallback rendered while SDK-managed entry fetching is pending. + */ + loadingFallback?: OptimizedEntryLoadingFallback + + /** + * Optional fallback rendered when SDK-managed entry fetching fails. + */ + errorFallback?: OptimizedEntryErrorFallback + + /** + * Callback invoked once for each SDK-managed entry fetching error. + */ + onEntryError?: (error: Error) => void + + /** + * Callback invoked when a resolved entry is rendered with tracking ready. + */ + onEntryResolved?: (metadata: OptimizedEntryMetadata) => void /** * Minimum time (in milliseconds) the component must be visible @@ -140,6 +154,32 @@ export interface OptimizedEntryProps { onTap?: (resolvedEntry: Entry) => void } +type OptimizedEntrySourceProps = + | { + /** + * The baseline Contentful entry to optimize and track. + * For optimized entries, the component resolves variants. For non-optimized entries, + * the entry is passed through unchanged. + */ + baselineEntry: Entry + entryId?: never + entryQuery?: never + } + | { + baselineEntry?: never + /** Contentful entry ID fetched through the SDK-managed Contentful client. */ + entryId: string + /** Per-call Contentful `getEntry()` query overrides. */ + entryQuery?: ContentfulEntryQuery + } + +/** + * Props for the {@link OptimizedEntry} component. + * + * @public + */ +export type OptimizedEntryProps = OptimizedEntrySharedProps & OptimizedEntrySourceProps + function resolveTapsEnabled( trackTaps: boolean | undefined, onTap: ((resolvedEntry: Entry) => void) | undefined, @@ -150,15 +190,129 @@ function resolveTapsEnabled( return globalTaps } +function resolveLoadingFallback( + loadingFallback: OptimizedEntryLoadingFallback | undefined, +): ReactNode { + if (typeof loadingFallback === 'function') { + return loadingFallback() + } + + return loadingFallback +} + +function resolveErrorFallback( + errorFallback: OptimizedEntryErrorFallback | undefined, + error: Error, +): ReactNode { + if (typeof errorFallback === 'function') { + return errorFallback(error) + } + + return errorFallback +} + +function renderFallback(content: ReactNode): React.JSX.Element | null { + return content === undefined || content === null ? null : <>{content} +} + +function resolveChildren( + children: OptimizedEntryChildren, + entry: Entry, + metadata: OptimizedEntryMetadata, +): ReactNode { + return typeof children === 'function' ? children(entry, metadata) : children +} + +function resolveUseOptimizedEntryParams( + entryProps: OptimizedEntrySourceProps, + liveUpdates: boolean | undefined, + onEntryError: ((error: Error) => void) | undefined, + onEntryResolved: ((metadata: OptimizedEntryMetadata) => void) | undefined, +): UseOptimizedEntryParams { + if (entryProps.baselineEntry !== undefined) { + return { baselineEntry: entryProps.baselineEntry, liveUpdates, onEntryError, onEntryResolved } + } + + return { + entryId: entryProps.entryId, + entryQuery: entryProps.entryQuery, + liveUpdates, + onEntryError, + onEntryResolved, + } +} + +interface OptimizedEntryContentProps { + readonly children: OptimizedEntryChildren + readonly dwellTimeMs?: number + readonly minVisibleRatio?: number + readonly metadata: OptimizedEntryMetadata + readonly onTap?: (resolvedEntry: Entry) => void + readonly resolvedData: ResolvedData + readonly style?: StyleProp + readonly testID?: string + readonly trackTaps?: boolean + readonly trackViews?: boolean + readonly viewDurationUpdateIntervalMs?: number +} + +function OptimizedEntryContent({ + children, + dwellTimeMs, + minVisibleRatio, + metadata, + onTap, + resolvedData, + style, + testID, + trackTaps, + trackViews, + viewDurationUpdateIntervalMs, +}: OptimizedEntryContentProps): React.JSX.Element { + const interactionTracking = useInteractionTracking() + const viewsEnabled = trackViews ?? interactionTracking.views + const tapsEnabled = resolveTapsEnabled(trackTaps, onTap, interactionTracking.taps) + + const { onLayout } = useViewportTracking({ + entry: resolvedData.entry, + optimizationContextId: resolvedData.optimizationContextId, + selectedOptimization: resolvedData.selectedOptimization, + dwellTimeMs, + minVisibleRatio, + viewDurationUpdateIntervalMs, + enabled: viewsEnabled, + }) + + const { onTouchStart, onTouchEnd } = useTapTracking({ + entry: resolvedData.entry, + optimizationContextId: resolvedData.optimizationContextId, + selectedOptimization: resolvedData.selectedOptimization, + enabled: tapsEnabled, + onTap, + }) + + return ( + + {resolveChildren(children, resolvedData.entry, metadata)} + + ) +} + /** * Unified component for tracking and personalizing Contentful entries. * * Handles both optimized entries (with `nt_experiences`) and non-optimized * entries. For optimized entries, it resolves the correct variant based on the - * user's profile. For all entries, it tracks views and taps. + * user's profile. For all resolved entries, it tracks views and taps. * * @param props - {@link OptimizedEntryProps} - * @returns A wrapper View with interaction tracking attached. + * @returns A wrapper View with interaction tracking attached after a real entry exists. * * @remarks * "Tracking" refers to tracking Contentful content entries, @@ -170,7 +324,18 @@ function resolveTapsEnabled( * flashing. Set `liveUpdates` to `true` or open the preview panel to enable * real-time variant switching. * - * @example Basic usage with render prop + * Configure `contentful.client` on {@link OptimizationRoot} or + * {@link OptimizationProvider} to let `entryId` fetch the baseline entry through the SDK. + * Passing `baselineEntry` keeps manual application-owned fetching behavior unchanged. + * + * @example SDK-managed entry fetching + * ```tsx + * + * {(resolvedEntry) => } + * + * ``` + * + * @example Manual baseline entry with render prop * ```tsx * * @@ -208,8 +373,11 @@ function resolveTapsEnabled( * @public */ export function OptimizedEntry({ - baselineEntry, children, + loadingFallback, + errorFallback, + onEntryError, + onEntryResolved, dwellTimeMs, minVisibleRatio, viewDurationUpdateIntervalMs, @@ -219,87 +387,34 @@ export function OptimizedEntry({ trackViews, trackTaps, onTap, -}: OptimizedEntryProps): React.JSX.Element { - const contentfulOptimization = useOptimization() - const liveUpdatesContext = useLiveUpdates() - const interactionTracking = useInteractionTracking() - - const isOptimized = isResolvedOptimizedEntry(baselineEntry) - - const shouldLiveUpdate = - liveUpdatesContext?.previewPanelVisible === true || - (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false) - - const [lockedSelectedOptimizations, setLockedSelectedOptimizations] = useState< - SelectedOptimizationArray | undefined - >(undefined) - - const isLockedRef = useRef(false) - - useEffect(() => { - if (shouldLiveUpdate) { - isLockedRef.current = false - } - }, [shouldLiveUpdate]) - - useEffect(() => { - if (!isOptimized) return - - const subscription = contentfulOptimization.states.selectedOptimizations.subscribe( - (nextSelectedOptimizations) => { - if (shouldLiveUpdate) { - setLockedSelectedOptimizations(nextSelectedOptimizations) - } else if (!isLockedRef.current && nextSelectedOptimizations !== undefined) { - isLockedRef.current = true - setLockedSelectedOptimizations(nextSelectedOptimizations) - } - }, - ) - - return () => { - subscription.unsubscribe() - } - }, [contentfulOptimization, shouldLiveUpdate, isOptimized]) - - const resolvedData: ResolvedData = useMemo( - () => - isOptimized - ? contentfulOptimization.resolveOptimizedEntry(baselineEntry, lockedSelectedOptimizations) - : { entry: baselineEntry }, - [baselineEntry, contentfulOptimization, lockedSelectedOptimizations, isOptimized], + ...entryProps +}: OptimizedEntryProps): React.JSX.Element | null { + const optimizedEntry = useOptimizedEntry( + resolveUseOptimizedEntryParams(entryProps, liveUpdates, onEntryError, onEntryResolved), ) - const viewsEnabled = trackViews ?? interactionTracking.views - const tapsEnabled = resolveTapsEnabled(trackTaps, onTap, interactionTracking.taps) + if (optimizedEntry.error !== undefined) { + return renderFallback(resolveErrorFallback(errorFallback, optimizedEntry.error)) + } - const { onLayout } = useViewportTracking({ - entry: resolvedData.entry, - optimizationContextId: resolvedData.optimizationContextId, - selectedOptimization: resolvedData.selectedOptimization, - dwellTimeMs, - minVisibleRatio, - viewDurationUpdateIntervalMs, - enabled: viewsEnabled, - }) - - const { onTouchStart, onTouchEnd } = useTapTracking({ - entry: resolvedData.entry, - optimizationContextId: resolvedData.optimizationContextId, - selectedOptimization: resolvedData.selectedOptimization, - enabled: tapsEnabled, - onTap, - }) + if (optimizedEntry.entry === undefined || optimizedEntry.metadata === undefined) { + return renderFallback(resolveLoadingFallback(loadingFallback)) + } return ( - - {typeof children === 'function' ? children(resolvedData.entry) : children} - + trackTaps={trackTaps} + trackViews={trackViews} + viewDurationUpdateIntervalMs={viewDurationUpdateIntervalMs} + /> ) } diff --git a/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx b/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx new file mode 100644 index 000000000..281f235ef --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx @@ -0,0 +1,211 @@ +import type { ResolvedData } from '@contentful/optimization-core' +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core' +import type { Entry, EntrySkeletonType } from 'contentful' +import React, { act } from 'react' +import { loadTestRenderer } from '../test/testRenderer' +import { + useOptimizedEntry, + type UseOptimizedEntryParams, + type UseOptimizedEntryResult, +} from './useOptimizedEntry' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +const selectedOptimizations = { + current: undefined, + subscribe: rs.fn(() => ({ unsubscribe: rs.fn() })), +} +const fetchContentfulEntry = rs.fn( + async (entryId: string) => await Promise.resolve(createEntry(entryId)), +) +const resolveOptimizedEntry = rs.fn((entry: Entry): ResolvedData => ({ entry })) +const optimization = { + fetchContentfulEntry, + resolveOptimizedEntry, + states: { + selectedOptimizations, + }, +} + +rs.mock('../context/OptimizationContext', () => ({ + useOptimization: () => optimization, +})) + +interface TestRenderer { + unmount: () => void +} + +function createEntry(id: string): Entry { + return { + sys: { + id, + type: 'Entry', + contentType: { sys: { id: 'testType', type: 'Link', linkType: 'ContentType' } }, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + publishedVersion: 1, + space: { sys: { id: 'space1', type: 'Link', linkType: 'Space' } }, + revision: 1, + locale: 'en-US', + }, + fields: { title: id }, + metadata: { concepts: [], tags: [] }, + } +} + +function createDeferred(): { + readonly promise: Promise + readonly reject: (reason?: unknown) => void + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + let rejectDeferred: (reason?: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve + rejectDeferred = reject + }) + + return { promise, reject: rejectDeferred, resolve: resolveDeferred } +} + +async function renderHook( + params: UseOptimizedEntryParams, +): Promise<{ getResult: () => UseOptimizedEntryResult; unmount: () => void }> { + const testRenderer = await loadTestRenderer() + let captured: UseOptimizedEntryResult | undefined = undefined + let renderer: TestRenderer | undefined = undefined + + function Probe(): null { + captured = useOptimizedEntry(params) + return null + } + + act(() => { + renderer = testRenderer.create() + }) + + return { + getResult() { + if (captured === undefined) { + throw new Error('Expected hook result to be captured') + } + + return captured + }, + unmount() { + renderer?.unmount() + }, + } +} + +describe('useOptimizedEntry', () => { + let unmount: (() => void) | undefined = undefined + + beforeEach(() => { + rs.clearAllMocks() + selectedOptimizations.current = undefined + fetchContentfulEntry.mockImplementation( + async (entryId: string) => await Promise.resolve(createEntry(entryId)), + ) + resolveOptimizedEntry.mockImplementation( + (entry: Entry): ResolvedData => ({ + entry, + }), + ) + }) + + afterEach(() => { + if (unmount) { + act(() => { + unmount?.() + }) + unmount = undefined + } + }) + + it('returns manual baseline entries synchronously', async () => { + const baselineEntry = createEntry('baseline') + const rendered = await renderHook({ baselineEntry }) + unmount = rendered.unmount + + expect(rendered.getResult()).toMatchObject({ + entry: baselineEntry, + baselineEntry, + error: undefined, + isLoading: false, + isPresentationReady: true, + }) + expect(rendered.getResult()).not.toHaveProperty('isReady') + }) + + it('fetches managed entryId entries with query options', async () => { + const baselineEntry = createEntry('baseline') + const deferred = createDeferred() + const onEntryResolved = rs.fn() + fetchContentfulEntry.mockImplementation(async () => await deferred.promise) + const rendered = await renderHook({ + entryId: 'baseline', + entryQuery: { locale: 'de-DE' }, + onEntryResolved, + }) + unmount = rendered.unmount + + expect(rendered.getResult()).toMatchObject({ + entry: undefined, + baselineEntry: undefined, + isLoading: true, + isPresentationReady: false, + }) + expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' }) + + await act(async () => { + deferred.resolve(baselineEntry) + await deferred.promise + }) + + expect(rendered.getResult()).toMatchObject({ + entry: baselineEntry, + baselineEntry, + error: undefined, + isLoading: false, + isPresentationReady: true, + isResolved: true, + metadata: { + baselineEntry, + baselineEntryId: 'baseline', + entry: baselineEntry, + entryId: 'baseline', + }, + }) + expect(onEntryResolved).toHaveBeenCalledWith( + expect.objectContaining({ + baselineEntry, + entry: baselineEntry, + }), + ) + }) + + it('surfaces managed entryId fetch errors once', async () => { + const error = new Error('CDA failed') + const onEntryError = rs.fn() + fetchContentfulEntry.mockImplementation(async () => await Promise.reject(error)) + const rendered = await renderHook({ entryId: 'baseline', onEntryError }) + unmount = rendered.unmount + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(onEntryError).toHaveBeenCalledTimes(1) + expect(onEntryError).toHaveBeenCalledWith(error) + expect(rendered.getResult()).toMatchObject({ + entry: undefined, + baselineEntry: undefined, + error, + isLoading: false, + isPresentationReady: false, + }) + }) +}) diff --git a/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts b/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts new file mode 100644 index 000000000..22f1f8ad9 --- /dev/null +++ b/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts @@ -0,0 +1,259 @@ +import type { OptimizedEntryMetadata, ResolvedData } from '@contentful/optimization-core' +import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas' +import { isResolvedOptimizedEntry } from '@contentful/optimization-core/api-schemas' +import { + createOptimizedEntryLoadingEntry, + getOptimizedEntrySourceKey, + OptimizedEntrySourceController, + type ContentfulEntryQuery, + type OptimizedEntrySourceSnapshot, +} from '@contentful/optimization-core/entry-source' +import type { Entry, EntrySkeletonType } from 'contentful' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useLiveUpdates } from '../context/LiveUpdatesContext' +import { useOptimization } from '../context/OptimizationContext' + +/** + * Source and behavior options for {@link useOptimizedEntry}. + * + * @public + */ +export type UseOptimizedEntryParams = { + liveUpdates?: boolean + onEntryError?: (error: Error) => void + onEntryResolved?: (metadata: OptimizedEntryMetadata) => void +} & ( + | { + baselineEntry: Entry + entryId?: never + entryQuery?: never + } + | { + baselineEntry?: never + entryId: string + entryQuery?: ContentfulEntryQuery + } +) + +type UseOptimizedEntryBaselineParams = Extract + +interface UseManagedBaselineEntryResult { + readonly entry: Entry | undefined + readonly error: Error | undefined + readonly isLoading: boolean +} + +/** + * Resolved entry state returned by {@link useOptimizedEntry}. + * + * @public + */ +export interface UseOptimizedEntryResult { + readonly entry: TEntry + readonly baselineEntry: TEntry + readonly error: Error | undefined + readonly isLoading: boolean + /** Whether the presentation layer can render resolved entry content. */ + readonly isPresentationReady: boolean + readonly isResolved: boolean + readonly metadata: OptimizedEntryMetadata | undefined + readonly selectedOptimization: ResolvedData['selectedOptimization'] + readonly resolvedData: ResolvedData + readonly selectedOptimizations: SelectedOptimizationArray | undefined +} + +function useManagedBaselineEntry({ + baselineEntry, + entryId, + entryQuery, + onEntryError, +}: UseOptimizedEntryParams): UseManagedBaselineEntryResult { + const sdk = useOptimization() + const entrySourceKey = + entryId === undefined ? undefined : getOptimizedEntrySourceKey(entryId, entryQuery) + const [controller] = useState(() => new OptimizedEntrySourceController()) + const [snapshot, setSnapshot] = useState(() => { + if (baselineEntry !== undefined) { + return { baselineEntry, isLoading: false } + } + + return { entryId, isLoading: true } + }) + const reportedErrorRef = useRef(undefined) + + useEffect(() => { + controller.setSnapshotListener(setSnapshot) + + return () => { + controller.setSnapshotListener(undefined) + controller.disconnect() + } + }, [controller]) + + useEffect(() => { + controller.updateOptions({ + baselineEntry, + entryId, + entryQuery, + sdk, + isSdkStateReady: true, + }) + }, [baselineEntry, controller, entryId, entrySourceKey, sdk]) + + useEffect(() => { + const { error } = snapshot + if (error === undefined) { + reportedErrorRef.current = undefined + return + } + + if (reportedErrorRef.current === error) { + return + } + + reportedErrorRef.current = error + onEntryError?.(error) + }, [onEntryError, snapshot]) + + if (baselineEntry !== undefined) { + return { entry: baselineEntry, error: undefined, isLoading: false } + } + + return { + entry: snapshot.baselineEntry, + error: snapshot.error, + isLoading: snapshot.isLoading, + } +} + +function useResolvedEntryData( + baselineEntry: Entry | undefined, + loadingEntry: Entry, + liveUpdates: boolean | undefined, +): { + readonly resolvedData: ResolvedData + readonly selectedOptimizations: SelectedOptimizationArray | undefined +} { + const sdk = useOptimization() + const liveUpdatesContext = useLiveUpdates() + const isOptimized = baselineEntry !== undefined && isResolvedOptimizedEntry(baselineEntry) + const shouldLiveUpdate = + liveUpdatesContext?.previewPanelVisible === true || + (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false) + const [lockedSelectedOptimizations, setLockedSelectedOptimizations] = useState< + SelectedOptimizationArray | undefined + >(undefined) + const isLockedRef = useRef(false) + + useEffect(() => { + if (shouldLiveUpdate) { + isLockedRef.current = false + } + }, [shouldLiveUpdate]) + + useEffect(() => { + if (!isOptimized) return + + const subscription = sdk.states.selectedOptimizations.subscribe((nextSelectedOptimizations) => { + if (shouldLiveUpdate) { + setLockedSelectedOptimizations(nextSelectedOptimizations) + } else if (!isLockedRef.current && nextSelectedOptimizations !== undefined) { + isLockedRef.current = true + setLockedSelectedOptimizations(nextSelectedOptimizations) + } + }) + + return () => { + subscription.unsubscribe() + } + }, [sdk, shouldLiveUpdate, isOptimized]) + + const resolvedData: ResolvedData = useMemo(() => { + if (baselineEntry === undefined) { + return { entry: loadingEntry } + } + + return isOptimized + ? sdk.resolveOptimizedEntry(baselineEntry, lockedSelectedOptimizations) + : { entry: baselineEntry } + }, [baselineEntry, isOptimized, loadingEntry, lockedSelectedOptimizations, sdk]) + + return { + resolvedData, + selectedOptimizations: isOptimized ? lockedSelectedOptimizations : undefined, + } +} + +/** + * Fetches or accepts a baseline Contentful entry, resolves the selected variant, and returns + * render-ready entry state for React Native components. + * + * @remarks + * Pass `entryId` when the SDK is configured with `contentful.client`. Pass `baselineEntry` to keep + * manual application-owned Contentful fetching unchanged. + * + * @public + */ +export function useOptimizedEntry( + params: UseOptimizedEntryBaselineParams, +): UseOptimizedEntryResult +export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult +export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult { + const managedEntry = useManagedBaselineEntry(params) + const loadingEntryId = (params as { readonly entryId?: string }).entryId ?? 'contentful-entry' + const loadingEntry = useMemo( + () => createOptimizedEntryLoadingEntry(loadingEntryId), + [loadingEntryId], + ) + const { resolvedData, selectedOptimizations } = useResolvedEntryData( + managedEntry.entry, + loadingEntry, + params.liveUpdates, + ) + const hasEntry = managedEntry.entry !== undefined + const metadata = useMemo( + () => + hasEntry + ? { + baselineEntry: managedEntry.entry, + baselineEntryId: managedEntry.entry.sys.id, + entry: resolvedData.entry, + entryId: resolvedData.entry.sys.id, + optimizationContextId: resolvedData.optimizationContextId, + resolvedData, + selectedOptimization: resolvedData.selectedOptimization, + selectedOptimizations, + } + : undefined, + [hasEntry, managedEntry.entry, resolvedData, selectedOptimizations], + ) + const { onEntryResolved } = params + const lastResolvedMetadataRef = useRef(undefined) + + useEffect(() => { + if (metadata === undefined) { + lastResolvedMetadataRef.current = undefined + return + } + + if (lastResolvedMetadataRef.current === metadata) { + return + } + + lastResolvedMetadataRef.current = metadata + onEntryResolved?.(metadata) + }, [metadata, onEntryResolved]) + + return { + entry: hasEntry ? resolvedData.entry : undefined, + baselineEntry: managedEntry.entry, + error: managedEntry.error, + isLoading: managedEntry.isLoading, + isPresentationReady: hasEntry, + isResolved: hasEntry, + metadata, + selectedOptimization: hasEntry ? resolvedData.selectedOptimization : undefined, + resolvedData, + selectedOptimizations: hasEntry ? selectedOptimizations : undefined, + } +} diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts index ba678d66c..9e807adf0 100644 --- a/packages/react-native-sdk/src/index.ts +++ b/packages/react-native-sdk/src/index.ts @@ -62,6 +62,9 @@ export type { export { useEntryResolver } from './hooks/useEntryResolver' export type { UseEntryResolverResult } from './hooks/useEntryResolver' +export { useOptimizedEntry } from './hooks/useOptimizedEntry' +export type { UseOptimizedEntryParams, UseOptimizedEntryResult } from './hooks/useOptimizedEntry' + export { useViewportTracking } from './hooks/useViewportTracking' export type { UseViewportTrackingOptions, diff --git a/packages/universal/api-schemas/README.md b/packages/universal/api-schemas/README.md index 50c7efb3b..e4a85bf34 100644 --- a/packages/universal/api-schemas/README.md +++ b/packages/universal/api-schemas/README.md @@ -83,10 +83,10 @@ These helpers identify and normalize Optimization-owned Contentful fields: | `isResolvedOptimizationEntry` | Structural guard for resolved optimization entries | | `normalizeOptimizationConfig` | Fills omitted optimization config fields with SDK-safe defaults | -These schemas model the SDK's single-locale CDA entry contract. Fetch entries in the app layer with -one application Contentful locale before passing entries to SDK resolution helpers. Avoid -`withAllLocales` or `locale=*` in that path. See -[Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_optimization_and_variant_resolution.html#single-locale-cda-entry-contract) +These schemas model the SDK's single-locale CDA entry contract. Manual resolution passes entries +fetched with one app Contentful locale. JS SDK-managed fetching uses the same contract when a +`contentful.js` client is configured. Avoid `withAllLocales` or `locale=*` in either path. See +[Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) for the entry contract and [Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) for the broader locale model. diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index 1e7b7b90a..3c6a73491 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -41,6 +41,7 @@ exported API signatures. - [Stateless Core](#stateless-core) - [Common configuration](#common-configuration) - [Package surface](#package-surface) + - [Custom entry-source adapters](#custom-entry-source-adapters) - [Preview support](#preview-support) - [Related](#related) @@ -115,6 +116,7 @@ Shared Core configuration: | `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | | `environment` | No | `'main'` | Contentful environment identifier | | `api` | No | See API options below | Experience API and Insights API endpoint options | +| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache | | `eventBuilder` | No | SDK-layer defaults | Event metadata overrides for platform SDK authors | | `fetchOptions` | No | SDK defaults | Fetch timeout and retry behavior | | `logLevel` | No | `'error'` | Minimum log level for the default console sink | @@ -181,27 +183,54 @@ For every option, callback payload, and exported type, use the generated Core exposes reusable primitives for SDK layers: -| Surface | Purpose | -| ------------------------------- | ---------------------------------------------------------------------- | -| `CoreStateful` | Stateful optimization runtime for browser, mobile, and bridge SDKs | -| `CoreStateless` | Stateless optimization runtime for server SDKs | -| Event methods | `identify`, `page`, `screen`, `track`, `trackView`, `trackClick`, etc. | -| Resolution helpers | `resolveOptimizedEntry`, `getMergeTagValue`, and `getFlag` | -| Current-state tracking | `AcceptedCurrentStateTracker` for SDK-owned page or screen adapters | -| `states` | Stateful observable state streams | -| Interceptors | First-party hooks for event and state lifecycle customization | -| Queue policy and fetch helpers | Shared retry, flush, timeout, and offline buffering behavior | -| Signal and observable utilities | Lightweight reactive primitives used internally by stateful SDK layers | - -Resolution helpers expect Contentful entries fetched by the app layer with one CDA locale. Use the -application Contentful locale before resolving entries. Do not pass all-locale CDA responses from -`withAllLocales` or `locale=*`; optimization fields such as `fields.nt_experiences` and -`fields.nt_variants` must be direct single-locale field values. See +| Surface | Purpose | +| ------------------------------- | --------------------------------------------------------------------------------- | +| `CoreStateful` | Stateful optimization runtime for browser, mobile, and bridge SDKs | +| `CoreStateless` | Stateless optimization runtime for server SDKs | +| Event methods | `identify`, `page`, `screen`, `track`, `trackView`, `trackClick`, etc. | +| Resolution and fetch helpers | `resolveOptimizedEntry`, `fetchOptimizedEntry`, `getMergeTagValue`, and `getFlag` | +| Current-state tracking | `AcceptedCurrentStateTracker` for SDK-owned page or screen adapters | +| `states` | Stateful observable state streams | +| Interceptors | First-party hooks for event and state lifecycle customization | +| Queue policy and fetch helpers | Shared retry, flush, timeout, and offline buffering behavior | +| Signal and observable utilities | Lightweight reactive primitives used internally by stateful SDK layers | + +When a `contentful.js` client is available, prefer SDK-managed fetching. Configure +`contentful: { client, defaultQuery?, cache? }`, then call `fetchContentfulEntry(entryId, query?)` +or `fetchOptimizedEntry(entryId, options?)`. Managed calls merge `defaultQuery`, per-call query +overrides, SDK `locale` fallback, and `include: 10`, then cache entries per SDK instance by default. +Set `contentful.cache: false` to disable the cache or call `clearContentfulEntryCache()` to clear +it. `resolveOptimizedEntry()` remains the manual path for entries the app already fetched. Stateful +Core uses the current `selectedOptimizations` when omitted, request-bound stateless clients use the +latest accepted Experience selections and request `locale` fallback for managed Contentful fetches, +and root stateless callers pass explicit `selectedOptimizations`. + +Do not pass all-locale CDA responses from `withAllLocales` or `locale=*`; optimization fields such +as `fields.nt_experiences` and `fields.nt_variants` must be direct single-locale field values. See [Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) for the entry contract and [Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) for the broader locale model. +### Custom entry-source adapters + +`@contentful/optimization-core/entry-source` is an advanced subpath for custom JavaScript runtime or +framework adapters that cannot use an official Web, React Web, React Native, Node, or native SDK +surface. It is not part of the root Core API posture and is not the preferred path for application +integrations. + +Import `OptimizedEntrySourceController` when an adapter accepts either a direct `baselineEntry` or +an `entryId` that must be fetched through an SDK-managed `contentful.js` client. The controller owns +the `baselineEntry` versus `entryId` source lifecycle, `entryId + entryQuery` fetch keying, SDK +readiness/loading/error snapshots, stale request protection, and `disconnect()` cleanup. +`createOptimizedEntryLoadingEntry(entryId)` creates a stable placeholder Contentful entry for +adapter loading states. + +The entry-source controller does not own rendering, variant resolution, tracking, consent, +Experience API calls, or Contentful client creation. After a snapshot contains `baselineEntry`, the +adapter still calls `resolveOptimizedEntry()`, renders the result, and wires any runtime-specific +tracking. + The generated reference owns method arguments, return types, callback payload shapes, and inherited members. Keep this README focused on package role and maintainer orientation. diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index 6e3849607..9c1eec0fa 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -62,6 +62,16 @@ "default": "./dist/runtime.cjs" } }, + "./entry-source": { + "import": { + "types": "./dist/entry-source.d.mts", + "default": "./dist/entry-source.mjs" + }, + "require": { + "types": "./dist/entry-source.d.cts", + "default": "./dist/entry-source.cjs" + } + }, "./api-client": { "import": { "types": "./dist/api-client.d.mts", @@ -101,12 +111,14 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 18700, - "index.mjs": 18300, + "index.cjs": 19100, + "index.mjs": 19000, "bridge-support.cjs": 1200, "bridge-support.mjs": 2100, "runtime.cjs": 4000, "runtime.mjs": 5500, + "entry-source.cjs": 1800, + "entry-source.mjs": 1400, "preview-support.cjs": 5300, "preview-support.mjs": 5500 } diff --git a/packages/universal/core-sdk/rslib.config.ts b/packages/universal/core-sdk/rslib.config.ts index bf5fca923..7d90cb1ab 100644 --- a/packages/universal/core-sdk/rslib.config.ts +++ b/packages/universal/core-sdk/rslib.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ constants: './src/constants.ts', 'bridge-support': './src/bridge-support/index.ts', runtime: './src/runtime/index.ts', + 'entry-source': './src/entry-source.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', 'preview-support': './src/preview-support/index.ts', diff --git a/packages/universal/core-sdk/src/CoreBase.test.ts b/packages/universal/core-sdk/src/CoreBase.test.ts index 40f2dbd00..ebf13a5f4 100644 --- a/packages/universal/core-sdk/src/CoreBase.test.ts +++ b/packages/universal/core-sdk/src/CoreBase.test.ts @@ -1,9 +1,16 @@ import type { ApiClientConfig } from '@contentful/optimization-api-client' import { EXPERIENCE_BASE_URL } from '@contentful/optimization-api-client' +import { createClient, type Entry, type EntryFieldTypes, type EntrySkeletonType } from 'contentful' import type { ChangeArray } from './api-schemas' import { OPTIMIZATION_CORE_SDK_NAME } from './constants' -import CoreBase, { type CoreConfig } from './CoreBase' +import CoreBase, { + type ContentfulEntryClient, + type ContentfulEntryQuery, + type CoreConfig, +} from './CoreBase' import { FlagsResolver } from './resolvers' +import { optimizedEntry } from './test/fixtures/optimizedEntry' +import { selectedOptimizations } from './test/fixtures/selectedOptimizations' class TestCore extends CoreBase { constructor( @@ -22,6 +29,13 @@ class TestCore extends CoreBase { const CLIENT_ID = 'key_123' const ENVIRONMENT = 'main' +type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise +type ProductEntrySkeleton = EntrySkeletonType< + { + title: EntryFieldTypes.Symbol + }, + 'product' +> const config: CoreConfig = { clientId: CLIENT_ID, environment: ENVIRONMENT, @@ -53,7 +67,61 @@ const CHANGES: ChangeArray = [ }, ] +function createEntry(id: string): Entry { + return { + 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', + }, + } +} + +function createContentfulClient( + implementation: MockContentfulGetEntry = async (entryId) => + await Promise.resolve(createEntry(entryId)), +): ContentfulEntryClient & { + readonly getEntry: ReturnType> +} { + const getEntry = rs.fn(implementation) + const client: ContentfulEntryClient & { + readonly getEntry: ReturnType> + } = { + getEntry, + } + + return client +} + +function createDeferred(): { + readonly promise: Promise + readonly reject: (reason?: unknown) => void + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + let rejectDeferred: (reason?: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve + rejectDeferred = reject + }) + + return { promise, reject: rejectDeferred, resolve: resolveDeferred } +} + describe('CoreBase', () => { + afterEach(() => { + rs.restoreAllMocks() + rs.useRealTimers() + }) + it('allows access to the original configuration options', () => { const core = new TestCore(config) @@ -133,4 +201,208 @@ describe('CoreBase', () => { expect(core.eventBuilder.buildPageView({}).context.locale).toBe('de-DE') }) + + it('accepts normal contentful.js clients for managed entry fetching config', () => { + const normalContentfulClient = createClient({ + accessToken: 'delivery-token', + space: 'space-id', + }) + const typedClient: ContentfulEntryClient = normalContentfulClient + + const core = new TestCore({ + ...config, + contentful: { client: typedClient, cache: false }, + }) + + expect(core.config.contentful?.client).toBe(normalContentfulClient) + }) + + it('preserves managed Contentful entry skeleton types for fetched entries', async () => { + const client = createContentfulClient() + const core = new TestCore({ + ...config, + contentful: { client, cache: false }, + }) + + const entry = await core.fetchContentfulEntry('entry-id') + const result = await core.fetchOptimizedEntry('entry-id') + const typedEntry: Entry = entry + const typedBaselineEntry: Entry = result.baselineEntry + const typedResolvedEntry: Entry = result.entry + const typedTitle: string = result.entry.fields.title + + expect(typedEntry).toBe(entry) + expect(typedBaselineEntry).toBe(result.baselineEntry) + expect(typedResolvedEntry).toBe(result.entry) + expect(typedTitle).toBe(result.entry.fields.title) + }) + + it('merges Contentful entry query defaults, SDK locale, include depth, and per-call overrides', async () => { + const client = createContentfulClient() + const core = new TestCore( + { + ...config, + contentful: { + client, + defaultQuery: { include: 2, locale: 'en-US' }, + cache: false, + }, + }, + {}, + 'de-DE', + ) + + await core.fetchContentfulEntry('entry-a', { locale: 'fr-FR' }) + await core.fetchContentfulEntry('entry-b', { include: 1 }) + + expect(client.getEntry).toHaveBeenNthCalledWith(1, 'entry-a', { + include: 2, + locale: 'fr-FR', + }) + expect(client.getEntry).toHaveBeenNthCalledWith(2, 'entry-b', { + include: 1, + locale: 'en-US', + }) + }) + + it('uses SDK locale and include 10 when Contentful entry query omits them', async () => { + const client = createContentfulClient() + const core = new TestCore( + { + ...config, + contentful: { client, cache: false }, + }, + {}, + 'de-DE', + ) + + await core.fetchContentfulEntry('entry-id') + + expect(client.getEntry).toHaveBeenCalledWith('entry-id', { + include: 10, + locale: 'de-DE', + }) + }) + + it('throws when managed Contentful fetching is used without a client', async () => { + const core = new TestCore(config) + + await expect(core.fetchContentfulEntry('entry-id')).rejects.toThrow( + 'fetchContentfulEntry() requires contentful.client in SDK config.', + ) + }) + + it('caches Contentful entries and in-flight requests by entry ID and merged query', async () => { + const deferred = createDeferred() + const client = createContentfulClient(async () => await deferred.promise) + const core = new TestCore({ + ...config, + contentful: { client }, + }) + + const first = core.fetchContentfulEntry('entry-id') + const second = core.fetchContentfulEntry('entry-id') + + expect(client.getEntry).toHaveBeenCalledTimes(1) + + const entry = createEntry('entry-id') + deferred.resolve(entry) + + await expect(first).resolves.toBe(entry) + await expect(second).resolves.toBe(entry) + await expect(core.fetchContentfulEntry('entry-id')).resolves.toBe(entry) + expect(client.getEntry).toHaveBeenCalledTimes(1) + }) + + it('expires cached Contentful entries by TTL', async () => { + rs.useFakeTimers() + rs.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const client = createContentfulClient() + const core = new TestCore({ + ...config, + contentful: { client, cache: { ttlMs: 1000 } }, + }) + + await core.fetchContentfulEntry('entry-id') + rs.setSystemTime(new Date('2026-01-01T00:00:00.999Z')) + await core.fetchContentfulEntry('entry-id') + rs.setSystemTime(new Date('2026-01-01T00:00:01.001Z')) + await core.fetchContentfulEntry('entry-id') + + expect(client.getEntry).toHaveBeenCalledTimes(2) + }) + + it('evicts least-recently-used Contentful entries when the cache is full', async () => { + const client = createContentfulClient() + const core = new TestCore({ + ...config, + contentful: { client, cache: { maxEntries: 2 } }, + }) + + await core.fetchContentfulEntry('entry-a') + await core.fetchContentfulEntry('entry-b') + await core.fetchContentfulEntry('entry-a') + await core.fetchContentfulEntry('entry-c') + await core.fetchContentfulEntry('entry-b') + + expect(client.getEntry).toHaveBeenCalledTimes(4) + }) + + it('removes failed Contentful requests from the cache', async () => { + const client = createContentfulClient() + client.getEntry.mockRejectedValueOnce(new Error('CDA failed')) + const core = new TestCore({ + ...config, + contentful: { client }, + }) + + await expect(core.fetchContentfulEntry('entry-id')).rejects.toThrow('CDA failed') + await expect(core.fetchContentfulEntry('entry-id')).resolves.toMatchObject({ + sys: { id: 'entry-id' }, + }) + + expect(client.getEntry).toHaveBeenCalledTimes(2) + }) + + it('can disable and clear the managed Contentful entry cache', async () => { + const uncachedClient = createContentfulClient() + const uncachedCore = new TestCore({ + ...config, + contentful: { client: uncachedClient, cache: false }, + }) + + await uncachedCore.fetchContentfulEntry('entry-id') + await uncachedCore.fetchContentfulEntry('entry-id') + expect(uncachedClient.getEntry).toHaveBeenCalledTimes(2) + + const cachedClient = createContentfulClient() + const cachedCore = new TestCore({ + ...config, + contentful: { client: cachedClient }, + }) + + await cachedCore.fetchContentfulEntry('entry-id') + cachedCore.clearContentfulEntryCache() + await cachedCore.fetchContentfulEntry('entry-id') + expect(cachedClient.getEntry).toHaveBeenCalledTimes(2) + }) + + it('fetches and resolves optimized Contentful entries with metadata', async () => { + const client = createContentfulClient(async () => await Promise.resolve(optimizedEntry)) + const core = new TestCore({ + ...config, + contentful: { client, cache: false }, + }) + + const result = await core.fetchOptimizedEntry('entry-id', { selectedOptimizations }) + + expect(result.baselineEntry).toBe(optimizedEntry) + expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb') + expect(result.selectedOptimization).toEqual( + expect.objectContaining({ + experienceId: '2qVK4T5lnScbswoyBuGipd', + variantIndex: 1, + }), + ) + }) }) diff --git a/packages/universal/core-sdk/src/CoreBase.ts b/packages/universal/core-sdk/src/CoreBase.ts index 650f9541c..f71a77885 100644 --- a/packages/universal/core-sdk/src/CoreBase.ts +++ b/packages/universal/core-sdk/src/CoreBase.ts @@ -15,13 +15,126 @@ import type { } from '@contentful/optimization-api-client/api-schemas' import type { LogLevels } from '@contentful/optimization-api-client/logger' import { ConsoleLogSink, logger } from '@contentful/optimization-api-client/logger' -import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' +import type { ChainModifiers, Entry, EntryQueries, EntrySkeletonType, LocaleCode } from 'contentful' import { OPTIMIZATION_CORE_SDK_NAME, OPTIMIZATION_CORE_SDK_VERSION } from './constants' import { EventBuilder, type EventBuilderConfig } from './events' import { InterceptorManager } from './lib/interceptor' import type { ResolvedData } from './resolvers' import { FlagsResolver, MergeTagValueResolver, OptimizedEntryResolver } from './resolvers' +const DEFAULT_CONTENTFUL_ENTRY_CACHE_MAX_ENTRIES = 100 +const DEFAULT_CONTENTFUL_ENTRY_CACHE_TTL_MS = 300_000 +const DEFAULT_CONTENTFUL_ENTRY_INCLUDE = 10 + +/** + * Query shape used for SDK-managed `contentful.js` `getEntry()` calls. + * + * @public + */ +export type ContentfulEntryQuery = EntryQueries + +type ManagedContentfulEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, +> = Entry + +/** + * Minimal `contentful.js` client surface required for SDK-managed entry fetching. + * + * @public + */ +export interface ContentfulEntryClient { + /** Fetch a single Contentful entry by ID. */ + getEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise +} + +/** + * Cache options for SDK-managed Contentful entry fetching. + * + * @public + */ +export interface ContentfulEntryCacheOptions { + /** Maximum number of entries retained per SDK instance. */ + maxEntries?: number + /** Time, in milliseconds, before a cached entry is refetched. */ + ttlMs?: number +} + +/** + * SDK-managed Contentful entry fetching configuration. + * + * @public + */ +export interface ContentfulConfig { + /** `contentful.js` client used for SDK-managed `getEntry()` calls. */ + client: ContentfulEntryClient + /** Query merged into every SDK-managed `getEntry()` call. */ + defaultQuery?: ContentfulEntryQuery + /** + * Per-SDK-instance entry cache configuration. + * + * @remarks + * Defaults to `{ maxEntries: 100, ttlMs: 300_000 }`. Set to `false` to disable caching. + */ + cache?: false | ContentfulEntryCacheOptions +} + +/** + * Options for {@link CoreBase.fetchOptimizedEntry}. + * + * @public + */ +export interface FetchOptimizedEntryOptions { + /** Per-call Contentful `getEntry()` query overrides. */ + query?: ContentfulEntryQuery + /** Selected optimizations used for personalized entry resolution. */ + selectedOptimizations?: SelectedOptimizationArray +} + +/** + * Result returned by {@link CoreBase.fetchOptimizedEntry}. + * + * @public + */ +export interface FetchOptimizedEntryResult< + S extends EntrySkeletonType = EntrySkeletonType, + M extends ChainModifiers = undefined, + L extends LocaleCode = LocaleCode, +> extends ResolvedData { + /** Baseline entry fetched from Contentful before optimization resolution. */ + baselineEntry: Entry +} + +type ResolvedContentfulEntryCacheOptions = readonly [maxEntries: number, ttlMs: number] +type ContentfulEntryCacheRecord = readonly [expiresAt: number, promise: Promise] + +function createContentfulEntryQuery( + defaultQuery: ContentfulEntryQuery | undefined, + query: ContentfulEntryQuery | undefined, + locale: string | undefined, +): ContentfulEntryQuery { + const mergedQuery: ContentfulEntryQuery = { + ...defaultQuery, + ...query, + } + + mergedQuery.include ??= DEFAULT_CONTENTFUL_ENTRY_INCLUDE + + if (mergedQuery.locale === undefined && locale !== undefined) { + mergedQuery.locale = locale + } + + return mergedQuery +} + +function evictEntryCache(cache: Map, maxEntries: number): void { + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next() + if (oldestKey.done) break + cache.delete(oldestKey.value) + } +} + /** * Lifecycle container for event and state interceptors. * @@ -50,6 +163,15 @@ export interface CoreConfig extends Pick { } private resolvedLocale: string | undefined + private readonly entryCache = new Map() + private readonly entryCacheOptions: ResolvedContentfulEntryCacheOptions | undefined /** Current SDK locale for Experience API requests and event context. */ get locale(): string | undefined { @@ -103,6 +227,14 @@ abstract class CoreBase { constructor(config: TConfig, api: CoreBaseApiClientConfig = {}, locale?: string) { this.config = config this.resolvedLocale = locale + const cache = config.contentful?.cache + this.entryCacheOptions = + cache === false + ? undefined + : [ + cache?.maxEntries ?? DEFAULT_CONTENTFUL_ENTRY_CACHE_MAX_ENTRIES, + cache?.ttlMs ?? DEFAULT_CONTENTFUL_ENTRY_CACHE_TTL_MS, + ] const { eventBuilder, logLevel, environment, clientId, fetchOptions } = config @@ -133,6 +265,110 @@ abstract class CoreBase { this.resolvedLocale = locale } + /** + * Clear SDK-managed Contentful entry cache entries for this SDK instance. + * + * @public + */ + clearContentfulEntryCache(): void { + this.entryCache.clear() + } + + /** + * Fetch a Contentful entry through the configured `contentful.js` client. + * + * @param entryId - Contentful entry ID to fetch. + * @param query - Per-call `getEntry()` query overrides. + * @returns The Contentful entry returned by the configured client. + * + * @remarks + * The SDK merges `contentful.defaultQuery`, the per-call query, SDK locale fallback, and + * `include: 10` before calling `getEntry()`. By default, results are cached per SDK instance. + */ + async fetchContentfulEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >(entryId: string, query?: ContentfulEntryQuery): Promise> + async fetchContentfulEntry(entryId: string, query?: ContentfulEntryQuery): Promise { + const { client } = this.config.contentful ?? {} + + if (!client) { + throw new Error('fetchContentfulEntry() requires contentful.client in SDK config.') + } + + const { locale } = this + const mergedQuery = createContentfulEntryQuery( + this.config.contentful?.defaultQuery, + query, + locale, + ) + const { entryCacheOptions: cacheOptions } = this + + if (cacheOptions === undefined) { + return await client.getEntry(entryId, mergedQuery) + } + + const now = Date.now() + const cacheKey = `${entryId}:${JSON.stringify(mergedQuery)}` + const cached = this.entryCache.get(cacheKey) + + if (cached && cached[0] > now) { + this.entryCache.delete(cacheKey) + this.entryCache.set(cacheKey, cached) + return await cached[1] + } + + if (cached) { + this.entryCache.delete(cacheKey) + } + + const promise = client.getEntry(entryId, mergedQuery) + const cacheRecord: ContentfulEntryCacheRecord = [now + cacheOptions[1], promise] + + this.entryCache.set(cacheKey, cacheRecord) + evictEntryCache(this.entryCache, cacheOptions[0]) + + try { + return await promise + } catch (error) { + if (this.entryCache.get(cacheKey) === cacheRecord) { + this.entryCache.delete(cacheKey) + } + + throw error + } + } + + /** + * Fetch a Contentful entry and resolve its optimized variant. + * + * @param entryId - Contentful entry ID to fetch. + * @param options - Per-call Contentful query and selected optimizations. + * @returns Baseline entry, resolved entry, and optimization metadata. + * + * @remarks + * This is additive to the synchronous `resolveOptimizedEntry()` API. + */ + async fetchOptimizedEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entryId: string, + options?: FetchOptimizedEntryOptions, + ): Promise> + async fetchOptimizedEntry( + entryId: string, + options: FetchOptimizedEntryOptions = {}, + ): Promise> { + const baselineEntry = await this.fetchContentfulEntry(entryId, options.query) + const resolvedData = this.resolveOptimizedEntry(baselineEntry, options.selectedOptimizations) + + return { + baselineEntry, + ...resolvedData, + } + } + /** * Get the value of a custom flag derived from a set of optimization changes. * diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index a95ba8491..c2fb0113a 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -1,6 +1,8 @@ -import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' +import type { Entry } from 'contentful' import type { ChangeArray } from './api-schemas' import { getPreviewPanelBridge, hydrateOptimizationData } from './bridge-support' +import type { ContentfulEntryClient, ContentfulEntryQuery } from './CoreBase' +import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' import type { BlockedEvent, EventOptimizationContext, @@ -21,6 +23,8 @@ const config: CoreStatefulConfig = { environment: 'main', } +type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise + const DARK_MODE_CHANGE: ChangeArray[number] = { key: 'dark-mode', type: 'Variable', @@ -558,6 +562,29 @@ describe('CoreStateful blocked event handling', () => { ) }) + it('defaults fetchOptimizedEntry to the selectedOptimizations signal', async () => { + const getEntry = rs.fn( + async () => await Promise.resolve(optimizedEntry), + ) + const client: ContentfulEntryClient & { + readonly getEntry: ReturnType> + } = { + getEntry, + } + const core = createCoreStateful({ + contentful: { client, cache: false }, + }) + + signals.selectedOptimizations.value = selectedOptimizationsFixture + + const result = await core.fetchOptimizedEntry('entry-id') + + expect(getEntry).toHaveBeenCalledWith('entry-id', { include: 10 }) + expect(result.baselineEntry).toBe(optimizedEntry) + expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb') + expect(result.optimizationContextId).toEqual(expect.any(String)) + }) + it('registers unique optimization contexts and clears them on reset and destroy', () => { const core = createCoreStatefulHarness() diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index d39edf178..70584d4ec 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -1,6 +1,10 @@ +import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import type { OptimizationData } from './api-schemas' +import type { ContentfulEntryClient, ContentfulEntryQuery } from './CoreBase' import CoreStateless from './CoreStateless' import type { BlockedEvent } from './events' +import { optimizedEntry } from './test/fixtures/optimizedEntry' +import { selectedOptimizations } from './test/fixtures/selectedOptimizations' const TRACK_CLICK_PROFILE_ERROR = 'CoreStatelessRequest.trackClick() requires a request-bound profile id for Insights delivery.' @@ -11,6 +15,14 @@ const TRACK_FLAG_VIEW_PROFILE_ERROR = const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = 'CoreStatelessRequest.trackView() requires a request-bound profile id when `payload.sticky` is not `true`.' +type ProductEntrySkeleton = EntrySkeletonType< + { + title: EntryFieldTypes.Symbol + }, + 'product' +> +type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise + const EMPTY_OPTIMIZATION_DATA: OptimizationData = { changes: [], selectedOptimizations: [], @@ -39,6 +51,19 @@ const EMPTY_OPTIMIZATION_DATA: OptimizationData = { }, } +function createOptimizedEntryClient(): ContentfulEntryClient & { + readonly getEntry: ReturnType> +} { + const getEntry = rs.fn(async () => await Promise.resolve(optimizedEntry)) + const client: ContentfulEntryClient & { + readonly getEntry: ReturnType> + } = { + getEntry, + } + + return client +} + async function invokeUntypedMethod( requestOptimization: ReturnType, method: 'trackClick' | 'trackHover' | 'trackFlagView' | 'trackView', @@ -240,6 +265,111 @@ describe('CoreStateless', () => { ) }) + it('uses latest request-bound Experience selections for managed optimized entry fetching', async () => { + const client = createOptimizedEntryClient() + const core = new CoreStateless({ + clientId: 'key_123', + environment: 'main', + contentful: { client, cache: false }, + }) + rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + ...EMPTY_OPTIMIZATION_DATA, + selectedOptimizations, + }) + const requestOptimization = core.forRequest({ + consent: true, + profile: { id: 'profile-123' }, + }) + + await requestOptimization.page() + const result = await requestOptimization.fetchOptimizedEntry('entry-id') + + expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb') + expect(result.selectedOptimization).toEqual( + expect.objectContaining({ + experienceId: '2qVK4T5lnScbswoyBuGipd', + variantIndex: 1, + }), + ) + }) + + it('preserves managed Contentful entry skeleton types for request-bound fetches', async () => { + const client = createOptimizedEntryClient() + const core = new CoreStateless({ + clientId: 'key_123', + environment: 'main', + contentful: { client, cache: false }, + }) + const requestOptimization = core.forRequest({ consent: true }) + + const entry = await requestOptimization.fetchContentfulEntry('entry-id') + const result = await requestOptimization.fetchOptimizedEntry('entry-id') + const typedEntry: Entry = entry + const typedBaselineEntry: Entry = result.baselineEntry + const typedResolvedEntry: Entry = result.entry + const typedTitle: string = result.entry.fields.title + + expect(typedEntry).toBe(entry) + expect(typedBaselineEntry).toBe(result.baselineEntry) + expect(typedResolvedEntry).toBe(result.entry) + expect(typedTitle).toBe(result.entry.fields.title) + }) + + it('uses request locale as the managed Contentful query fallback', async () => { + const client = createOptimizedEntryClient() + const core = new CoreStateless({ + clientId: 'key_123', + environment: 'main', + locale: 'en-US', + contentful: { + client, + defaultQuery: { include: 2 }, + cache: false, + }, + }) + const requestOptimization = core.forRequest({ + consent: true, + locale: 'de-DE', + }) + + await requestOptimization.fetchContentfulEntry('entry-id') + await requestOptimization.fetchOptimizedEntry('entry-id', { + query: { locale: 'fr-FR' }, + selectedOptimizations: [], + }) + + expect(client.getEntry).toHaveBeenNthCalledWith(1, 'entry-id', { + include: 2, + locale: 'de-DE', + }) + expect(client.getEntry).toHaveBeenNthCalledWith(2, 'entry-id', { + include: 2, + locale: 'fr-FR', + }) + + const defaultLocaleClient = createOptimizedEntryClient() + const defaultLocaleCore = new CoreStateless({ + clientId: 'key_123', + environment: 'main', + contentful: { + client: defaultLocaleClient, + defaultQuery: { locale: 'it-IT' }, + cache: false, + }, + }) + const defaultLocaleRequest = defaultLocaleCore.forRequest({ + consent: true, + locale: 'de-DE', + }) + + await defaultLocaleRequest.fetchContentfulEntry('entry-id') + + expect(defaultLocaleClient.getEntry).toHaveBeenCalledWith('entry-id', { + include: 10, + locale: 'it-IT', + }) + }) + it('defaults allowedEventTypes to empty for stateless core requests', async () => { const blockedEvents: BlockedEvent[] = [] const core = new CoreStateless({ diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts index f9a214c6c..0968fbaa2 100644 --- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts @@ -7,6 +7,12 @@ import { type InsightsEvent as InsightsEventPayload, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' +import type { Entry, EntrySkeletonType, LocaleCode } from 'contentful' +import type { + ContentfulEntryQuery, + FetchOptimizedEntryOptions, + FetchOptimizedEntryResult, +} from './CoreBase' import type CoreStateless from './CoreStateless' import type { CoreStatelessInsightsOptions, CoreStatelessRequestOptions } from './CoreStateless' import { PartialProfile, type OptimizationData } from './api-schemas' @@ -60,7 +66,10 @@ export type CoreStatelessRequestConsent = export interface CoreStatelessForRequestOptions { /** Request-scoped event and persistence consent. */ consent: CoreStatelessRequestConsent - /** Request-scoped SDK locale used for Experience API requests and default event context. */ + /** + * Request-scoped SDK locale used for Experience API requests, default event context, and + * SDK-managed Contentful entry fetching when no Contentful query locale is configured. + */ locale?: string /** Profile already known for the request, such as an anonymous ID from a cookie. */ profile?: PartialProfile @@ -141,10 +150,12 @@ type RequestExperienceMethod = 'identify' | 'page' | 'screen' | 'track' export class CoreStatelessRequest { private readonly core: CoreStateless private currentProfile: PartialProfile | undefined + private currentSelectedOptimizations: OptimizationData['selectedOptimizations'] | undefined private readonly requestEventConsent: boolean | undefined private readonly eventContext: UniversalEventBuilderArgs private readonly experienceOptions: CoreStatelessRequestOptions | undefined private readonly insightsOptions: CoreStatelessInsightsOptions | undefined + private readonly requestLocale: string | undefined readonly canPersistProfile: boolean constructor(core: CoreStateless, options: CoreStatelessForRequestOptions) { @@ -154,6 +165,7 @@ export class CoreStatelessRequest { this.core = core this.currentProfile = profile + this.requestLocale = requestLocale this.requestEventConsent = isBooleanConsent ? consent : consent.events this.canPersistProfile = (isBooleanConsent ? consent : consent.persistence) === true this.eventContext = @@ -280,6 +292,64 @@ export class CoreStatelessRequest { ) } + /** + * Fetch a Contentful entry through the parent SDK's configured `contentful.js` client. + * + * @remarks + * If `contentful.defaultQuery` and the per-call query omit `locale`, this request's `locale` + * becomes the managed Contentful query locale. + * + * @public + */ + async fetchContentfulEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >(entryId: string, query?: ContentfulEntryQuery): Promise> { + return await this.core.fetchContentfulEntry( + entryId, + this.withRequestContentfulLocale(query), + ) + } + + /** + * Fetch a Contentful entry and resolve it with request-local selected optimizations. + * + * @remarks + * If `options.selectedOptimizations` is omitted, this uses the latest selected optimizations + * returned by an accepted request-bound Experience API call. If `contentful.defaultQuery` and the + * per-call query omit `locale`, this request's `locale` becomes the managed Contentful query + * locale. + * + * @public + */ + async fetchOptimizedEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entryId: string, + options: FetchOptimizedEntryOptions = {}, + ): Promise> { + return await this.core.fetchOptimizedEntry(entryId, { + ...options, + query: this.withRequestContentfulLocale(options.query), + selectedOptimizations: options.selectedOptimizations ?? this.currentSelectedOptimizations, + }) + } + + private withRequestContentfulLocale( + query: ContentfulEntryQuery | undefined, + ): ContentfulEntryQuery | undefined { + if ( + this.requestLocale === undefined || + query?.locale !== undefined || + this.core.config.contentful?.defaultQuery?.locale !== undefined + ) { + return query + } + + return { ...query, locale: this.requestLocale } + } + private withEventContext( payload: TPayload, ): TPayload { @@ -326,8 +396,9 @@ export class CoreStatelessRequest { this.experienceOptions, ) - const { profile } = result + const { profile, selectedOptimizations } = result this.currentProfile = profile + this.currentSelectedOptimizations = selectedOptimizations return result } diff --git a/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts b/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts new file mode 100644 index 000000000..1fbeb1f8d --- /dev/null +++ b/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts @@ -0,0 +1,31 @@ +import type { SelectedOptimizationArray } from '@contentful/optimization-api-client/api-schemas' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' +import type { ResolvedData } from './resolvers' + +/** + * Baseline and resolved-entry metadata for optimized-entry render surfaces. + * + * @public + */ +export interface OptimizedEntryMetadata< + S extends EntrySkeletonType = EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, +> { + /** Entry supplied by the caller or fetched before optimization resolution. */ + readonly baselineEntry: Entry + /** ID of the baseline entry supplied by the caller or fetched before optimization resolution. */ + readonly baselineEntryId: string + /** Baseline or resolved variant entry. */ + readonly entry: Entry + /** ID of the baseline or resolved variant entry. */ + readonly entryId: string + /** Opaque runtime-owned optimization context ID for entry interaction tracking. */ + readonly optimizationContextId: string | undefined + /** Full resolved entry payload returned by the optimization resolver. */ + readonly resolvedData: ResolvedData + /** Selected optimization metadata, if a matching optimization was selected. */ + readonly selectedOptimization: ResolvedData['selectedOptimization'] + /** Selected optimizations used to resolve this entry, when available. */ + readonly selectedOptimizations: SelectedOptimizationArray | undefined +} diff --git a/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts b/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts new file mode 100644 index 000000000..60f8ddd5f --- /dev/null +++ b/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts @@ -0,0 +1,204 @@ +import type { Entry } from 'contentful' +import type { ContentfulEntryQuery } from './CoreBase' +import { + OptimizedEntrySourceController, + prefetchOptimizedEntries, +} from './OptimizedEntrySourceController' + +function createTestEntry(id: string): Entry { + return { + 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', + }, + } +} + +function createDeferred(): { + readonly promise: Promise + readonly reject: (reason?: unknown) => void + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + let rejectDeferred: (reason?: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve + rejectDeferred = reject + }) + + return { promise, reject: rejectDeferred, resolve: resolveDeferred } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function createSdk( + fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise, +): { + readonly fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise +} { + return { fetchContentfulEntry: rs.fn(fetchContentfulEntry) } +} + +describe('OptimizedEntrySourceController', () => { + it('lets baselineEntry take precedence over entryId without fetching', () => { + const baselineEntry = createTestEntry('baseline') + const sdk = createSdk(async () => await Promise.resolve(createTestEntry('managed'))) + const controller = new OptimizedEntrySourceController() + + controller.updateOptions({ + baselineEntry, + entryId: 'managed', + sdk, + isSdkStateReady: true, + }) + + expect(controller.getSnapshot()).toEqual({ + baselineEntry, + isLoading: false, + }) + expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled() + }) + + it('fetches managed entryId entries with query options', async () => { + const baselineEntry = createTestEntry('baseline') + const sdk = createSdk(async () => await Promise.resolve(baselineEntry)) + const controller = new OptimizedEntrySourceController() + + controller.updateOptions({ + entryId: 'baseline', + entryQuery: { locale: 'de-DE' }, + sdk, + isSdkStateReady: true, + }) + + expect(controller.getSnapshot()).toEqual({ + entryId: 'baseline', + isLoading: true, + }) + await flushMicrotasks() + + expect(sdk.fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' }) + expect(controller.getSnapshot()).toEqual({ + baselineEntry, + entryId: 'baseline', + isLoading: false, + }) + }) + + it('stays loading without fetching until the SDK is ready', () => { + const sdk = createSdk(async () => await Promise.resolve(createTestEntry('baseline'))) + const controller = new OptimizedEntrySourceController() + + controller.updateOptions({ entryId: 'baseline' }) + + expect(controller.getSnapshot()).toEqual({ + entryId: 'baseline', + isLoading: true, + }) + expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled() + + controller.updateOptions({ entryId: 'baseline', sdk, isSdkStateReady: false }) + + expect(controller.getSnapshot()).toEqual({ + entryId: 'baseline', + isLoading: true, + }) + expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled() + }) + + it('ignores stale fetches after source changes or disconnects', async () => { + const firstEntry = createTestEntry('first') + const secondEntry = createTestEntry('second') + const thirdEntry = createTestEntry('third') + const firstFetch = createDeferred() + const secondFetch = createDeferred() + const thirdFetch = createDeferred() + const sdk = createSdk(async (entryId) => { + if (entryId === 'first') return await firstFetch.promise + if (entryId === 'second') return await secondFetch.promise + return await thirdFetch.promise + }) + const controller = new OptimizedEntrySourceController() + + controller.updateOptions({ entryId: 'first', sdk, isSdkStateReady: true }) + controller.updateOptions({ entryId: 'second', sdk, isSdkStateReady: true }) + + secondFetch.resolve(secondEntry) + await flushMicrotasks() + expect(controller.getSnapshot().baselineEntry).toBe(secondEntry) + + firstFetch.resolve(firstEntry) + await flushMicrotasks() + expect(controller.getSnapshot().baselineEntry).toBe(secondEntry) + + controller.updateOptions({ entryId: 'third', sdk, isSdkStateReady: true }) + controller.disconnect() + thirdFetch.resolve(thirdEntry) + await flushMicrotasks() + + expect(controller.getSnapshot()).toEqual({ + entryId: 'third', + isLoading: true, + }) + }) + + it('surfaces failed fetches as Error snapshots', async () => { + const sdk = createSdk(async () => await Promise.reject(new Error('CDA failed'))) + const controller = new OptimizedEntrySourceController() + + controller.updateOptions({ entryId: 'baseline', sdk, isSdkStateReady: true }) + await flushMicrotasks() + + expect(controller.getSnapshot().error).toBeInstanceOf(Error) + expect(controller.getSnapshot().error?.message).toBe('CDA failed') + expect(controller.getSnapshot()).toMatchObject({ + entryId: 'baseline', + isLoading: false, + }) + }) +}) + +describe('prefetchOptimizedEntries', () => { + it('dedupes descriptor fetches by managed entry source key', async () => { + const fetchContentfulEntry = rs.fn( + async (entryId: string) => await Promise.resolve(createTestEntry(entryId)), + ) + + const entries = await prefetchOptimizedEntries({ fetchContentfulEntry }, [ + { entryId: 'baseline', entryQuery: { locale: 'de-DE' } }, + { entryId: 'baseline', entryQuery: { locale: 'de-DE' } }, + { entryId: 'baseline', entryQuery: { locale: 'fr-FR' } }, + ]) + + expect(fetchContentfulEntry).toHaveBeenCalledTimes(2) + expect(entries).toEqual([ + { + baselineEntry: createTestEntry('baseline'), + entryId: 'baseline', + entryQuery: { locale: 'de-DE' }, + }, + { + baselineEntry: createTestEntry('baseline'), + entryId: 'baseline', + entryQuery: { locale: 'de-DE' }, + }, + { + baselineEntry: createTestEntry('baseline'), + entryId: 'baseline', + entryQuery: { locale: 'fr-FR' }, + }, + ]) + }) +}) diff --git a/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts b/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts new file mode 100644 index 000000000..7c70c4a06 --- /dev/null +++ b/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts @@ -0,0 +1,227 @@ +import type { Entry } from 'contentful' +import type { ContentfulEntryQuery } from './CoreBase' + +export interface OptimizedEntrySourceControllerOptions { + /** Baseline entry supplied by the application. Takes precedence over `entryId`. */ + readonly baselineEntry?: Entry + /** Contentful entry ID fetched through the SDK-managed Contentful client. */ + readonly entryId?: string + /** Per-entry Contentful `getEntry()` query overrides. */ + readonly entryQuery?: ContentfulEntryQuery + /** SDK surface required for managed entry fetching. */ + readonly sdk?: { + fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise + } + /** Whether SDK state is ready for managed entry fetching. */ + readonly isSdkStateReady?: boolean +} + +export interface OptimizedEntrySourceSnapshot { + /** Current baseline entry, either supplied directly or fetched by `entryId`. */ + readonly baselineEntry?: Entry + /** Managed entry ID currently being fetched or resolved. */ + readonly entryId?: string + /** Managed fetch error, normalized to `Error`. */ + readonly error?: Error + /** Whether a managed entry source is waiting for SDK readiness or fetch resolution. */ + readonly isLoading: boolean +} + +export type OptimizedEntrySourceSnapshotListener = (snapshot: OptimizedEntrySourceSnapshot) => void + +export interface OptimizedEntryPrefetchDescriptor { + readonly entryId: string + readonly entryQuery?: ContentfulEntryQuery +} + +export interface ServerOptimizedEntryHandoff extends OptimizedEntryPrefetchDescriptor { + readonly baselineEntry: Entry +} + +export interface OptimizedEntryPrefetchRuntime { + fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise +} + +type OptimizedEntrySourceSdk = NonNullable + +const LOADING_ENTRY_CONTENT_TYPE_ID = 'contentful-loading-entry' +const LOADING_ENTRY_TIMESTAMP = '2024-01-01T00:00:00Z' +const REQUEST_IDLE = 0 +const REQUEST_LOADING = 1 +const REQUEST_SUCCESS = 2 +const REQUEST_ERROR = REQUEST_SUCCESS + REQUEST_LOADING + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export function getOptimizedEntrySourceKey( + entryId: string, + query: ContentfulEntryQuery | undefined, +): string { + return `${entryId}:${JSON.stringify(query ?? {})}` +} + +function areSourceSnapshotsEqual( + left: OptimizedEntrySourceSnapshot, + right: OptimizedEntrySourceSnapshot, +): boolean { + return ( + left.baselineEntry === right.baselineEntry && + left.entryId === right.entryId && + left.error === right.error && + left.isLoading === right.isLoading + ) +} + +/** Creates a stable placeholder entry while framework wrappers wait for a managed fetch. */ +export function createOptimizedEntryLoadingEntry(entryId: string): Entry { + return { + fields: {}, + metadata: { tags: [] }, + sys: { + contentType: { + sys: { id: LOADING_ENTRY_CONTENT_TYPE_ID, linkType: 'ContentType', type: 'Link' }, + }, + createdAt: LOADING_ENTRY_TIMESTAMP, + environment: { sys: { id: '', linkType: 'Environment', type: 'Link' } }, + id: entryId, + publishedVersion: 0, + revision: 0, + space: { sys: { id: '', linkType: 'Space', type: 'Link' } }, + type: 'Entry', + updatedAt: LOADING_ENTRY_TIMESTAMP, + }, + } +} + +export async function prefetchOptimizedEntries( + runtime: OptimizedEntryPrefetchRuntime, + descriptors: readonly OptimizedEntryPrefetchDescriptor[], +): Promise { + const entriesByKey = new Map>() + + return await Promise.all( + descriptors.map(async ({ entryId, entryQuery }) => { + const key = getOptimizedEntrySourceKey(entryId, entryQuery) + const baselineEntry = + entriesByKey.get(key) ?? runtime.fetchContentfulEntry(entryId, entryQuery) + entriesByKey.set(key, baselineEntry) + + return { + ...(entryQuery === undefined ? {} : { entryQuery }), + baselineEntry: await baselineEntry, + entryId, + } + }), + ) +} + +/** Coordinates direct baseline entries and SDK-managed `entryId` fetching for framework wrappers. */ +export class OptimizedEntrySourceController { + private key: string | undefined + private sdk: OptimizedEntrySourceSdk | undefined + private version = 0 + private listener: OptimizedEntrySourceSnapshotListener | undefined + private status = REQUEST_IDLE + private snapshot: OptimizedEntrySourceSnapshot = { isLoading: false } + + updateOptions(options: OptimizedEntrySourceControllerOptions): void { + if (options.baselineEntry !== undefined) { + this.resetSource({ baselineEntry: options.baselineEntry, isLoading: false }) + return + } + + const { entryId } = options + if (entryId === undefined) { + this.resetSource({ isLoading: false }) + return + } + + const key = getOptimizedEntrySourceKey(entryId, options.entryQuery) + const { sdk } = options + const sourceChanged = this.key !== key || this.sdk !== sdk + + if (sourceChanged) { + this.version += 1 + this.key = key + this.sdk = sdk + this.status = REQUEST_IDLE + } + + if (sdk === undefined || options.isSdkStateReady !== true) { + if (this.status !== REQUEST_IDLE) { + this.version += 1 + this.status = REQUEST_IDLE + } + this.setSnapshot({ entryId, isLoading: true }) + return + } + + if (this.status !== REQUEST_IDLE) { + return + } + + const version = ++this.version + this.status = REQUEST_LOADING + this.setSnapshot({ entryId, isLoading: true }, true) + + void sdk.fetchContentfulEntry(entryId, options.entryQuery).then( + (entry) => { + if (!this.isCurrent(version, key, sdk)) return + + this.status = REQUEST_SUCCESS + this.setSnapshot({ baselineEntry: entry, entryId, isLoading: false }) + }, + (error: unknown) => { + if (!this.isCurrent(version, key, sdk)) return + + this.status = REQUEST_ERROR + this.setSnapshot({ entryId, error: toError(error), isLoading: false }) + }, + ) + } + + private resetSource(snapshot: OptimizedEntrySourceSnapshot): void { + if (this.key !== undefined || this.status !== REQUEST_IDLE) { + this.version += 1 + } + this.key = undefined + this.sdk = undefined + this.status = REQUEST_IDLE + this.setSnapshot(snapshot) + } + + private isCurrent(version: number, key: string, sdk: OptimizedEntrySourceSdk): boolean { + return ( + this.version === version && + this.key === key && + this.sdk === sdk && + this.status === REQUEST_LOADING + ) + } + + getSnapshot(): OptimizedEntrySourceSnapshot { + return this.snapshot + } + + setSnapshotListener(listener: OptimizedEntrySourceSnapshotListener | undefined): void { + this.listener = listener + } + + disconnect(): void { + this.version += 1 + if (this.status === REQUEST_LOADING) { + this.status = REQUEST_IDLE + } + } + + private setSnapshot(snapshot: OptimizedEntrySourceSnapshot, forceNotify = false): void { + if (!forceNotify && areSourceSnapshotsEqual(this.snapshot, snapshot)) { + return + } + + this.snapshot = snapshot + this.listener?.(snapshot) + } +} diff --git a/packages/universal/core-sdk/src/entry-source.ts b/packages/universal/core-sdk/src/entry-source.ts new file mode 100644 index 000000000..4449a1c79 --- /dev/null +++ b/packages/universal/core-sdk/src/entry-source.ts @@ -0,0 +1,13 @@ +export type { ContentfulEntryQuery } from './CoreBase' +export { + OptimizedEntrySourceController, + createOptimizedEntryLoadingEntry, + getOptimizedEntrySourceKey, + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type OptimizedEntryPrefetchRuntime, + type OptimizedEntrySourceControllerOptions, + type OptimizedEntrySourceSnapshot, + type OptimizedEntrySourceSnapshotListener, + type ServerOptimizedEntryHandoff, +} from './OptimizedEntrySourceController' diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index bc4f7120f..ea9f4571e 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -36,6 +36,7 @@ export type { QueueFlushRecoveredContext, } from './lib/queue' export * from './locale' +export type * from './OptimizedEntryMetadata' export * from './page-context' export type { ExperienceQueue } from './queues/ExperienceQueue' export type { InsightsQueue } from './queues/InsightsQueue' diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 0e532de49..6eda088cf 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -40,11 +40,12 @@ SDK on the server with the React Web SDK on the client; it is not a new optimiza ## Install ```sh -pnpm add @contentful/optimization-nextjs +pnpm add @contentful/optimization-nextjs contentful ``` Next.js, React, and React DOM are application-owned peer dependencies. The adapter uses the runtime -already installed by your app instead of installing its own copy. +already installed by your app instead of installing its own copy. The `contentful` package is the +app-owned CDA client used by the managed entry fetching example. ## App Router setup @@ -210,35 +211,45 @@ import { OptimizationRoot } from '@contentful/optimization-nextjs/client' import { createNextjsOptimization, getNextjsServerOptimizationData, + ServerOptimizedEntry, } from '@contentful/optimization-nextjs/server' -import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' +import { createClient } from 'contentful' import { cookies, headers } from 'next/headers' +const contentfulClient = createClient({ + accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!, + space: process.env.CONTENTFUL_SPACE_ID!, +}) + const sdk = createNextjsOptimization({ clientId: 'client-id', + contentful: { client: contentfulClient }, environment: 'main', + locale: 'en-US', }) export default async function Page() { const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - const { data } = await getNextjsServerOptimizationData(sdk, { + const { data, requestOptimization } = await getNextjsServerOptimizationData(sdk, { consent: { events: true, persistence: true }, cookies: cookieStore, headers: headerStore, locale: 'en-US', }) - const resolvedData = sdk.resolveOptimizedEntry(entry, data?.selectedOptimizations) - const trackingAttributes = getServerTrackingAttributes(entry, resolvedData) + const result = await requestOptimization.fetchOptimizedEntry('hero-entry') return ( -
{resolvedData.entry.fields.title}
+ {result.entry.fields.title}
) } ``` +`ServerOptimizedEntry` also keeps the manual `baselineEntry` plus `resolvedData` props for apps that +fetch Contentful entries outside the SDK. + ## Manual client setup Use `/client` imports when a client-only provider must be wired manually. App Router layouts that diff --git a/packages/web/frameworks/nextjs-sdk/package.json b/packages/web/frameworks/nextjs-sdk/package.json index 95f008a79..2626f7161 100644 --- a/packages/web/frameworks/nextjs-sdk/package.json +++ b/packages/web/frameworks/nextjs-sdk/package.json @@ -127,8 +127,8 @@ "gzipBudgets": { "app-router.cjs": 1200, "app-router.mjs": 1200, - "app-router-server.cjs": 3600, - "app-router-server.mjs": 3900, + "app-router-server.cjs": 3800, + "app-router-server.mjs": 4200, "client.cjs": 1200, "client.mjs": 1200, "esr.cjs": 4200, @@ -136,11 +136,11 @@ "pages-router.cjs": 1200, "pages-router.mjs": 1200, "pages-router/server.cjs": 3600, - "pages-router/server.mjs": 3600, + "pages-router/server.mjs": 4000, "server.cjs": 4500, "server.mjs": 4500, "request-handler.cjs": 2500, - "request-handler.mjs": 2500, + "request-handler.mjs": 2800, "tracking-attributes.cjs": 1200, "tracking-attributes.mjs": 1200 } diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index 0d0d48cdd..434637f6a 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -1,3 +1,4 @@ +import type { Entry } from 'contentful' import * as appRouter from './app-router-client' import * as client from './client' @@ -10,16 +11,50 @@ const testConfig = { }, } +function createEntry(id: string): Entry { + return { + fields: {}, + metadata: { tags: [] }, + sys: { + contentType: { sys: { id: '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', + }, + } +} + describe('Next.js App Router client components', () => { it('creates bound client components from config props without server-only config', () => { + const contentful = { + client: { getEntry: async () => await Promise.resolve(createEntry('unused')) }, + } + const serverOptimizedEntries = [ + { + baselineEntry: createEntry('hero'), + entryId: 'hero', + }, + ] const components = appRouter.createNextjsAppRouterOptimization({ ...testConfig, + contentful, liveUpdates: true, server: { enabled: false }, }) - const element = components.OptimizationRoot({ children: 'Bound content' }) - const provider = components.OptimizationProvider({ children: 'Provider content' }) + const element = components.OptimizationRoot({ + children: 'Bound content', + serverOptimizedEntries, + }) + const provider = components.OptimizationProvider({ + children: 'Provider content', + serverOptimizedEntries, + }) expect(components.OptimizedEntry).toBe(appRouter.OptimizedEntry) expect(components.NextAppAutoPageTracker).toBe(appRouter.NextAppAutoPageTracker) @@ -32,13 +67,17 @@ describe('Next.js App Router client components', () => { clientId: testConfig.clientId, environment: testConfig.environment, liveUpdates: true, + serverOptimizedEntries, }) + expect(element.props).not.toHaveProperty('contentful') expect(element.props).not.toHaveProperty('server') expect(provider?.props).toMatchObject({ api: testConfig.api, clientId: testConfig.clientId, environment: testConfig.environment, + serverOptimizedEntries, }) + expect(provider?.props).not.toHaveProperty('contentful') expect(provider?.props).not.toHaveProperty('liveUpdates') expect(provider).toMatchObject({ props: { diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts index 958177487..d19517ca9 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts @@ -49,16 +49,24 @@ export function createNextjsAppRouterOptimization( const rootConfig = toClientRootConfig(config) const providerConfig = toClientProviderConfig(config) - function OptimizationRoot({ children }: BoundNextjsOptimizationRootProps): ReactElement { - return createElement(ReactWebOptimizationRoot, rootConfig, children) + function OptimizationRoot({ + children, + serverOptimizedEntries, + }: BoundNextjsOptimizationRootProps): ReactElement { + return createElement( + ReactWebOptimizationRoot, + { ...rootConfig, serverOptimizedEntries }, + children, + ) } function OptimizationProvider({ children, + serverOptimizedEntries, }: BoundNextjsOptimizationRootProps): ReactElement | null { return createElement( ReactWebOptimizationProvider, - providerConfig, + { ...providerConfig, serverOptimizedEntries }, createElement( ReactWebLiveUpdatesProvider, { globalLiveUpdates: config.liveUpdates }, @@ -78,15 +86,23 @@ export function createNextjsAppRouterOptimization( function toClientRootConfig( config: NextjsOptimizationComponentsConfig, -): Omit { - const { server: _server, ...clientConfig } = config +): Omit< + OptimizationRootProps, + 'children' | 'sdk' | 'serverOptimizationState' | 'serverOptimizedEntries' +> { + const { contentful: _contentful, server: _server, ...clientConfig } = config return clientConfig } function toClientProviderConfig( config: NextjsOptimizationComponentsConfig, ): NextjsBoundProviderConfig { - const { liveUpdates: _liveUpdates, server: _server, ...providerConfig } = config + const { + contentful: _contentful, + liveUpdates: _liveUpdates, + server: _server, + ...providerConfig + } = config const clientProviderConfig: NextjsBoundProviderConfig = providerConfig return clientProviderConfig } diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx index 6d7933dfd..871990626 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx @@ -147,9 +147,13 @@ describe('Next.js App Router server components', () => { it('loads server data into the bound client provider', async () => { setServerData({ events: true, persistence: true }) + const serverOptimizedEntries = [{ baselineEntry, entryId: 'baseline-entry' }] const { OptimizationRoot } = createNextjsAppRouterOptimization({ ...sdkConfig, + contentful: { + client: { getEntry: async () => await Promise.resolve(baselineEntry) }, + }, defaults: { consent: false, persistenceConsent: false }, server: { enabled: true, @@ -157,7 +161,7 @@ describe('Next.js App Router server components', () => { }, }) - const element = await OptimizationRoot({ children: 'Server content' }) + const element = await OptimizationRoot({ children: 'Server content', serverOptimizedEntries }) expect(element).toMatchObject({ props: { @@ -170,8 +174,10 @@ describe('Next.js App Router server components', () => { defaults: { consent: true, persistenceConsent: true }, environment: sdkConfig.environment, serverOptimizationState: optimizationData, + serverOptimizedEntries, }, }) + expect(element.props).not.toHaveProperty('contentful') }) it('loads server defaults and live updates into the bound provider', async () => { @@ -277,6 +283,33 @@ describe('Next.js App Router server components', () => { }) }) + it('fetches and renders managed entryId content on the server', async () => { + const getEntry = rs.fn(async () => await Promise.resolve(baselineEntry)) + const { OptimizedEntry } = createNextjsAppRouterOptimization({ + ...sdkConfig, + contentful: { client: { getEntry }, cache: false }, + server: { + enabled: false, + }, + }) + + const element = await OptimizedEntry({ + entryId: 'baseline-entry', + entryQuery: { locale: 'de-DE' }, + children: (entry) => entry.sys.id, + }) + + expect(getEntry).toHaveBeenCalledWith('baseline-entry', { + include: 10, + locale: 'de-DE', + }) + expect(element.props).toMatchObject({ + 'data-ctfl-baseline-id': 'baseline-entry', + 'data-ctfl-entry-id': 'baseline-entry', + children: 'baseline-entry', + }) + }) + it('passes request-profile merge-tag helpers to server render props', async () => { setServerData(true) const mergeTagEntry = createMergeTagEntry('merge-tag', 'traits.continent') diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx index 004c5b7af..d2b7eecb1 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx @@ -35,7 +35,7 @@ import { type OptimizationNodeConfig, } from './server' import { renderOptimizedEntryOnServer } from './server-entry-renderer' -import type { ServerTrackingResolvedData } from './tracking-attributes' +import type { ServerTrackingBaselineEntry, ServerTrackingResolvedData } from './tracking-attributes' export type { OptimizedEntryRenderContext } from '@contentful/optimization-react-web' export type { @@ -48,11 +48,20 @@ export type { NextjsOptimizationServerOptions, NextjsServerOptimizedEntryProps, } from './bound-component-types' +export { + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type ServerOptimizedEntryHandoff, +} from './server' export { NextAppAutoPageTracker, type NextAppAutoPageContext, type NextAppAutoPageTrackerProps } type IgnoredReactWebOptimizedEntryProps = Pick< ReactWebOptimizedEntryProps, 'liveUpdates' | 'loadingFallback' > +type NextjsBoundManagedEntryQuery = Extract< + NextjsBoundOptimizedEntryProps, + { entryId: string } +>['entryQuery'] export interface NextjsOptimizationComponents { readonly OptimizationRoot: (props: BoundNextjsOptimizationRootProps) => Promise @@ -103,11 +112,12 @@ export function createNextjsAppRouterOptimization( async function OptimizationRoot({ children, + serverOptimizedEntries, }: BoundNextjsOptimizationRootProps): Promise { const serverData = await loadServerData() return createElement( ReactWebOptimizationProvider, - toClientProviderConfig(config, serverData), + { ...toClientProviderConfig(config, serverData), serverOptimizedEntries }, createElement( ReactWebLiveUpdatesProvider, { globalLiveUpdates: config.liveUpdates }, @@ -118,11 +128,12 @@ export function createNextjsAppRouterOptimization( async function OptimizationProvider({ children, + serverOptimizedEntries, }: BoundNextjsOptimizationRootProps): Promise { const serverData = await loadServerData() return createElement( ReactWebOptimizationProvider, - toClientProviderConfig(config, serverData), + { ...toClientProviderConfig(config, serverData), serverOptimizedEntries }, createElement( ReactWebLiveUpdatesProvider, { globalLiveUpdates: config.liveUpdates }, @@ -133,19 +144,41 @@ export function createNextjsAppRouterOptimization( async function OptimizedEntry(props: NextjsBoundOptimizedEntryProps): Promise { const { - baselineEntry, + baselineEntry: suppliedBaselineEntry, children, + entryId, + entryQuery, + errorFallback: _errorFallback, liveUpdates: _liveUpdates, loadingFallback: _loadingFallback, + onEntryError: _onEntryError, + onEntryResolved: _onEntryResolved, testId, 'data-testid': dataTestId, ...serverEntryProps } = props as NextjsBoundOptimizedEntryProps & Partial const { data } = await loadServerData() - const resolvedData = sdk.resolveOptimizedEntry(baselineEntry, data?.selectedOptimizations) + const { baselineEntry, resolvedData } = + suppliedBaselineEntry === undefined + ? await resolveManagedServerOptimizedEntry(entryId, entryQuery, data) + : { + baselineEntry: suppliedBaselineEntry, + resolvedData: sdk.resolveOptimizedEntry( + suppliedBaselineEntry, + data?.selectedOptimizations, + ), + } const renderContext: OptimizedEntryRenderContext = { + baselineEntry, + baselineEntryId: baselineEntry.sys.id, + entry: resolvedData.entry, + entryId: resolvedData.entry.sys.id, getMergeTagValue: (embeddedEntryNodeTarget, profile) => sdk.getMergeTagValue(embeddedEntryNodeTarget, profile ?? data?.profile), + optimizationContextId: resolvedData.optimizationContextId, + resolvedData, + selectedOptimization: resolvedData.selectedOptimization, + selectedOptimizations: data?.selectedOptimizations, } const testAttributes = dataTestId === undefined && testId === undefined @@ -163,6 +196,26 @@ export function createNextjsAppRouterOptimization( }) } + async function resolveManagedServerOptimizedEntry( + entryId: string | undefined, + entryQuery: NextjsBoundManagedEntryQuery, + data: OptimizationData | undefined, + ): Promise<{ + readonly baselineEntry: ServerTrackingBaselineEntry + readonly resolvedData: ServerTrackingResolvedData + }> { + if (entryId === undefined) { + throw new Error('Bound Next.js OptimizedEntry requires either baselineEntry or entryId.') + } + + const result = await sdk.fetchOptimizedEntry(entryId, { + query: entryQuery, + selectedOptimizations: data?.selectedOptimizations, + }) + + return { baselineEntry: result.baselineEntry, resolvedData: result } + } + return { NextAppAutoPageTracker, OptimizationProvider, @@ -227,7 +280,12 @@ function toClientProviderConfig( config: NextjsOptimizationComponentsConfig, serverData: NextjsAutomaticServerOptimizationData, ): NextjsBoundProviderConfig { - const { liveUpdates: _liveUpdates, server: _server, ...providerConfig } = config + const { + contentful: _contentful, + liveUpdates: _liveUpdates, + server: _server, + ...providerConfig + } = config const clientProviderConfig: NextjsBoundProviderConfig = { ...providerConfig, defaults: resolveClientDefaults(providerConfig.defaults, serverData.consent), diff --git a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts index bcb56fb03..9d38fc8e3 100644 --- a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts +++ b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts @@ -1,7 +1,10 @@ import type { OptimizationRootProps, OptimizedEntryProps } from '@contentful/optimization-react-web' import type { ReactNode } from 'react' -export type NextjsBoundProviderConfig = Omit +export type NextjsBoundProviderConfig = Omit< + OptimizationRootProps, + 'children' | 'liveUpdates' | 'serverOptimizedEntries' +> export interface NextjsCookieValue { readonly value: string @@ -44,7 +47,7 @@ export interface NextjsOptimizationCookieConfig { export type NextjsBoundRootConfig = Omit< OptimizationRootProps, - 'children' | 'cookie' | 'sdk' | 'serverOptimizationState' + 'children' | 'cookie' | 'sdk' | 'serverOptimizationState' | 'serverOptimizedEntries' > & { readonly cookie?: NextjsOptimizationCookieConfig } @@ -60,7 +63,9 @@ export interface NextjsPagesRouterClientDefaults { readonly persistenceConsent?: boolean } -export type NextjsBoundOptimizedEntryProps = Omit< +type DistributiveOmit = T extends unknown ? Omit : never + +export type NextjsBoundOptimizedEntryProps = DistributiveOmit< OptimizedEntryProps, 'liveUpdates' | 'loadingFallback' > @@ -69,4 +74,5 @@ export type NextjsServerOptimizedEntryProps = NextjsBoundOptimizedEntryProps export interface BoundNextjsOptimizationRootProps { readonly children?: ReactNode + readonly serverOptimizedEntries?: OptimizationRootProps['serverOptimizedEntries'] } diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts index a23099598..609f31e84 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts @@ -1,4 +1,5 @@ import ContentfulOptimizationRuntime from '@contentful/optimization-node' +import type { Entry } from 'contentful' import type { GetServerSidePropsContext } from 'next' import { IncomingMessage, ServerResponse } from 'node:http' import { Socket } from 'node:net' @@ -55,13 +56,15 @@ interface CreatedSdk { readonly page: ReturnType> readonly sdk: ContentfulOptimization } +type NextjsOptimizationConfig = Parameters[0] function createSdk( page = rs.fn( async () => await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }), ), + config: NextjsOptimizationConfig = SDK_CONFIG, ): CreatedSdk { - const sdk = createNextjsOptimization(SDK_CONFIG) + const sdk = createNextjsOptimization(config) const originalForRequest = sdk.forRequest.bind(sdk) const forRequest = rs.spyOn(sdk, 'forRequest') @@ -74,6 +77,24 @@ function createSdk( return { forRequest, page, sdk } } +function createEntry(id: string): Entry { + return { + fields: { title: id }, + metadata: { tags: [] }, + sys: { + contentType: { sys: { id: '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', + }, + } +} + function createContext({ cookies = {}, headers = {}, @@ -238,6 +259,47 @@ describe('Next.js Pages Router server helpers', () => { expect(JSON.parse(JSON.stringify(result.props))).toEqual(result.props) }) + it('prefetches declared optimized entries into page props after request data loads', async () => { + const calls: string[] = [] + const baselineEntry = createEntry('hero') + const getEntry = rs.fn(async () => { + calls.push('fetch') + return await Promise.resolve(baselineEntry) + }) + const page = rs.fn(async () => { + calls.push('page') + return await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }) + }) + const { sdk } = createSdk(page, { + ...SDK_CONFIG, + contentful: { client: { getEntry }, cache: false }, + }) + + const result = await getNextjsPagesRouterOptimizationProps(sdk, createContext(), { + consent: true, + prefetchOptimizedEntries: [ + { entryId: 'hero', entryQuery: { locale: 'de-DE' } }, + { entryId: 'hero', entryQuery: { locale: 'de-DE' } }, + ], + }) + + expect(calls).toEqual(['page', 'fetch']) + expect(getEntry).toHaveBeenCalledTimes(1) + expect(getEntry).toHaveBeenCalledWith('hero', { include: 10, locale: 'de-DE' }) + expect(result.props.contentfulOptimization.serverOptimizedEntries).toEqual([ + { + baselineEntry, + entryId: 'hero', + entryQuery: { locale: 'de-DE' }, + }, + { + baselineEntry, + entryId: 'hero', + entryQuery: { locale: 'de-DE' }, + }, + ]) + }) + it('emits the initial browser page event when server data came from pre-consent tracking', async () => { const { sdk } = createSdk() diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts index 55fa4db16..160fe42db 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts @@ -12,6 +12,7 @@ import { import { createNextjsOptimization, getNextjsServerOptimizationData, + prefetchOptimizedEntries as prefetchServerOptimizedEntries, type ContentfulOptimization, type CoreStatelessRequest, type CoreStatelessRequestConsent, @@ -20,7 +21,9 @@ import { type NextjsServerOptimizationDataOptions, type OptimizationData, type OptimizationNodeConfig, + type OptimizedEntryPrefetchDescriptor, type PersistNextjsAnonymousIdOptions, + type ServerOptimizedEntryHandoff, } from './server' const SECONDS_IN_DAY = 86_400 @@ -30,11 +33,17 @@ export type { NextjsOptimizationCookieConfig, NextjsPagesRouterClientDefaults, } from './bound-component-types' +export { + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type ServerOptimizedEntryHandoff, +} from './server' export interface NextjsPagesRouterOptimizationPageProps { readonly clientDefaults?: NextjsPagesRouterClientDefaults readonly initialPageEvent: NextjsPagesRouterInitialPageEvent readonly serverOptimizationState?: OptimizationData + readonly serverOptimizedEntries?: readonly ServerOptimizedEntryHandoff[] } export interface NextjsPagesRouterOptimizationProps { @@ -47,6 +56,7 @@ export interface NextjsPagesRouterOptimizationPropsOptions PersistNextjsAnonymousIdOptions { readonly initialPageEvent?: NextjsPagesRouterInitialPageEvent readonly locale?: string + readonly prefetchOptimizedEntries?: readonly OptimizedEntryPrefetchDescriptor[] } export type NextjsPagesRouterServerConsentResolver = ( @@ -108,6 +118,7 @@ export async function getNextjsPagesRouterOptimizationProps( deleteWhenProfileCannotPersist, initialPageEvent, locale, + prefetchOptimizedEntries, ...requestOptions } = options const request = createPagesRouterRequest(context) @@ -122,6 +133,10 @@ export async function getNextjsPagesRouterOptimizationProps( deleteWhenProfileCannotPersist, }) if (setCookie !== undefined) appendSetCookie(context, setCookie) + const serverOptimizedEntries = + prefetchOptimizedEntries === undefined + ? undefined + : await prefetchServerOptimizedEntries(requestOptimization, prefetchOptimizedEntries) return { data, @@ -131,6 +146,7 @@ export async function getNextjsPagesRouterOptimizationProps( data, initialPageEvent ?? resolveInitialPageEvent(data, requestOptions.consent), resolveClientDefaults(requestOptions.consent), + serverOptimizedEntries, ), }, } @@ -234,10 +250,12 @@ function toContentfulOptimizationProps( data: OptimizationData | undefined, initialPageEvent: NextjsPagesRouterInitialPageEvent, clientDefaults: NextjsPagesRouterClientDefaults | undefined, + serverOptimizedEntries: readonly ServerOptimizedEntryHandoff[] | undefined, ): NextjsPagesRouterOptimizationPageProps { const props: NextjsPagesRouterOptimizationPageProps = { ...(clientDefaults === undefined ? {} : { clientDefaults }), initialPageEvent, + ...(serverOptimizedEntries === undefined ? {} : { serverOptimizedEntries }), } if (data === undefined) return props diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx index 0b6842397..0d5696722 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx @@ -1,3 +1,5 @@ +import type { Entry } from 'contentful' +import { renderToString } from 'react-dom/server' import * as pagesRouter from './pages-router' import type { OptimizationData } from './server' @@ -38,20 +40,41 @@ const serverOptimizationState: OptimizationData = { }, } +function createEntry(id: string): Entry { + return { + fields: { title: id }, + metadata: { tags: [] }, + sys: { + contentType: { sys: { id: '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', + }, + } +} + describe('Next.js Pages Router client components', () => { it('passes config and render-time server state through the bound root and provider', () => { const components = pagesRouter.createNextjsPagesRouterOptimization({ ...testConfig, liveUpdates: true, }) + const serverOptimizedEntries = [{ baselineEntry: createEntry('hero'), entryId: 'hero' }] const root = components.OptimizationRoot({ children: 'Root content', serverOptimizationState, + serverOptimizedEntries, }) const provider = components.OptimizationProvider({ children: 'Provider content', serverOptimizationState, + serverOptimizedEntries, }) expect(root.props).toMatchObject({ @@ -61,12 +84,14 @@ describe('Next.js Pages Router client components', () => { environment: testConfig.environment, liveUpdates: true, serverOptimizationState, + serverOptimizedEntries, }) expect(provider?.props).toMatchObject({ api: testConfig.api, clientId: testConfig.clientId, environment: testConfig.environment, serverOptimizationState, + serverOptimizedEntries, }) expect(provider?.props).not.toHaveProperty('liveUpdates') expect(provider).toMatchObject({ @@ -104,6 +129,25 @@ describe('Next.js Pages Router client components', () => { }) }) + it('renders client OptimizedEntry content from Pages Router handoff during SSR', () => { + const components = pagesRouter.createNextjsPagesRouterOptimization(testConfig) + const baselineEntry = createEntry('hero') + + const markup = renderToString( + + + {(entry) => entry.sys.id} + + , + ) + + expect(markup).toContain('hero') + expect(markup).not.toContain('loading') + }) + it('returns the Pages tracker only', () => { const components = pagesRouter.createNextjsPagesRouterOptimization(testConfig) diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts index 0e4024cd2..fd4e34b27 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts @@ -58,6 +58,7 @@ export function createNextjsPagesRouterOptimization( children, clientDefaults, serverOptimizationState, + serverOptimizedEntries, }: BoundNextjsPagesRouterOptimizationRootProps): ReactElement { return createElement( ReactWebOptimizationRoot, @@ -65,6 +66,7 @@ export function createNextjsPagesRouterOptimization( ...rootConfig, defaults: resolveClientDefaults(rootConfig.defaults, clientDefaults), serverOptimizationState, + serverOptimizedEntries, }, children, ) @@ -74,6 +76,7 @@ export function createNextjsPagesRouterOptimization( children, clientDefaults, serverOptimizationState, + serverOptimizedEntries, }: BoundNextjsPagesRouterOptimizationRootProps): ReactElement | null { return createElement( ReactWebOptimizationProvider, @@ -81,6 +84,7 @@ export function createNextjsPagesRouterOptimization( ...providerConfig, defaults: resolveClientDefaults(providerConfig.defaults, clientDefaults), serverOptimizationState, + serverOptimizedEntries, }, createElement( ReactWebLiveUpdatesProvider, @@ -100,14 +104,18 @@ export function createNextjsPagesRouterOptimization( function toClientRootConfig( config: NextjsPagesRouterOptimizationComponentsConfig, -): Omit { - return config +): Omit< + OptimizationRootProps, + 'children' | 'sdk' | 'serverOptimizationState' | 'serverOptimizedEntries' +> { + const { contentful: _contentful, ...clientConfig } = config + return clientConfig } function toClientProviderConfig( config: NextjsPagesRouterOptimizationComponentsConfig, -): Omit { - const { liveUpdates: _liveUpdates, ...providerConfig } = config +): Omit { + const { contentful: _contentful, liveUpdates: _liveUpdates, ...providerConfig } = config return providerConfig } diff --git a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx index 3e35d62e0..e56af95ef 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx @@ -1,5 +1,6 @@ import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, + ServerOptimizedEntry, bindNextjsOptimizationRequest, createNextjsOptimization, createNextjsPageContext, @@ -10,6 +11,7 @@ import { type CoreStatelessRequest, type OptimizationData, } from './server' +import type { ServerTrackingBaselineEntry, ServerTrackingResolvedData } from './tracking-attributes' const sdkConfig = { clientId: 'test-client-id', @@ -21,6 +23,16 @@ interface CreatedSdk { readonly sdk: ContentfulOptimization } +const baselineEntry = createEntry('baseline-entry') +const resolvedData = { + entry: createEntry('variant-entry'), + selectedOptimization: { + experienceId: 'experience-id', + sticky: false, + variantIndex: 1, + variants: {}, + }, +} satisfies ServerTrackingResolvedData const optimizationData: OptimizationData = { changes: [], selectedOptimizations: [], @@ -49,6 +61,45 @@ const optimizationData: OptimizationData = { }, } +function createEntry(id: string): ServerTrackingBaselineEntry { + return { + fields: {}, + metadata: { + tags: [], + }, + sys: { + contentType: { + sys: { + id: 'content-type', + linkType: 'ContentType', + type: 'Link', + }, + }, + createdAt: '2024-01-01T00:00:00.000Z', + environment: { + sys: { + id: 'main', + linkType: 'Environment', + type: 'Link', + }, + }, + id, + locale: 'en-US', + publishedVersion: 1, + revision: 1, + space: { + sys: { + id: 'space-id', + linkType: 'Space', + type: 'Link', + }, + }, + type: 'Entry', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + } +} + function createSdk( page = rs.fn( async () => @@ -362,4 +413,45 @@ describe('Next.js server helpers', () => { sameSite: 'lax', }) }) + + it('renders a server wrapper with tracking attributes and caller props', () => { + const element = ServerOptimizedEntry({ + as: 'article', + baselineEntry, + children: 'Rendered content', + className: 'entry', + resolvedData, + trackViews: true, + }) + + expect(element.type).toBe('article') + expect(element.props).toMatchObject({ + 'data-ctfl-baseline-id': 'baseline-entry', + 'data-ctfl-entry-id': 'variant-entry', + 'data-ctfl-optimization-id': 'experience-id', + 'data-ctfl-track-views': true, + 'data-ctfl-variant-index': 1, + children: 'Rendered content', + className: 'entry', + }) + }) + + it('renders a server wrapper from a managed fetch result', () => { + const element = ServerOptimizedEntry({ + as: 'article', + children: 'Rendered content', + result: { + baselineEntry, + ...resolvedData, + }, + }) + + expect(element.props).toMatchObject({ + 'data-ctfl-baseline-id': 'baseline-entry', + 'data-ctfl-entry-id': 'variant-entry', + 'data-ctfl-optimization-id': 'experience-id', + 'data-ctfl-variant-index': 1, + children: 'Rendered content', + }) + }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index c5c1dac09..92e5ddf35 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -12,17 +12,31 @@ import type { UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' import { createPageContextFromUrl } from '@contentful/optimization-node/core-sdk' +import type { JSX, ReactElement, ReactNode } from 'react' import type { NextjsCookieReader } from './bound-component-types' import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' +import { renderOptimizedEntryOnServer } from './server-entry-renderer' +import type { + ServerTrackingAttributeOptions, + ServerTrackingBaselineEntry, + ServerTrackingResolvedData, +} from './tracking-attributes' export const DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE = ANONYMOUS_ID_COOKIE export type { OptimizationNodeConfig } from '@contentful/optimization-node' export type { OptimizationData, PartialProfile } from '@contentful/optimization-node/api-schemas' +export { + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type OptimizedEntryPrefetchRuntime, + type ServerOptimizedEntryHandoff, +} from '@contentful/optimization-node/core-sdk' export type { CoreStatelessInsightsOptions, CoreStatelessRequest, CoreStatelessRequestConsent, CoreStatelessRequestOptions, + FetchOptimizedEntryResult, PageViewBuilderArgs, ResolvedData, UniversalEventBuilderArgs, @@ -136,6 +150,36 @@ export type NextjsPageContextInput = | NonNullable | CreateNextjsPageContextOptions +export type ServerOptimizedEntryFetchResult = ServerTrackingResolvedData & { + readonly baselineEntry: ServerTrackingBaselineEntry +} + +type ServerOptimizedEntryOwnProps = + ServerTrackingAttributeOptions & { + readonly as?: TElement + readonly children?: ReactNode + } & ( + | { + readonly baselineEntry: ServerTrackingBaselineEntry + readonly resolvedData: ServerTrackingResolvedData + readonly result?: never + } + | { + readonly baselineEntry?: never + readonly resolvedData?: never + readonly result: ServerOptimizedEntryFetchResult + } + ) + +type DataCtflAttributeName = `data-ctfl-${string}` + +export type ServerOptimizedEntryProps = + ServerOptimizedEntryOwnProps & + Omit< + JSX.IntrinsicElements[TElement], + keyof ServerOptimizedEntryOwnProps | DataCtflAttributeName + > + export function createNextjsOptimization(config: OptimizationNodeConfig): ContentfulOptimization { return new ContentfulOptimizationRuntime(config) } @@ -315,6 +359,37 @@ export function persistNextjsAnonymousId( } } +function getServerOptimizedEntryData( + props: ServerOptimizedEntryProps, +): { + readonly baselineEntry: ServerTrackingBaselineEntry + readonly resolvedData: ServerTrackingResolvedData +} { + if (props.result !== undefined) { + return { baselineEntry: props.result.baselineEntry, resolvedData: props.result } + } + + return { baselineEntry: props.baselineEntry, resolvedData: props.resolvedData } +} + +export function ServerOptimizedEntry( + props: ServerOptimizedEntryProps, +): ReactElement { + const { + baselineEntry: _baselineEntry, + resolvedData: _resolvedData, + result: _result, + ...rendererProps + } = props + const { baselineEntry, resolvedData } = getServerOptimizedEntryData(props) + + return renderOptimizedEntryOnServer({ + ...rendererProps, + baselineEntry, + resolvedData, + }) +} + function mergePageContext( requestPage: NonNullable | undefined, eventPage: UniversalEventBuilderArgs['page'], diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 8eff726bd..63aacdb22 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -60,6 +60,9 @@ Install using an NPM-compatible package manager, pnpm for example: pnpm install @contentful/optimization-react-web ``` +Add `contentful` too when `OptimizationRoot` will use your app-owned `contentful.js` client for +managed entry fetching. + React and React DOM are application-owned peer dependencies. The SDK uses the React runtime already installed by your app instead of installing its own copy. @@ -99,23 +102,24 @@ or custom framework adapters. such as `liveUpdates`, `onStatesReady`, and `serverOptimizationState`. The Web SDK `autoTrackEntryInteraction` option is exposed as the React `trackEntryInteraction` prop. -| Prop | Required? | Default | Description | -| ------------------------- | --------- | --------------------------------------------- | -------------------------------------------------------------------- | -| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | -| `environment` | No | `'main'` | Contentful environment identifier | -| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | -| `app` | No | `undefined` | Application metadata attached to outgoing event context | -| `locale` | No | `undefined` | SDK Experience API and default event locale | -| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | -| `serverOptimizationState` | No | `undefined` | Server-returned Optimization state for the initial provider snapshot | -| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | -| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | -| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | -| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | -| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | -| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | -| `logLevel` | No | `'error'` | Minimum log level for the default console sink | -| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | +| Prop | Required? | Default | Description | +| ------------------------- | --------- | --------------------------------------------- | -------------------------------------------------------------------------- | +| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | +| `environment` | No | `'main'` | Contentful environment identifier | +| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | +| `app` | No | `undefined` | Application metadata attached to outgoing event context | +| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache | +| `locale` | No | `undefined` | SDK Experience API and default event locale | +| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | +| `serverOptimizationState` | No | `undefined` | Server-returned Optimization state to apply before provider children mount | +| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | +| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | +| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | +| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | +| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | +| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | +| `logLevel` | No | `'error'` | Minimum log level for the default console sink | +| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | Use `OptimizationProvider` directly when an application or framework adapter needs direct provider control, including integrations that supply an SDK instance: @@ -254,10 +258,10 @@ function HeroEntry({ baselineEntry }) { } ``` -Fetch Contentful entries in the app layer with one CDA locale. For localized apps, configure your -application locale and pass it directly before passing entries to `OptimizedEntry`, -`useEntryResolver()`, or `useOptimizedEntry()`. Do not pass all-locale CDA responses from -`withAllLocales` or `locale=*`; these APIs expect direct single-locale field values. See +For manual entries, fetch Contentful entries in the app layer with one CDA locale before passing +them to `baselineEntry` surfaces. For managed fetching, use `entryId` and `entryQuery`. Do not pass +all-locale CDA responses from `withAllLocales` or `locale=*`; these APIs expect direct single-locale +field values. See [Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) for the entry contract and [Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) @@ -322,8 +326,9 @@ component-local UI state, keep using hooks and React effects under the provider. ### OptimizedEntry -`OptimizedEntry` resolves a baseline Contentful entry and renders either the selected variant or the -baseline entry: +`OptimizedEntry` fetches a Contentful entry by ID when the root SDK is configured with +`contentful: { client }`, then renders the selected variant or baseline. `baselineEntry` remains +supported for the manual path: ```tsx import { OptimizedEntry } from '@contentful/optimization-react-web' @@ -339,6 +344,47 @@ function HeroEntry({ baselineEntry }) { } ``` +When `OptimizationRoot` uses a Web SDK configured with `contentful: { client }`, React entry +surfaces can fetch by entry ID: + +```tsx +function HeroEntry() { + return ( + } + errorFallback={() => } + > + {(resolvedEntry) => } + + ) +} +``` + +Hooks use the same managed entry source: + +```tsx +import { useOptimizedEntry } from '@contentful/optimization-react-web' + +function HeroEntry() { + const { entry, error, isLoading } = useOptimizedEntry({ + entryId: 'hero-entry', + entryQuery: { locale: 'en-US' }, + }) + + if (isLoading) return + if (error || !entry) return + return +} +``` + +`baselineEntry` remains supported and takes the manual path. Use `onEntryError` when application +code needs to log or report managed CDA failures. Use `errorFallback` on `OptimizedEntry` to render +fallback UI for managed CDA failures. Use `onEntryResolved`, the render prop metadata, or the hook's +`metadata` and `isResolved` fields when application code needs the baseline ID, resolved entry ID, +or optimization context after tracking attributes are ready. + Use `loadingFallback`, direct children, wrapper props, and nested composition patterns when needed. For optimized entries, the loading phase begins immediately while optimization is unresolved. If the state is still unresolved after 5 seconds, the component reveals baseline content so loading does diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 69ded0bbe..080edaea9 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": 4000, - "index.mjs": 3500 + "index.cjs": 4700, + "index.mjs": 4000 } } }, diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 8273fac8f..aeb2c89c7 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -1,3 +1,4 @@ +import type { Entry } from 'contentful' import { createContext } from 'react' import type { WebOptimizationRuntime } from '@contentful/optimization-web/runtime' @@ -7,6 +8,8 @@ export type OptimizationSdk = WebOptimizationRuntime export interface OptimizationContextValue { readonly sdk: OptimizationSdk | undefined readonly error: Error | undefined + readonly isLive?: boolean + readonly serverOptimizedEntries?: ReadonlyMap } export const OptimizationContext = createContext(null) diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index e1e216e8f..dc1597759 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -31,6 +31,7 @@ export { } from './hooks/useOptimizationState' export { OptimizedEntry } from './optimized-entry/OptimizedEntry' export type { + OptimizedEntryErrorFallback, OptimizedEntryLoadingFallback, OptimizedEntryProps, OptimizedEntryRenderContext, @@ -50,4 +51,10 @@ export type { } from './provider/OptimizationProvider' export { OptimizationRoot } from './root/OptimizationRoot' export type { OptimizationRootProps } from './root/OptimizationRoot' +export { + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type OptimizedEntryPrefetchRuntime, + type ServerOptimizedEntryHandoff, +} from './server-optimized-entries' export type ContentfulOptimizationOrNull = OptimizationSdk | null 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 672bacdc7..36a7f29b9 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 @@ -1,8 +1,11 @@ import type { MergeTagEntry, + OptimizationData, SelectedOptimizationArray, } from '@contentful/optimization-web/api-schemas' import { act, createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { OptimizationRoot } from '../root/OptimizationRoot' import { OptimizedEntry, type OptimizedEntryProps } from './OptimizedEntry' import { createOptimizationSdk, @@ -18,6 +21,21 @@ import { type TestEntry, } from './OptimizedEntry.testUtils' +function createDeferred(): { + readonly promise: Promise + readonly reject: (reason?: unknown) => void + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + let rejectDeferred: (reason?: unknown) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolveDeferred = resolve + rejectDeferred = reject + }) + + return { promise, reject: rejectDeferred, resolve: resolveDeferred } +} + describe('OptimizedEntry', () => { const baseline = makeEntry('baseline') const optimizedBaseline = makeOptimizableEntry('optimized-baseline') @@ -51,6 +69,36 @@ describe('OptimizedEntry', () => { return mergeTagEntry } + function createServerOptimizationState(): OptimizationData { + return { + changes: [], + selectedOptimizations: [], + profile: { + id: 'server-profile', + stableId: 'server-profile', + random: 0.5, + audiences: [], + traits: {}, + location: {}, + session: { + id: 'server-session', + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + }, + } + } + const variantOneState: SelectedOptimizationArray = [ { experienceId: 'exp-hero', @@ -203,6 +251,62 @@ describe('OptimizedEntry', () => { await view.unmount() }) + it('fetches entryId entries and renders the loading fallback until they resolve', async () => { + const deferred = createDeferred() + const { optimization } = createRuntime((entry) => ({ entry })) + const fetchContentfulEntry = rs.fn(async () => await deferred.promise) + Reflect.set(optimization, 'fetchContentfulEntry', fetchContentfulEntry) + + const view = await renderComponent( + + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + expect(view.container.textContent).toContain('loading') + expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' }) + + await act(async () => { + deferred.resolve(baseline) + await deferred.promise + }) + + expect(view.container.textContent).toContain('baseline') + expect(getWrapper(view.container).dataset.ctflEntryId).toBe('baseline') + + await view.unmount() + }) + + it('renders entryId fetch error fallbacks', async () => { + const deferred = createDeferred() + const error = new Error('CDA failed') + const onEntryError = rs.fn() + const { optimization } = createRuntime((entry) => ({ entry })) + Reflect.set(optimization, 'fetchContentfulEntry', async () => await deferred.promise) + + const view = await renderComponent( + `error: ${entryError.message}`} + onEntryError={onEntryError} + > + {(resolved) => readTitle(resolved)} + , + optimization, + ) + + await act(async () => { + deferred.reject(error) + await deferred.promise.catch(() => undefined) + }) + + expect(onEntryError).toHaveBeenCalledWith(error) + expect(view.container.textContent).toContain('error: CDA failed') + + await view.unmount() + }) + it('reveals baseline after the unresolved loading timeout when a custom fallback is provided', async () => { rs.useFakeTimers() @@ -293,6 +397,42 @@ describe('OptimizedEntry', () => { await view.unmount() }) + it('passes resolved metadata to render props and onEntryResolved', async () => { + const onEntryResolved = rs.fn() + const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { + if (!selectedOptimizations?.length) return { entry } + return { + entry: variantA, + optimizationContextId: 'ctx-1', + selectedOptimization: selectedOptimizations[0], + } + }) + + const view = await renderComponent( + + {(resolved, metadata) => + `${readTitle(resolved)}:${metadata.baselineEntryId}:${metadata.entryId}:${metadata.optimizationContextId}` + } + , + optimization, + ) + + await emit(variantOneState) + + expect(view.container.textContent).toContain('variant-a:optimized-baseline:variant-a:ctx-1') + expect(onEntryResolved).toHaveBeenCalledWith( + expect.objectContaining({ + baselineEntry: optimizedBaseline, + baselineEntryId: 'optimized-baseline', + entry: variantA, + entryId: 'variant-a', + optimizationContextId: 'ctx-1', + }), + ) + + await view.unmount() + }) + it('maps configurable Web SDK attributes to data attributes', async () => { const { optimization } = createRuntime((entry) => ({ entry })) @@ -668,6 +808,42 @@ describe('OptimizedEntry', () => { expect(markup).not.toContain('visibility:hidden') }) + it('renders managed entryId content from server handoff during SSR', () => { + const markup = renderToStringWithoutWindow(() => + renderToString( + + + {(resolved) => readTitle(resolved)} + + , + ), + ) + + expect(markup).toContain('baseline') + expect(markup).not.toContain('variant-a') + expect(markup).not.toContain('loading') + }) + it('renders non-optimized content after sdk initialization', async () => { const { optimization } = createRuntime((entry) => ({ entry })) 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 8801b6129..c9d056a21 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 @@ -1,24 +1,44 @@ +import type { + ContentfulEntryQuery, + OptimizedEntryMetadata, +} from '@contentful/optimization-web/core-sdk' import { OPTIMIZED_ENTRY_HOST_DISPLAY, + createOptimizedEntryLoadingEntry, resolveOptimizedEntryNestingState, } from '@contentful/optimization-web/presentation' import type { Entry } from 'contentful' -import { createContext, useContext, useEffect, useMemo, useRef, type JSX } from 'react' +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + type JSX, + type ReactNode, +} from 'react' import { useOptimization } from '../hooks/useOptimization' import { createScopedLogger } from '../logger' import { resolveChildren, + resolveErrorFallback, resolveLoadingFallback, resolveLoadingLayoutTargetStyle, + type ErrorFallback, type LoadingFallback, type OptimizedEntryChildren, type OptimizedEntryRenderContext, type RenderProp, type WrapperElement, } from './optimizedEntryUtils' -import { useOptimizedEntrySnapshot } from './useOptimizedEntry' +import { + useManagedBaselineEntry, + useOptimizedEntrySnapshot, + type UseOptimizedEntryParams, +} from './useOptimizedEntry' export type OptimizedEntryLoadingFallback = LoadingFallback +export type OptimizedEntryErrorFallback = ErrorFallback export type { OptimizedEntryRenderContext } export type OptimizedEntryWrapperElement = WrapperElement export type OptimizedEntryRenderProp = RenderProp @@ -28,14 +48,9 @@ export type OptimizedEntryRenderProp = RenderProp * * @public */ -export interface OptimizedEntryProps { +interface OptimizedEntrySharedProps { /** - * The baseline Contentful entry fetched with `include: 10`. - * Must include `nt_experiences` field with linked optimization data. - */ - baselineEntry: Entry - /** - * Render prop that receives the resolved variant entry. + * Render prop that receives the resolved variant entry and metadata when resolved. */ children: OptimizedEntryChildren /** @@ -60,6 +75,18 @@ export interface OptimizedEntryProps { * Optional fallback rendered while optimization state is unresolved. */ loadingFallback?: OptimizedEntryLoadingFallback + /** + * Optional fallback rendered when SDK-managed entry fetching fails. + */ + errorFallback?: OptimizedEntryErrorFallback + /** + * Callback invoked when SDK-managed entry fetching fails. + */ + onEntryError?: (error: Error) => void + /** + * Callback invoked when a resolved entry is rendered with tracking attributes ready. + */ + onEntryResolved?: (metadata: OptimizedEntryMetadata) => void /** * Marks the optimized entry wrapper as a click target for entry click tracking. */ @@ -86,10 +113,106 @@ export interface OptimizedEntryProps { hoverDurationUpdateIntervalMs?: number } +type OptimizedEntrySourceProps = + | { + /** + * The baseline Contentful entry fetched with `include: 10`. + * Must include `nt_experiences` field with linked optimization data. + */ + baselineEntry: Entry + entryId?: never + entryQuery?: never + } + | { + baselineEntry?: never + /** Contentful entry ID fetched through the SDK-managed Contentful client. */ + entryId: string + /** Per-call Contentful `getEntry()` query overrides. */ + entryQuery?: ContentfulEntryQuery + } + +/** + * Props for the {@link OptimizedEntry} component. + * + * @public + */ +export type OptimizedEntryProps = OptimizedEntrySharedProps & OptimizedEntrySourceProps + const WRAPPER_STYLE = Object.freeze({ display: OPTIMIZED_ENTRY_HOST_DISPLAY }) const OptimizedEntryNestingContext = createContext | null>(null) const logger = createScopedLogger('React:OptimizedEntry') +interface OptimizedEntryFrameProps { + readonly children: ReactNode + readonly currentAndAncestorBaselineIds: ReadonlySet + readonly dataTestId: string | undefined + readonly hostAttributes?: Record + readonly Wrapper: OptimizedEntryWrapperElement +} + +function OptimizedEntryFrame({ + children, + currentAndAncestorBaselineIds, + dataTestId, + hostAttributes, + Wrapper, +}: OptimizedEntryFrameProps): JSX.Element { + return ( + + + {children} + + + ) +} + +function hasBaselineEntry( + entryProps: OptimizedEntrySourceProps, +): entryProps is Extract { + return entryProps.baselineEntry !== undefined +} + +function resolveEntrySource( + entryProps: OptimizedEntrySourceProps, + liveUpdates: boolean | undefined, + onEntryError: ((error: Error) => void) | undefined, +): { readonly entryId: string | undefined; readonly managedEntryParams: UseOptimizedEntryParams } { + if (hasBaselineEntry(entryProps)) { + return { + entryId: undefined, + managedEntryParams: { baselineEntry: entryProps.baselineEntry, liveUpdates, onEntryError }, + } + } + + return { + entryId: entryProps.entryId, + managedEntryParams: { + entryId: entryProps.entryId, + entryQuery: entryProps.entryQuery, + liveUpdates, + onEntryError, + }, + } +} + +function renderErrorFallback( + errorFallback: OptimizedEntryErrorFallback | undefined, + error: Error, + frameProps: Omit, +): JSX.Element | null { + const errorContent = resolveErrorFallback(errorFallback, error) + + if (errorContent === undefined) { + return null + } + + return {errorContent} +} + +function getTargetDisplay(wrapper: OptimizedEntryWrapperElement): 'block' | 'inline' { + return wrapper === 'span' ? 'inline' : 'block' +} + function useDuplicateBaselineGuard(baselineEntryId: string): { currentAndAncestorBaselineIds: ReadonlySet hasDuplicateBaselineAncestor: boolean @@ -119,35 +242,38 @@ function useDuplicateBaselineGuard(baselineEntryId: string): { } export function OptimizedEntry({ - baselineEntry, children, liveUpdates, as = 'div', testId, 'data-testid': dataTestIdProp, loadingFallback, + errorFallback, + onEntryError, + onEntryResolved, clickable, hoverDurationUpdateIntervalMs, trackClicks, trackHovers, trackViews, viewDurationUpdateIntervalMs, + ...entryProps }: OptimizedEntryProps): JSX.Element | null { const sdk = useOptimization() - const renderContext = useMemo( - () => ({ - getMergeTagValue: (embeddedEntryNodeTarget, profile) => - sdk.getMergeTagValue(embeddedEntryNodeTarget, profile), - }), - [sdk], + const { entryId, managedEntryParams } = resolveEntrySource(entryProps, liveUpdates, onEntryError) + const managedEntry = useManagedBaselineEntry(managedEntryParams) + const loadingEntry = useMemo( + () => createOptimizedEntryLoadingEntry(entryId ?? 'contentful-entry'), + [entryId], ) + const baselineEntry = managedEntry.entry ?? loadingEntry const { sys: { id: baselineEntryId }, } = baselineEntry const { currentAndAncestorBaselineIds, hasDuplicateBaselineAncestor } = useDuplicateBaselineGuard(baselineEntryId) const hasCustomLoadingFallback = loadingFallback !== undefined - const targetDisplay = as === 'span' ? 'inline' : 'block' + const targetDisplay = getTargetDisplay(as) const snapshot = useOptimizedEntrySnapshot({ baselineEntry, clickable, @@ -160,14 +286,60 @@ export function OptimizedEntry({ trackViews, viewDurationUpdateIntervalMs, }) + const { metadata } = snapshot + const renderContext = useMemo( + () => ({ + ...metadata, + getMergeTagValue: (embeddedEntryNodeTarget, profile) => + sdk.getMergeTagValue(embeddedEntryNodeTarget, profile), + }), + [metadata, sdk], + ) + + useEffect(() => { + if (managedEntry.entry && !hasDuplicateBaselineAncestor && snapshot.isResolved) { + onEntryResolved?.(metadata) + } + }, [ + hasDuplicateBaselineAncestor, + managedEntry.entry, + metadata, + onEntryResolved, + snapshot.isResolved, + ]) if (hasDuplicateBaselineAncestor) { return null } + const dataTestId = dataTestIdProp ?? testId + const Wrapper = as + const frameProps = { + 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, isEmptyVariant, loadingPresentation } = snapshot const { hideLoadingLayoutTarget, @@ -178,8 +350,6 @@ export function OptimizedEntry({ const loadingContent = shouldRenderBaselineWhileLoading ? resolveChildren(children, baselineEntry, renderContext) : resolvedLoadingFallback - const dataTestId = dataTestIdProp ?? testId - const Wrapper = as if (showLoadingFallback) { const LoadingLayoutTarget = Wrapper @@ -189,25 +359,21 @@ export function OptimizedEntry({ ) return ( - - - - {loadingContent} - - - + + + {loadingContent} + + ) } return ( - - - {isEmptyVariant ? null : resolveChildren(children, entry, renderContext)} - - + + {isEmptyVariant ? null : resolveChildren(children, entry, renderContext)} + ) } diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts index b8267b16e..8c6fcea38 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts @@ -1,11 +1,15 @@ -import type { OptimizedEntryLoadingTargetDisplay } from '@contentful/optimization-web/presentation' +import type { + OptimizedEntryLoadingTargetDisplay, + OptimizedEntryMetadata, +} from '@contentful/optimization-web/presentation' import type { Entry } from 'contentful' import type { CSSProperties, ReactNode } from 'react' import type { OptimizationSdk } from '../context/OptimizationContext' export type LoadingFallback = ReactNode | (() => ReactNode) +export type ErrorFallback = ReactNode | ((error: Error) => ReactNode) export type WrapperElement = 'div' | 'span' -export interface OptimizedEntryRenderContext { +export interface OptimizedEntryRenderContext extends OptimizedEntryMetadata { readonly getMergeTagValue: OptimizationSdk['getMergeTagValue'] } export type RenderProp = (resolvedEntry: Entry, context: OptimizedEntryRenderContext) => ReactNode @@ -21,8 +25,15 @@ export function resolveLoadingFallback(loadingFallback: LoadingFallback | undefi return loadingFallback } -export function isRenderProp(children: OptimizedEntryChildren): children is RenderProp { - return typeof children === 'function' +export function resolveErrorFallback( + errorFallback: ErrorFallback | undefined, + error: Error, +): ReactNode { + if (typeof errorFallback === 'function') { + return errorFallback(error) + } + + return errorFallback } export function resolveChildren( @@ -30,7 +41,7 @@ export function resolveChildren( entry: Entry, context: OptimizedEntryRenderContext, ): ReactNode { - if (!isRenderProp(children)) { + if (typeof children !== 'function') { return children } diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx index a3dae16cb..0bd2105e2 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx @@ -1,7 +1,10 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import { getOptimizedEntrySourceKey } from '@contentful/optimization-web/presentation' +import { act, useState } from 'react' import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext' -import type { OptimizationSdk } from '../context/OptimizationContext' +import type { OptimizationContextValue, OptimizationSdk } from '../context/OptimizationContext' import { + createOptimizationSdk, createRuntime, defaultLiveUpdatesContext, createTestEntry as makeEntry, @@ -15,12 +18,14 @@ async function renderHook(params: { liveUpdates?: boolean optimization: OptimizationSdk liveUpdatesContext?: LiveUpdatesContextValue + optimizationContext?: Partial }): Promise<{ getResult: () => UseOptimizedEntryResult; unmount: () => Promise }> { const { baselineEntry, liveUpdates, optimization, liveUpdatesContext = defaultLiveUpdatesContext(), + optimizationContext, } = params let captured: UseOptimizedEntryResult | undefined = undefined @@ -29,7 +34,12 @@ async function renderHook(params: { return null } - const view = await renderWithOptimizationProviders(, optimization, liveUpdatesContext) + const view = await renderWithOptimizationProviders( + , + optimization, + liveUpdatesContext, + optimizationContext, + ) return { getResult() { @@ -59,6 +69,7 @@ describe('useOptimizedEntry', () => { canOptimize: false, selectedOptimizations: undefined, }) + expect(rendered.getResult()).not.toHaveProperty('isReady') await rendered.unmount() }) @@ -76,6 +87,7 @@ describe('useOptimizedEntry', () => { ] const { emit, optimization } = createRuntime((entry, selectedOptimizations) => ({ entry: selectedOptimizations ? variantEntry : entry, + optimizationContextId: selectedOptimizations ? 'ctx-1' : undefined, selectedOptimization: selectedOptimizations?.[0], })) const rendered = await renderHook({ baselineEntry, optimization }) @@ -86,6 +98,16 @@ describe('useOptimizedEntry', () => { entry: variantEntry, selectedOptimization: variantState[0], isLoading: false, + isResolved: true, + metadata: { + baselineEntry, + baselineEntryId: 'baseline', + entry: variantEntry, + entryId: 'variant-a', + optimizationContextId: 'ctx-1', + selectedOptimization: variantState[0], + selectedOptimizations: variantState, + }, canOptimize: true, selectedOptimizations: variantState, }) @@ -191,4 +213,185 @@ describe('useOptimizedEntry', () => { await rendered.unmount() }) + + it('returns updated baselineEntry props during the first render after manual entry changes', async () => { + const firstEntry = makeEntry('baseline') + const secondEntry = makeEntry('updated-baseline') + const optimization = createOptimizationSdk() + const renderedEntryIdsAfterUpdate: string[] = [] + let setBaselineEntry: ((entry: typeof firstEntry) => void) | undefined + + function Probe(): null { + const [baselineEntry, setEntry] = useState(firstEntry) + setBaselineEntry = setEntry + const result = useOptimizedEntry({ baselineEntry }) + if (baselineEntry === secondEntry) { + renderedEntryIdsAfterUpdate.push(result.baselineEntry.sys.id) + } + return null + } + + const view = await renderWithOptimizationProviders(, optimization) + + await act(async () => { + setBaselineEntry?.(secondEntry) + await Promise.resolve() + }) + + expect(renderedEntryIdsAfterUpdate[0]).toBe('updated-baseline') + + await view.unmount() + }) + + it('fetches entryId entries through the SDK', async () => { + const baselineEntry = makeEntry('baseline') + const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(baselineEntry)) + const optimization = createOptimizationSdk({ + fetchContentfulEntry, + }) + let captured: UseOptimizedEntryResult | undefined = undefined + + function Probe(): null { + captured = useOptimizedEntry({ + entryId: 'baseline', + entryQuery: { locale: 'de-DE' }, + }) + return null + } + + function getCaptured(): UseOptimizedEntryResult { + if (!captured) throw new Error('Expected hook result to be captured') + return captured + } + + const view = await renderWithOptimizationProviders(, optimization) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' }) + expect(getCaptured().entry).toBe(baselineEntry) + expect(getCaptured().baselineEntry).toBe(baselineEntry) + expect(getCaptured().error).toBeUndefined() + + await view.unmount() + }) + + it('does not fetch entryId entries while the context is snapshot-backed', async () => { + const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(makeEntry('baseline'))) + const optimization = createOptimizationSdk({ fetchContentfulEntry }) + let captured: UseOptimizedEntryResult | undefined = undefined + + function Probe(): null { + captured = useOptimizedEntry({ entryId: 'baseline' }) + return null + } + + function getCaptured(): UseOptimizedEntryResult { + if (!captured) throw new Error('Expected hook result to be captured') + return captured + } + + const view = await renderWithOptimizationProviders( + , + optimization, + defaultLiveUpdatesContext(), + { isLive: false }, + ) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(fetchContentfulEntry).not.toHaveBeenCalled() + expect(getCaptured()).toMatchObject({ + entry: undefined, + isLoading: true, + }) + + await view.unmount() + }) + + it('uses server handoff entries before fetching new live entry IDs', async () => { + const preloadedEntry = makeEntry('preloaded') + const liveEntry = makeEntry('live-entry') + const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(liveEntry)) + const optimization = createOptimizationSdk({ fetchContentfulEntry }) + let setEntryId: ((entryId: string) => void) | undefined = undefined + let captured: UseOptimizedEntryResult | undefined = undefined + + function Probe(): null { + const [entryId, setCurrentEntryId] = useState('hero') + setEntryId = setCurrentEntryId + captured = useOptimizedEntry({ + entryId, + entryQuery: { locale: 'de-DE' }, + }) + return null + } + + function getCaptured(): UseOptimizedEntryResult { + if (!captured) throw new Error('Expected hook result to be captured') + return captured + } + + const view = await renderWithOptimizationProviders( + , + optimization, + defaultLiveUpdatesContext(), + { + isLive: true, + serverOptimizedEntries: new Map([ + [getOptimizedEntrySourceKey('hero', { locale: 'de-DE' }), preloadedEntry], + ]), + }, + ) + + expect(getCaptured().entry).toBe(preloadedEntry) + expect(fetchContentfulEntry).not.toHaveBeenCalled() + + await act(async () => { + setEntryId?.('other') + await Promise.resolve() + await Promise.resolve() + }) + + expect(fetchContentfulEntry).toHaveBeenCalledWith('other', { locale: 'de-DE' }) + expect(getCaptured().entry).toBe(liveEntry) + + await view.unmount() + }) + + it('surfaces entryId fetch errors', async () => { + const error = new Error('CDA failed') + const onEntryError = rs.fn() + const optimization = createOptimizationSdk({ + fetchContentfulEntry: async () => await Promise.reject(error), + }) + let captured: UseOptimizedEntryResult | undefined = undefined + + function Probe(): null { + captured = useOptimizedEntry({ entryId: 'baseline', onEntryError }) + return null + } + + function getCaptured(): UseOptimizedEntryResult { + if (!captured) throw new Error('Expected hook result to be captured') + return captured + } + + const view = await renderWithOptimizationProviders(, optimization) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(onEntryError).toHaveBeenCalledWith(error) + expect(getCaptured().entry).toBeUndefined() + expect(getCaptured().error).toBe(error) + expect(getCaptured().isLoading).toBe(false) + + await view.unmount() + }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index f26b902b7..f7bd34eec 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -1,42 +1,80 @@ import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import type { ResolvedData } from '@contentful/optimization-web/core-sdk' +import type { ContentfulEntryQuery, ResolvedData } from '@contentful/optimization-web/core-sdk' import { + createOptimizedEntryLoadingEntry, + getOptimizedEntrySourceKey, OptimizedEntryController, + OptimizedEntrySourceController, + type OptimizedEntryMetadata, type OptimizedEntrySnapshot, + type OptimizedEntrySourceSnapshot, } from '@contentful/optimization-web/presentation' import type { Entry, EntrySkeletonType } from 'contentful' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useLiveUpdates } from '../hooks/useLiveUpdates' import { useOptimizationContext } from '../hooks/useOptimization' -export interface UseOptimizedEntryParams { - /** Baseline Contentful entry fetched by the application. */ - baselineEntry: Entry +export type UseOptimizedEntryParams = { /** Per-entry live-update override. */ liveUpdates?: boolean + /** Callback invoked when SDK-managed entry fetching fails. */ + onEntryError?: (error: Error) => void +} & ( + | { + /** Baseline Contentful entry fetched by the application. */ + baselineEntry: Entry + entryId?: never + entryQuery?: never + } + | { + baselineEntry?: never + /** Contentful entry ID fetched through the SDK-managed Contentful client. */ + entryId: string + /** Per-call Contentful `getEntry()` query overrides. */ + entryQuery?: ContentfulEntryQuery + } +) + +interface UseManagedBaselineEntryResult { + readonly entry: Entry | undefined + readonly error: Error | undefined + readonly isLoading: boolean } -export interface UseOptimizedEntryResult { +type UseOptimizedEntryBaselineParams = Extract +type UseOptimizedEntryManagedParams = Extract + +export interface UseOptimizedEntryResult { /** Whether SDK state says optimized content can be selected. */ canOptimize: boolean + /** Baseline entry used for resolution, or `undefined` while managed fetching is unresolved. */ + baselineEntry: TEntry /** Entry that should be rendered for the current hook state. */ - entry: Entry - /** Whether the optimized entry is still waiting for optimization state. */ + entry: TEntry + /** Error from SDK-managed entry fetching, when one occurred. */ + error: Error | undefined + /** Whether the optimized entry is still waiting for content or optimization state. */ isLoading: boolean /** Whether the client presentation layer is ready to reveal rendered content. */ isPresentationReady: boolean - /** Selected optimization that resolved the current entry, when one applied. */ - selectedOptimization: ResolvedData['selectedOptimization'] + /** Whether the current entry has been resolved and metadata can be consumed. */ + isResolved: boolean + /** Baseline, resolved-entry, and optimization metadata for render surfaces. */ + metadata: OptimizedEntryMetadata | undefined /** Full resolved entry data returned by the SDK resolver. */ resolvedData: ResolvedData + /** Selected optimization that resolved the current entry, when one applied. */ + selectedOptimization: ResolvedData['selectedOptimization'] /** Selected optimization array used for this hook state. */ selectedOptimizations: SelectedOptimizationArray | undefined } -export interface UseOptimizedEntrySnapshotParams extends UseOptimizedEntryParams { +export interface UseOptimizedEntrySnapshotParams { + baselineEntry: Entry clickable?: boolean hasCustomLoadingFallback?: boolean hoverDurationUpdateIntervalMs?: number + liveUpdates?: boolean targetDisplay?: 'block' | 'inline' trackClicks?: boolean trackHovers?: boolean @@ -44,6 +82,75 @@ export interface UseOptimizedEntrySnapshotParams extends UseOptimizedEntryParams viewDurationUpdateIntervalMs?: number } +export function useManagedBaselineEntry({ + baselineEntry, + entryId, + entryQuery, + onEntryError, +}: UseOptimizedEntryParams): UseManagedBaselineEntryResult { + const optimizationContext = useOptimizationContext() + const { sdk, serverOptimizedEntries } = optimizationContext + const isSdkLive = optimizationContext.isLive ?? sdk !== undefined + const entrySourceKey = + entryId === undefined ? undefined : getOptimizedEntrySourceKey(entryId, entryQuery) + const handoffEntry = + entrySourceKey === undefined ? undefined : serverOptimizedEntries?.get(entrySourceKey) + const effectiveBaselineEntry = baselineEntry ?? handoffEntry + const [controller] = useState(() => new OptimizedEntrySourceController()) + const [snapshot, setSnapshot] = useState(() => { + if (effectiveBaselineEntry !== undefined) { + return { baselineEntry: effectiveBaselineEntry, isLoading: false } + } + + return { entryId, isLoading: true } + }) + const reportedErrorRef = useRef(undefined) + + useEffect(() => { + controller.setSnapshotListener(setSnapshot) + + return () => { + controller.setSnapshotListener(undefined) + controller.disconnect() + } + }, [controller]) + + useEffect(() => { + controller.updateOptions({ + baselineEntry: effectiveBaselineEntry, + entryId, + entryQuery, + sdk, + isSdkStateReady: isSdkLive, + }) + }, [controller, effectiveBaselineEntry, entryId, entryQuery, entrySourceKey, isSdkLive, sdk]) + + useEffect(() => { + const { error } = snapshot + if (error === undefined) { + reportedErrorRef.current = undefined + return + } + + if (reportedErrorRef.current === error) { + return + } + + reportedErrorRef.current = error + onEntryError?.(error) + }, [onEntryError, snapshot]) + + if (effectiveBaselineEntry !== undefined) { + return { entry: effectiveBaselineEntry, error: undefined, isLoading: false } + } + + return { + entry: snapshot.baselineEntry, + error: snapshot.error, + isLoading: snapshot.isLoading, + } +} + /** * Return the low-level optimized-entry presentation snapshot for a baseline entry. * @@ -133,16 +240,36 @@ export function useOptimizedEntrySnapshot({ * * @public */ +export function useOptimizedEntry( + params: UseOptimizedEntryBaselineParams, +): UseOptimizedEntryResult +export function useOptimizedEntry(params: UseOptimizedEntryManagedParams): UseOptimizedEntryResult export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult { - const snapshot = useOptimizedEntrySnapshot(params) + const managedEntry = useManagedBaselineEntry(params) + const loadingEntryId = (params as { readonly entryId?: string }).entryId ?? 'contentful-entry' + const loadingEntry = useMemo( + () => createOptimizedEntryLoadingEntry(loadingEntryId), + [loadingEntryId], + ) + const baselineEntry = managedEntry.entry ?? loadingEntry + const snapshot = useOptimizedEntrySnapshot({ + baselineEntry, + liveUpdates: params.liveUpdates, + }) + const hasEntry = managedEntry.entry !== undefined + const isPresentationReady = hasEntry && snapshot.isPresentationReady return { canOptimize: snapshot.canOptimize, - entry: snapshot.entry, - isLoading: snapshot.isLoading, - isPresentationReady: snapshot.isPresentationReady, - selectedOptimization: snapshot.selectedOptimization, + baselineEntry: managedEntry.entry, + entry: hasEntry ? snapshot.entry : undefined, + error: managedEntry.error, + isLoading: managedEntry.isLoading || snapshot.isLoading, + isPresentationReady, + isResolved: hasEntry && snapshot.isResolved, + metadata: hasEntry ? snapshot.metadata : undefined, resolvedData: snapshot.resolvedData, - selectedOptimizations: snapshot.selectedOptimizations, + selectedOptimization: hasEntry ? snapshot.selectedOptimization : undefined, + selectedOptimizations: hasEntry ? snapshot.selectedOptimizations : undefined, } } diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index 2a700e8fd..5b101947f 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -531,10 +531,7 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(capturedContext).toEqual({ - sdk: undefined, - error, - }) + expect(capturedContext).toEqual(expect.objectContaining({ sdk: undefined, error })) expect(destroySpy).toHaveBeenCalledTimes(1) expect(window.contentfulOptimization).toBeUndefined() destroySpy.mockRestore() diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index e03406f70..f36b5576d 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -5,6 +5,7 @@ import { DEFAULT_WEB_ALLOWED_EVENT_TYPES } from '@contentful/optimization-web/co import { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, + getOptimizedEntrySourceKey, type OptimizationRootSdkBinding, type OptimizationRootSdkConfig, type OnStatesReady as SharedOnStatesReady, @@ -24,6 +25,7 @@ import { type WebOptimizationRuntime, } from '@contentful/optimization-web/runtime' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import type { ServerOptimizedEntryHandoff } from '../server-optimized-entries' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -51,6 +53,10 @@ interface ServerOptimizationStateProps { * state such as consent policy. */ readonly serverOptimizationState?: OptimizationData + /** + * Server-fetched baseline entries for SDK-managed OptimizedEntry hydration. + */ + readonly serverOptimizedEntries?: readonly ServerOptimizedEntryHandoff[] } export type OptimizationProviderConfigProps = PropsWithChildren< @@ -103,6 +109,7 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider onStatesReady: _onStatesReady, sdk: _sdk, serverOptimizationState: _serverOptimizationState, + serverOptimizedEntries: _serverOptimizedEntries, trackEntryInteraction, ...config } = props @@ -200,6 +207,19 @@ function createInitialRuntime(props: OptimizationProviderProps): WebOptimization }) } +function createServerOptimizedEntries( + entries: readonly ServerOptimizedEntryHandoff[] | undefined, +): ReadonlyMap | undefined { + if (entries === undefined) return undefined + + const map = new Map() + for (const { baselineEntry, entryId, entryQuery } of entries) { + map.set(getOptimizedEntrySourceKey(entryId, entryQuery), baselineEntry) + } + + return map +} + export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { const { children } = props const initialPropsRef = useRef(props) @@ -209,6 +229,10 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle isLive: injectedSdkBacksInitialRender(props), runtime: createInitialRuntime(props), })) + const serverOptimizedEntries = useMemo( + () => createServerOptimizedEntries(props.serverOptimizedEntries), + [props.serverOptimizedEntries], + ) useLayoutEffect(() => { const { current: initialProps } = initialPropsRef @@ -285,8 +309,13 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle }, [liveLocale, props.sdk, state.isLive, state.runtime]) const contextValue = useMemo( - () => ({ sdk: state.runtime, error: state.error }), - [state.runtime, state.error], + () => ({ + sdk: state.runtime, + error: state.error, + isLive: state.isLive, + serverOptimizedEntries, + }), + [state.runtime, state.error, state.isLive, serverOptimizedEntries], ) return ( diff --git a/packages/web/frameworks/react-web-sdk/src/server-optimized-entries.ts b/packages/web/frameworks/react-web-sdk/src/server-optimized-entries.ts new file mode 100644 index 000000000..e6459cc8d --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/server-optimized-entries.ts @@ -0,0 +1,6 @@ +export { + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type OptimizedEntryPrefetchRuntime, + type ServerOptimizedEntryHandoff, +} from '@contentful/optimization-web/presentation' diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index ecae52986..2437a165f 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -1,6 +1,7 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' import type { + ContentfulEntryQuery, EventEmissionResult, ExperienceRequestState, ResolvedData, @@ -42,6 +43,7 @@ export type RuntimeOptimization = OptimizationSdk export type OptimizationSdkOverrides = Omit< Partial, | 'identify' + | 'fetchContentfulEntry' | 'page' | 'resolveOptimizedEntry' | 'screen' @@ -50,6 +52,7 @@ export type OptimizationSdkOverrides = Omit< | 'tracking' | 'trackView' > & { + fetchContentfulEntry?: (entryId: string, query?: ContentfulEntryQuery) => Promise identify?: EventMethodOverride page?: EventMethodOverride resolveOptimizedEntry?: ResolveOptimizedEntry @@ -222,6 +225,8 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): flush: async () => { await Promise.resolve() }, + fetchContentfulEntry: async (entryId: string) => + await Promise.resolve(createTestEntry(entryId)), getFlag: () => undefined, getMergeTagValue: () => undefined, hasConsent, @@ -453,6 +458,7 @@ export async function renderWithOptimizationProviders( node: ReactNode, optimization: OptimizationSdk, liveUpdatesContext = defaultLiveUpdatesContext(), + optimizationContext: Partial = {}, ): Promise<{ container: HTMLDivElement; unmount: () => Promise }> { const container = document.createElement('div') document.body.appendChild(container) @@ -461,7 +467,9 @@ export async function renderWithOptimizationProviders( await act(async () => { await Promise.resolve() root.render( - + {node} , ) @@ -483,9 +491,12 @@ export function renderWithOptimizationProvidersToString( node: ReactNode, optimization: OptimizationSdk, liveUpdatesContext = defaultLiveUpdatesContext(), + optimizationContext: Partial = {}, ): string { return renderToString( - + {node} , ) diff --git a/packages/web/web-sdk/README.md b/packages/web/web-sdk/README.md index 637d6ac98..55386edb8 100644 --- a/packages/web/web-sdk/README.md +++ b/packages/web/web-sdk/README.md @@ -59,6 +59,9 @@ Install using an NPM-compatible package manager, pnpm for example: pnpm install @contentful/optimization-web ``` +Add `contentful` too when the SDK will use your app-owned `contentful.js` client for managed entry +fetching. + Import the Optimization class; both CJS and ESM module systems are supported, ESM preferred: ```ts @@ -140,15 +143,22 @@ root.onStatesReady = (states) => { entry.baselineEntry = baselineEntry entry.addEventListener('ctfl-entry-resolved', (event) => { - renderHero(event.detail.entry) + renderHero(event.detail.entry, event.detail.metadata) }) ``` -`baselineEntry` is property-only because Contentful entries are structured objects. For framework -wrappers, assign `baselineEntry`, `sdk`, `defaults`, `api`, and callback properties after client -hydration, then listen for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to -render framework-owned UI. The custom element intentionally does not provide a framework-neutral -render-prop API. +`baselineEntry` is property-only because Contentful entries are structured objects. When the Web SDK +is configured with `contentful: { client }`, `ctfl-optimized-entry` can also fetch by +`entry-id`/`entryId`; `baselineEntry` takes precedence when both are set. Set the `entryQuery` +property for per-entry CDA query overrides. For framework wrappers, assign `baselineEntry`, +`entryQuery`, `sdk`, `defaults`, `api`, and callback properties after client hydration, then listen +for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to render framework-owned +UI. The custom element intentionally does not provide a framework-neutral render-prop API. Framework +wrappers that render without the custom element but still use Web presentation helpers can import +`OptimizedEntrySourceController` and `createOptimizedEntryLoadingEntry` from +`@contentful/optimization-web/presentation` to share the same managed entry-source lifecycle. +Core-only or non-Web custom runtime adapters can import those entry-source primitives directly from +`@contentful/optimization-core/entry-source` without depending on the Web SDK. For script-tag usage, load the main Web SDK UMD bundle and the separate Web Components UMD bundle: @@ -187,6 +197,7 @@ the Insights API for event ingestion. | `environment` | No | `'main'` | Contentful environment identifier | | `api` | No | See API options below | Experience API and Insights API endpoint and request options | | `app` | No | `undefined` | Application metadata attached to outgoing event context | +| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache | | `locale` | No | `undefined` | SDK Experience API and default event locale | | `defaults` | No | `undefined` | Initial state, commonly including consent, persistence consent, or profile values | | `allowedEventTypes` | No | `['identify', 'page']` | Event types intentionally allowed while consent is unset or false | @@ -273,8 +284,23 @@ not have data yet. Router integrations that need current-route deduplication can ### Content resolution -Fetch Contentful entries in your application layer, then use the SDK to resolve the selected -variant: +When a `contentful.js` client is available, prefer SDK-managed fetching by entry ID: + +```ts +const optimization = new ContentfulOptimization({ + clientId: 'client-id', + contentful: { client: contentfulClient }, + environment: 'main', + locale: appLocale, +}) + +const { baselineEntry, entry } = await optimization.fetchOptimizedEntry('hero-entry') +``` + +`fetchOptimizedEntry(entryId)` fetches the baseline entry and resolves it with the Web SDK's current +`selectedOptimizations` when omitted. `fetchContentfulEntry()` only performs the managed CDA fetch. + +If your application already fetched the baseline entry, keep using the manual resolver: ```ts const { accepted, data: optimizationData } = await optimization.page({ @@ -286,10 +312,10 @@ const resolvedEntry = optimization.resolveOptimizedEntry( ) ``` -Fetch entries with one CDA locale in the app layer. For localized apps, configure your application -locale and pass it directly before calling `getEntry()` or `getEntries()`. Do not pass all-locale -CDA responses from `withAllLocales` or `locale=*`; the resolver expects direct single-locale field -values. See +Use one CDA locale in either path. For localized apps, configure your application locale and pass it +directly before calling `getEntry()`, `getEntries()`, or SDK-managed entry fetches. Do not pass +all-locale CDA responses from `withAllLocales` or `locale=*`; the resolver expects direct +single-locale field values. See [Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract) for the entry contract and [Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html) diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 79087cb89..7413a6b73 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -137,7 +137,7 @@ "bridge-support.mjs": 1200, "runtime.cjs": 900, "runtime.mjs": 320, - "contentful-optimization-web-components.umd.js": 39000, + "contentful-optimization-web-components.umd.js": 39010, "presentation.cjs": 3200, "presentation.mjs": 3200, "tracking-attributes.cjs": 1200, diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index a2aa2afa1..7e6d7110f 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -251,7 +251,7 @@ class ContentfulOptimization extends CoreStateful { * * @internal */ - private readonly cookieAttributes?: CookieAttributes = undefined + private readonly cookieAttributes?: CookieAttributes /** * Cleanup function for online/offline listener bindings. diff --git a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts index b0532f2de..01d82f966 100644 --- a/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts +++ b/packages/web/web-sdk/src/entry-tracking/EntryInteractionRuntime.ts @@ -164,7 +164,7 @@ export class EntryInteractionRuntime { private readonly entryElements = new Map() private readonly autoTrack: Record public readonly tracking: EntryInteractionApi - private entryElementObserver: MutationObserver | undefined = undefined + private entryElementObserver: MutationObserver | undefined private readonly elementOverrides: EntryInteractionElementOverrideMap = { clicks: new Map(), views: new Map(), diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts index 7dd3e90ec..fa95ea442 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts @@ -108,6 +108,8 @@ function createSdk( experienceRequestState, optimizationPossible, sdk: { + fetchContentfulEntry: async (entryId: string) => + await Promise.resolve(createTestEntry(entryId)), resolveOptimizedEntry, states: { canOptimize, @@ -187,21 +189,40 @@ describe('OptimizedEntryController', () => { controller.connect() - expect(controller.getSnapshot().hostAttributes).toMatchObject({ - 'data-ctfl-entry-id': 'baseline', - 'data-ctfl-track-clicks': true, - 'data-ctfl-track-hovers': false, - 'data-ctfl-variant-index': 0, + expect(controller.getSnapshot()).toMatchObject({ + entry: baseline, + isResolved: true, + metadata: { + baselineEntry: baseline, + baselineEntryId: 'baseline', + entry: baseline, + entryId: 'baseline', + }, + hostAttributes: { + 'data-ctfl-entry-id': 'baseline', + 'data-ctfl-track-clicks': true, + 'data-ctfl-track-hovers': false, + 'data-ctfl-variant-index': 0, + }, }) runtime.selectedOptimizations.emit(variantOneState) - expect(controller.getSnapshot().hostAttributes).toMatchObject({ - 'data-ctfl-entry-id': 'variant-a', - 'data-ctfl-optimization-context-id': 'ctx-1', - 'data-ctfl-optimization-id': 'exp-hero', - 'data-ctfl-sticky': true, - 'data-ctfl-variant-index': 1, + expect(controller.getSnapshot()).toMatchObject({ + metadata: { + baselineEntry: baseline, + entry: variantA, + optimizationContextId: 'ctx-1', + selectedOptimization: variantOneState[0], + selectedOptimizations: variantOneState, + }, + hostAttributes: { + 'data-ctfl-entry-id': 'variant-a', + 'data-ctfl-optimization-context-id': 'ctx-1', + 'data-ctfl-optimization-id': 'exp-hero', + 'data-ctfl-sticky': true, + 'data-ctfl-variant-index': 1, + }, }) controller.disconnect() diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index bc9df9f02..8091414db 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -1,4 +1,10 @@ -import type { Observable, ResolvedData, Subscription } from '@contentful/optimization-core' +import type { + ContentfulEntryQuery, + Observable, + OptimizedEntryMetadata, + ResolvedData, + Subscription, +} from '@contentful/optimization-core' import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas' import type { Entry, EntrySkeletonType } from 'contentful' import { @@ -44,6 +50,7 @@ export interface OptimizedEntrySdk { entry: Entry, selectedOptimizations?: SelectedOptimizationArray, ) => ResolvedData + fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise } /** @@ -68,6 +75,8 @@ export interface OptimizedEntrySnapshot { readonly isLoading: boolean /** Whether the client presentation layer is ready to reveal rendered content. */ readonly isPresentationReady: boolean + /** Whether the current entry has been resolved and can be exposed to render callbacks. */ + readonly isResolved: boolean /** Loading and fallback rendering decisions for wrappers around the entry. */ readonly loadingPresentation: { readonly showLoadingFallback: boolean @@ -75,6 +84,8 @@ export interface OptimizedEntrySnapshot { readonly shouldRenderBaselineWhileLoading: boolean readonly targetDisplay: OptimizedEntryLoadingTargetDisplay } + /** Baseline, resolved-entry, and optimization metadata for render surfaces. */ + readonly metadata: OptimizedEntryMetadata /** Full resolved entry data returned by the SDK resolver. */ readonly resolvedData: ResolvedData /** Selected optimization that resolved the current entry, when one applied. */ @@ -265,15 +276,36 @@ function areLoadingPresentationsEqual( ) } -function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySnapshot): boolean { +function areSnapshotMetadataEqual( + left: OptimizedEntrySnapshot['metadata'], + right: OptimizedEntrySnapshot['metadata'], +): boolean { + return ( + left.baselineEntry === right.baselineEntry && + left.optimizationContextId === right.optimizationContextId + ) +} + +function areSnapshotValuesEqual( + left: OptimizedEntrySnapshot, + right: OptimizedEntrySnapshot, +): boolean { 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 && left.selectedOptimization === right.selectedOptimization && - left.selectedOptimizations === right.selectedOptimizations && + left.selectedOptimizations === right.selectedOptimizations + ) +} + +function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySnapshot): boolean { + return ( + areSnapshotValuesEqual(left, right) && + areSnapshotMetadataEqual(left.metadata, right.metadata) && areLoadingPresentationsEqual(left.loadingPresentation, right.loadingPresentation) && areHostAttributesEqual(left.hostAttributes, right.hostAttributes) ) @@ -290,11 +322,11 @@ export class OptimizedEntryController { private connected = false private hasExperienceRequestSettled = false private optimizationPossible = true - private listener: OptimizedEntrySnapshotListener | undefined = undefined - private baselineRevealTimeout: ReturnType | undefined = undefined + private listener: OptimizedEntrySnapshotListener | undefined + private baselineRevealTimeout: ReturnType | undefined private options: NormalizedOptimizedEntryControllerOptions private hasBaselineRevealTimedOut = false - private selectedOptimizations: SelectedOptimizationArray | undefined = undefined + private selectedOptimizations: SelectedOptimizationArray | undefined private snapshot: OptimizedEntrySnapshot private subscriptions: Subscription[] = [] @@ -479,10 +511,21 @@ export class OptimizedEntryController { this.selectedOptimizations, ) : createBaselineResolvedData(this.options.baselineEntry) + const metadata: OptimizedEntryMetadata = { + baselineEntry: this.options.baselineEntry, + baselineEntryId: this.options.baselineEntry.sys.id, + entry: resolvedData.entry, + entryId: resolvedData.entry.sys.id, + optimizationContextId: resolvedData.optimizationContextId, + resolvedData, + selectedOptimization: resolvedData.selectedOptimization, + selectedOptimizations: this.selectedOptimizations, + } + const isResolved = !showLoadingFallback return { canOptimize: this.canOptimize, - entry: resolvedData.entry, + entry: metadata.entry, hostAttributes: showLoadingFallback ? {} : resolveOptimizedEntryTrackingAttributes( @@ -493,14 +536,16 @@ export class OptimizedEntryController { isEmptyVariant: resolvedData.isEmptyVariant === true, isLoading, isPresentationReady: this.options.isPresentationReady, + isResolved, loadingPresentation: { showLoadingFallback, hideLoadingLayoutTarget, shouldRenderBaselineWhileLoading, targetDisplay: this.options.targetDisplay, }, + metadata, resolvedData, - selectedOptimization: resolvedData.selectedOptimization, + selectedOptimization: metadata.selectedOptimization, selectedOptimizations: this.selectedOptimizations, } } diff --git a/packages/web/web-sdk/src/presentation/index.ts b/packages/web/web-sdk/src/presentation/index.ts index ff0aa763a..4a15173bb 100644 --- a/packages/web/web-sdk/src/presentation/index.ts +++ b/packages/web/web-sdk/src/presentation/index.ts @@ -4,6 +4,19 @@ * @packageDocumentation */ +export type { OptimizedEntryMetadata } from '@contentful/optimization-core' +export { + OptimizedEntrySourceController, + createOptimizedEntryLoadingEntry, + getOptimizedEntrySourceKey, + prefetchOptimizedEntries, + type OptimizedEntryPrefetchDescriptor, + type OptimizedEntryPrefetchRuntime, + type OptimizedEntrySourceControllerOptions, + type OptimizedEntrySourceSnapshot, + type OptimizedEntrySourceSnapshotListener, + type ServerOptimizedEntryHandoff, +} from '@contentful/optimization-core/entry-source' export { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, diff --git a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts index 6f4a8284a..5d8c4aa7a 100644 --- a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts +++ b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts @@ -32,7 +32,9 @@ export type OptimizationRootSdkConfig = Omit> { + Partial< + Omit + > { /** SDK states required by optimized entries and preview-panel-aware roots. */ readonly states: OptimizedEntrySdk['states'] & { readonly previewPanelOpen: Observable diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index c16273c63..ad5d90771 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -4,8 +4,9 @@ * Re-exports the universal {@link OptimizationRuntime} / {@link createSnapshotRuntime} from * `@contentful/optimization-core/runtime` and adds the web-only composition * ({@link WebOptimizationRuntime} / {@link createWebSnapshotRuntime}) that stitches in - * browser-only imperative APIs (`tracking`, `trackCurrentPage`) so framework layers can - * bind a single runtime object across server rendering and browser hydration. + * browser-only imperative APIs (`tracking`, `trackCurrentPage`) and managed entry fetch methods + * so framework layers can bind a single runtime object across server rendering and browser + * hydration. * * @packageDocumentation */ @@ -27,6 +28,7 @@ export * from '@contentful/optimization-core/runtime' * @internal */ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' +type ManagedEntryFetchMembers = 'fetchContentfulEntry' | 'fetchOptimizedEntry' /** * The single runtime object framework layers (React Web, Angular, etc.) can consume. @@ -36,7 +38,8 @@ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' * actions — safe on server and client) with the browser-only web SDK surface * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} instance * satisfies it by construction; a server / initial-render backing is produced by - * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops. + * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops and managed + * entry fetch methods reject until a live SDK is available. * * Every member is safe to reference in any environment: render-time members behave * correctly on the server, and effect-only members are no-ops there (the server never @@ -45,22 +48,32 @@ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' * @public */ export interface WebOptimizationRuntime - extends OptimizationRuntime, Pick {} + extends + OptimizationRuntime, + Pick {} const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { - enable: () => undefined, - disable: () => undefined, - enableElement: () => undefined, - disableElement: () => undefined, - clearElement: () => undefined, + enable: noop, + disable: noop, + enableElement: noop, + disableElement: noop, + clearElement: noop, +} + +function noop(): undefined { + return undefined +} + +async function rejectSnapshotManagedEntryFetch(): Promise { + return await Promise.reject(new Error('Live SDK needed')) } /** * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. * * @param snapshot - Server-resolved optimization state for the current request. - * @returns A runtime that resolves and reads from the snapshot, with browser-only - * tracking APIs as inert no-ops. + * @returns A runtime that resolves and reads from the snapshot, with browser-only tracking APIs as + * inert no-ops and managed entry fetch methods rejected. * * @remarks * Used by framework providers during server rendering and the initial client render, @@ -73,6 +86,8 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp const runtime = createSnapshotRuntime(snapshot) return Object.assign(runtime, { + fetchContentfulEntry: rejectSnapshotManagedEntryFetch, + fetchOptimizedEntry: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) diff --git a/packages/web/web-sdk/src/web-components/ContentfulOptimizationRootElement.ts b/packages/web/web-sdk/src/web-components/ContentfulOptimizationRootElement.ts index d22e93e27..8e5e812e2 100644 --- a/packages/web/web-sdk/src/web-components/ContentfulOptimizationRootElement.ts +++ b/packages/web/web-sdk/src/web-components/ContentfulOptimizationRootElement.ts @@ -52,7 +52,7 @@ export class ContentfulOptimizationRootElement extends HTMLElement { return ['client-id', 'environment', 'live-updates', 'locale'] } - private apiOptions: OptimizationRootSdkConfig['api'] | undefined = undefined + private apiOptions: OptimizationRootSdkConfig['api'] | undefined private context: ContentfulOptimizationRootContext = { error: undefined, rootLiveUpdatesEnabled: false, @@ -60,25 +60,20 @@ export class ContentfulOptimizationRootElement extends HTMLElement { isPreviewPanelOpen: false, sdk: undefined, } - private defaultOptions: OptimizationRootSdkConfig['defaults'] | undefined = undefined - private onStatesReadyHandler: OnStatesReady | undefined = undefined - private previewPanelOpenSubscription: { unsubscribe: () => void } | undefined = undefined - private sdkBinding: OptimizationRootSdkBinding | undefined = undefined - private assignedSdk: ContentfulOptimization | undefined = undefined + private defaultOptions: OptimizationRootSdkConfig['defaults'] | undefined + private onStatesReadyHandler: OnStatesReady | undefined + private previewPanelOpenSubscription: { unsubscribe: () => void } | undefined + private sdkBinding: OptimizationRootSdkBinding | undefined + private assignedSdk: ContentfulOptimization | undefined private readonly subscribers = new Set() - private trackEntryInteractionOptions: TrackEntryInteractionOptions | undefined = undefined + private trackEntryInteractionOptions: TrackEntryInteractionOptions | undefined get clientId(): string | undefined { return this.getAttribute('client-id') ?? undefined } set clientId(value: string | undefined) { - if (value === undefined) { - this.removeAttribute('client-id') - return - } - - this.setAttribute('client-id', value) + this.setOptionalAttribute('client-id', value) } get environment(): string | undefined { @@ -86,12 +81,7 @@ export class ContentfulOptimizationRootElement extends HTMLElement { } set environment(value: string | undefined) { - if (value === undefined) { - this.removeAttribute('environment') - return - } - - this.setAttribute('environment', value) + this.setOptionalAttribute('environment', value) } get locale(): string | undefined { @@ -99,12 +89,7 @@ export class ContentfulOptimizationRootElement extends HTMLElement { } set locale(value: string | undefined) { - if (value === undefined) { - this.removeAttribute('locale') - return - } - - this.setAttribute('locale', value) + this.setOptionalAttribute('locale', value) } get liveUpdates(): boolean { @@ -165,6 +150,15 @@ export class ContentfulOptimizationRootElement extends HTMLElement { this.reinitializeIfConnected() } + private setOptionalAttribute(name: string, value: string | undefined): void { + if (value === undefined) { + this.removeAttribute(name) + return + } + + this.setAttribute(name, value) + } + connectedCallback(): void { this.style.display ||= OPTIMIZED_ENTRY_HOST_DISPLAY @@ -253,13 +247,7 @@ export class ContentfulOptimizationRootElement extends HTMLElement { isPreviewPanelOpen: false, sdk: undefined, }) - this.dispatchEvent( - new CustomEvent(ROOT_ERROR_EVENT, { - bubbles: true, - composed: true, - detail: { error: normalizedError }, - }), - ) + this.dispatchRootError(normalizedError) } } @@ -302,13 +290,7 @@ export class ContentfulOptimizationRootElement extends HTMLElement { } catch (error: unknown) { const normalizedError = toError(error) this.publishContext({ error: normalizedError }) - this.dispatchEvent( - new CustomEvent(ROOT_ERROR_EVENT, { - bubbles: true, - composed: true, - detail: { error: normalizedError }, - }), - ) + this.dispatchRootError(normalizedError) } } @@ -325,6 +307,16 @@ export class ContentfulOptimizationRootElement extends HTMLElement { }) } + private dispatchRootError(error: Error): void { + this.dispatchEvent( + new CustomEvent(ROOT_ERROR_EVENT, { + bubbles: true, + composed: true, + detail: { error }, + }), + ) + } + private publishContext(nextContext: Partial): void { this.context = { ...this.context, diff --git a/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts b/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts index 958e19c55..44cf673d2 100644 --- a/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts +++ b/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts @@ -1,5 +1,10 @@ import type { ResolvedData } from '@contentful/optimization-core' import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas' +import { + OptimizedEntrySourceController, + type ContentfulEntryQuery, + type OptimizedEntrySourceSnapshot, +} from '@contentful/optimization-core/entry-source' import type { Entry, EntrySkeletonType } from 'contentful' import { OPTIMIZED_ENTRY_HOST_DISPLAY, @@ -21,6 +26,7 @@ type HostAttributeValue = string | boolean | number | undefined export interface ContentfulOptimizedEntryEventDetail { readonly entry: Entry + readonly metadata: OptimizedEntrySnapshot['metadata'] readonly resolvedData: ResolvedData readonly selectedOptimization: ResolvedData['selectedOptimization'] readonly selectedOptimizations: SelectedOptimizationArray | undefined @@ -68,17 +74,20 @@ function hasResolvedDataChanged( export class ContentfulOptimizedEntryElement extends HTMLElement { static get observedAttributes(): string[] { - return ['live-updates', 'track-clicks', 'track-hovers', 'track-views'] + return ['entry-id', 'live-updates', 'track-clicks', 'track-hovers', 'track-views'] } - private explicitRoot: ContentfulOptimizationRootElement | undefined = undefined - private assignedBaselineEntry: Entry | undefined = undefined - private controller: OptimizedEntryController | undefined = undefined + private explicitRoot: ContentfulOptimizationRootElement | undefined + private assignedBaselineEntry: Entry | undefined + private assignedEntryQuery: ContentfulEntryQuery | undefined + private controller: OptimizedEntryController | undefined + private readonly sourceController = new OptimizedEntrySourceController() private appliedHostAttributes = new Map() - private previousSnapshot: OptimizedEntrySnapshot | undefined = undefined - private optimizationRootContext: ContentfulOptimizationRootContext | undefined = undefined - private unsubscribeFromRootContext: (() => void) | undefined = undefined - private assignedSdk: OptimizedEntrySdk | undefined = undefined + private previousSnapshot: OptimizedEntrySnapshot | undefined + private previousSourceSnapshot: OptimizedEntrySourceSnapshot | undefined + private optimizationRootContext: ContentfulOptimizationRootContext | undefined + private unsubscribeFromRootContext: (() => void) | undefined + private assignedSdk: OptimizedEntrySdk | undefined get baselineEntry(): Entry | undefined { return this.assignedBaselineEntry @@ -89,6 +98,28 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { this.syncEntryController() } + get entryId(): string | undefined { + return this.getAttribute('entry-id') ?? undefined + } + + set entryId(value: string | undefined) { + if (value === undefined) { + this.removeAttribute('entry-id') + return + } + + this.setAttribute('entry-id', value) + } + + get entryQuery(): ContentfulEntryQuery | undefined { + return this.assignedEntryQuery + } + + set entryQuery(value: ContentfulEntryQuery | undefined) { + this.assignedEntryQuery = value + this.syncEntryController() + } + get sdk(): OptimizedEntrySdk | undefined { return this.assignedSdk } @@ -143,11 +174,16 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { connectedCallback(): void { this.style.display ||= OPTIMIZED_ENTRY_HOST_DISPLAY + this.sourceController.setSnapshotListener((snapshot) => { + this.applySourceSnapshot(snapshot) + }) this.bindRoot() this.syncEntryController() } disconnectedCallback(): void { + this.sourceController.disconnect() + this.sourceController.setSnapshotListener(undefined) this.unsubscribeFromRootContext?.() this.unsubscribeFromRootContext = undefined this.controller?.disconnect() @@ -155,6 +191,7 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { this.controller = undefined this.optimizationRootContext = undefined this.previousSnapshot = undefined + this.previousSourceSnapshot = undefined } attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void { @@ -211,25 +248,48 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { } private syncEntryController(): void { - const { assignedBaselineEntry: baselineEntry } = this + const { sdk, isSdkStateReady } = this.resolveControllerSdk() + const previousSourceSnapshot = this.sourceController.getSnapshot() + const { entryId } = this - if (!baselineEntry) { - this.clearController() - this.resetPresentationState() - return - } + this.sourceController.updateOptions({ + baselineEntry: this.assignedBaselineEntry, + entryId: entryId === '' ? undefined : entryId, + entryQuery: this.assignedEntryQuery, + sdk, + isSdkStateReady, + }) + const nextSourceSnapshot = this.sourceController.getSnapshot() + this.applySourceSnapshot(nextSourceSnapshot, previousSourceSnapshot === nextSourceSnapshot) + } + + private syncBaselineEntryController(baselineEntry: Entry): void { const { sdk, isSdkStateReady } = this.resolveControllerSdk() if (!sdk || !isSdkStateReady) { - this.handleMissingControllerSdk() + this.clearController() + this.resetPresentationState() + + if (this.optimizationRootContext?.error) { + this.dispatchEntryError(this.optimizationRootContext.error) + } return } try { - const controller = this.updateOrCreateController( - this.createControllerOptions({ baselineEntry, sdk, isSdkStateReady }), - ) + const controllerOptions = this.createControllerOptions(baselineEntry, sdk, isSdkStateReady) + let { controller } = this + if (controller === undefined) { + controller = new OptimizedEntryController(controllerOptions) + this.controller = controller + controller.setSnapshotListener((snapshot) => { + this.applySnapshot(snapshot) + }) + controller.connect() + } else { + controller.updateOptions(controllerOptions) + } this.applySnapshot(controller.getSnapshot()) } catch (error: unknown) { this.dispatchEntryError(toError(error)) @@ -253,24 +313,66 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { } } - private handleMissingControllerSdk(): void { + private applySourceSnapshot( + snapshot: OptimizedEntrySourceSnapshot, + forcePresentationUpdate = false, + ): void { + const { previousSourceSnapshot } = this + const sourceSnapshotChanged = previousSourceSnapshot !== snapshot + this.previousSourceSnapshot = snapshot + + if (snapshot.baselineEntry !== undefined) { + if (sourceSnapshotChanged || forcePresentationUpdate) { + this.syncBaselineEntryController(snapshot.baselineEntry) + } + return + } + this.clearController() this.resetPresentationState() + this.applyManagedSourceSnapshot(snapshot, sourceSnapshotChanged, forcePresentationUpdate) + } + private applyManagedSourceSnapshot( + snapshot: OptimizedEntrySourceSnapshot, + sourceSnapshotChanged: boolean, + forcePresentationUpdate: boolean, + ): void { + if (snapshot.error !== undefined) { + if (sourceSnapshotChanged) { + this.dispatchEntryError(snapshot.error) + } + return + } + + if (snapshot.isLoading && sourceSnapshotChanged) { + const { sdk, isSdkStateReady } = this.resolveControllerSdk() + if (sdk !== undefined && isSdkStateReady) { + this.dispatchEvent( + new CustomEvent(ENTRY_LOADING_EVENT, { + bubbles: true, + composed: true, + }), + ) + } + } + + if (forcePresentationUpdate && snapshot.entryId !== undefined) { + this.dispatchRootContextError() + } + } + + private dispatchRootContextError(): void { if (this.optimizationRootContext?.error) { this.dispatchEntryError(this.optimizationRootContext.error) } } - private createControllerOptions({ - baselineEntry, - sdk, - isSdkStateReady, - }: { - readonly baselineEntry: Entry - readonly sdk: OptimizedEntrySdk - readonly isSdkStateReady: boolean - }): OptimizedEntryControllerOptions { + private createControllerOptions( + baselineEntry: Entry, + sdk: OptimizedEntrySdk, + isSdkStateReady: boolean, + ): OptimizedEntryControllerOptions { return { isPresentationReady: true, baselineEntry, @@ -287,22 +389,6 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { } } - private updateOrCreateController( - controllerOptions: OptimizedEntryControllerOptions, - ): OptimizedEntryController { - if (this.controller === undefined) { - this.controller = new OptimizedEntryController(controllerOptions) - this.controller.setSnapshotListener((snapshot) => { - this.applySnapshot(snapshot) - }) - this.controller.connect() - return this.controller - } - - this.controller.updateOptions(controllerOptions) - return this.controller - } - private applySnapshot(snapshot: OptimizedEntrySnapshot): void { const { previousSnapshot } = this this.applyHostAttributes(snapshot.hostAttributes) @@ -371,6 +457,7 @@ export class ContentfulOptimizedEntryElement extends HTMLElement { composed: true, detail: { entry: snapshot.entry, + metadata: snapshot.metadata, resolvedData: snapshot.resolvedData, selectedOptimization: snapshot.selectedOptimization, selectedOptimizations: snapshot.selectedOptimizations, diff --git a/packages/web/web-sdk/src/web-components/index.test.ts b/packages/web/web-sdk/src/web-components/index.test.ts index 29a38075d..4360863c4 100644 --- a/packages/web/web-sdk/src/web-components/index.test.ts +++ b/packages/web/web-sdk/src/web-components/index.test.ts @@ -70,6 +70,23 @@ async function resolveVoid(): Promise { await Promise.resolve() } +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function createDeferred(): { + readonly promise: Promise + readonly resolve: (value: T) => void +} { + let resolveDeferred: (value: T) => void = () => undefined + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + + return { promise, resolve: resolveDeferred } +} + function toContentfulOptimization(sdk: TSdk): TSdk & ContentfulOptimization { Object.setPrototypeOf(sdk, ContentfulOptimization.prototype) @@ -153,6 +170,8 @@ function createSdk( consent: () => undefined, destroy, flush: resolveVoid, + fetchContentfulEntry: async (entryId: string) => + await Promise.resolve(createTestEntry(entryId)), getFlag: () => undefined, getMergeTagValue: () => undefined, hasConsent: () => true, @@ -230,6 +249,16 @@ function getEntryDetail(event: Event): ContentfulOptimizedEntryEventDetail { return detail } +function getEntryError(event: Event): Error | undefined { + if (!(event instanceof CustomEvent)) return undefined + + const { detail }: { detail: unknown } = event + if (!isRecord(detail)) return undefined + + const error = Reflect.get(detail, 'error') + return error instanceof Error ? error : undefined +} + function ensureElementsDefined(): void { defineContentfulOptimizationElements() } @@ -324,13 +353,21 @@ describe('Contentful Optimization Web Components', () => { const runtime = createSdk((entry) => ({ entry })) const root = createRootElement(runtime.sdk) const entry = createEntryElement(baseline) - const resolved = rs.fn((event: Event) => getEntryDetail(event).entry.sys.id) + const resolved = rs.fn((event: Event) => getEntryDetail(event)) entry.addEventListener('ctfl-entry-resolved', resolved) root.append(entry) document.body.append(root) - expect(resolved).toHaveReturnedWith('baseline') + expect(resolved).toHaveReturnedWith( + expect.objectContaining({ + entry: baseline, + metadata: expect.objectContaining({ + baselineEntry: baseline, + entry: baseline, + }), + }), + ) expect(entry.style.display).toBe('contents') expect(entry.dataset.ctflEntryId).toBe('baseline') expect(entry.dataset.ctflVariantIndex).toBe('0') @@ -345,7 +382,7 @@ describe('Contentful Optimization Web Components', () => { const root = createRootElement(runtime.sdk) const entry = createEntryElement(optimizedBaseline) const loading = rs.fn() - const resolved = rs.fn((event: Event) => getEntryDetail(event).entry.sys.id) + const resolved = rs.fn((event: Event) => getEntryDetail(event)) entry.addEventListener('ctfl-entry-loading', loading) entry.addEventListener('ctfl-entry-resolved', resolved) @@ -361,7 +398,16 @@ describe('Contentful Optimization Web Components', () => { runtime.canOptimize.emit(true) runtime.experienceRequestState.emit({ status: 'success' }) - expect(resolved).toHaveReturnedWith('variant-a') + expect(resolved).toHaveReturnedWith( + expect.objectContaining({ + entry: variantA, + metadata: expect.objectContaining({ + baselineEntry: optimizedBaseline, + entryId: 'variant-a', + selectedOptimization: variantOneState[0], + }), + }), + ) expect(entry.dataset.ctflBaselineId).toBe('optimized-baseline') expect(entry.dataset.ctflEntryId).toBe('variant-a') expect(entry.dataset.ctflOptimizationId).toBe('exp-hero') @@ -369,6 +415,117 @@ describe('Contentful Optimization Web Components', () => { expect(entry.style.visibility).toBe('') }) + it('fetches entryId entries through the SDK before resolving', async () => { + ensureElementsDefined() + const runtime = createSdk((entry) => ({ entry })) + const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(baseline)) + Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry) + const root = createRootElement(runtime.sdk) + const entry = document.createElement('ctfl-optimized-entry') + + if (!(entry instanceof ContentfulOptimizedEntryElement)) { + throw new Error('ctfl-optimized-entry is not registered.') + } + + const loading = rs.fn() + const resolved = rs.fn((event: Event) => getEntryDetail(event)) + + entry.entryId = 'baseline' + entry.entryQuery = { locale: 'de-DE' } + entry.addEventListener('ctfl-entry-loading', loading) + entry.addEventListener('ctfl-entry-resolved', resolved) + root.append(entry) + document.body.append(root) + await flushMicrotasks() + + expect(loading).toHaveBeenCalledTimes(1) + expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' }) + expect(resolved).toHaveReturnedWith( + expect.objectContaining({ + entry: baseline, + metadata: expect.objectContaining({ + baselineEntry: baseline, + }), + }), + ) + expect(entry.dataset.ctflEntryId).toBe('baseline') + }) + + it('lets baselineEntry take precedence over entryId', () => { + ensureElementsDefined() + const runtime = createSdk((entry) => ({ entry })) + const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(variantA)) + Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry) + const root = createRootElement(runtime.sdk) + const entry = createEntryElement(baseline) + + entry.entryId = 'variant-a' + root.append(entry) + document.body.append(root) + + expect(fetchContentfulEntry).not.toHaveBeenCalled() + expect(entry.dataset.ctflEntryId).toBe('baseline') + }) + + it('starts a fresh entryId fetch when baselineEntry precedence is removed', async () => { + ensureElementsDefined() + const runtime = createSdk((entry) => ({ entry })) + const firstFetch = createDeferred() + let fetchCount = 0 + const fetchContentfulEntry = rs.fn(async () => { + fetchCount += 1 + if (fetchCount === 1) return await firstFetch.promise + return await Promise.resolve(baseline) + }) + Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry) + const root = createRootElement(runtime.sdk) + const entry = document.createElement('ctfl-optimized-entry') + + if (!(entry instanceof ContentfulOptimizedEntryElement)) { + throw new Error('ctfl-optimized-entry is not registered.') + } + + entry.entryId = 'baseline' + root.append(entry) + document.body.append(root) + + expect(fetchContentfulEntry).toHaveBeenCalledTimes(1) + + entry.baselineEntry = variantA + expect(entry.dataset.ctflEntryId).toBe('variant-a') + + entry.baselineEntry = undefined + await flushMicrotasks() + + expect(fetchContentfulEntry).toHaveBeenCalledTimes(2) + expect(entry.dataset.ctflEntryId).toBe('baseline') + firstFetch.resolve(baseline) + await flushMicrotasks() + }) + + it('dispatches entry errors from managed entryId fetches', async () => { + ensureElementsDefined() + const runtime = createSdk((entry) => ({ entry })) + const error = new Error('CDA failed') + runtime.sdk.fetchContentfulEntry = rs.fn(async () => await Promise.reject(error)) + const root = createRootElement(runtime.sdk) + const entry = document.createElement('ctfl-optimized-entry') + + if (!(entry instanceof ContentfulOptimizedEntryElement)) { + throw new Error('ctfl-optimized-entry is not registered.') + } + + const errored = rs.fn((event: Event) => getEntryError(event)) + + entry.entryId = 'baseline' + entry.addEventListener('ctfl-entry-error', errored) + root.append(entry) + document.body.append(root) + await flushMicrotasks() + + expect(errored).toHaveReturnedWith(error) + }) + it('clears presentation state when baselineEntry is unset and resolves again when reused', () => { ensureElementsDefined() const runtime = createSdk((entry, selectedOptimizations) => ({ From 554a2bf0de8934afafe81fcda6498e53dabe2484 Mon Sep 17 00:00:00 2001 From: Charles Hudson <265039+phobetron@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:06:21 +0200 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=90=9E=20fix(nextjs):=20Fixing=20Ne?= =?UTF-8?q?xt.js=2015=20export=20bug=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js 15 rejects wildcard re-exports across client boundaries, which breaks live updates and preview panel usage when SDK client entries are compiled into a client graph. Replace Next.js SDK client-boundary wildcard barrels with explicit named exports and keep router entrypoints scoped to router factories and route trackers. Remove the ambiguous package-root export so consumers must choose the correct server, client, router, schema, ESR, request-handler, or tracking subpath. Add a shared build-tools guard that fails when a `"use client"` file contains `export *`, and run it from the React Web and Next.js SDK builds. Update tests and documentation for the new import boundaries. BREAKING CHANGE: `@contentful/optimization-nextjs` no longer exports from the package root. Use explicit subpaths such as `/app-router`, `/pages-router`, `/pages-router/server`, `/client`, `/server`, `/api-schemas`, `/esr`, `/request-handler`, or `/tracking-attributes`. BREAKING CHANGE: `/app-router` and `/pages-router` no longer re-export generic React Web hooks, providers, or components. Import those from `/client`. [[NT-3614](https://contentful.atlassian.net/browse/NT-3614)] --- .../guides/choosing-the-right-sdk.md | 14 ++--- ...mization-sdk-in-a-nextjs-app-router-app.md | 29 +++++----- ...zation-sdk-in-a-nextjs-pages-router-app.md | 25 ++++---- .../nextjs-sdk_app-router/README.md | 6 +- .../nextjs-sdk_pages-router/README.md | 6 +- lib/build-tools/README.md | 11 ++-- lib/build-tools/src/cli.ts | 22 +++++++ .../src/clientBoundaryExports.test.ts | 54 +++++++++++++++++ lib/build-tools/src/clientBoundaryExports.ts | 58 +++++++++++++++++++ lib/build-tools/src/index.ts | 5 ++ packages/web/frameworks/nextjs-sdk/AGENTS.md | 4 +- packages/web/frameworks/nextjs-sdk/README.md | 15 +++-- .../web/frameworks/nextjs-sdk/package.json | 15 +---- .../nextjs-sdk/src/app-router-client.test.tsx | 11 +++- .../nextjs-sdk/src/app-router-client.ts | 1 - .../web/frameworks/nextjs-sdk/src/client.ts | 51 +++++++++++++++- .../frameworks/nextjs-sdk/src/package.test.ts | 37 ++++++++++++ .../nextjs-sdk/src/pages-router.test.tsx | 7 +++ .../frameworks/nextjs-sdk/src/pages-router.ts | 1 - .../nextjs-sdk/src/runtime-types.test.ts | 3 +- .../web/frameworks/react-web-sdk/package.json | 2 +- tsconfig.base.json | 1 - 22 files changed, 306 insertions(+), 72 deletions(-) create mode 100644 lib/build-tools/src/clientBoundaryExports.test.ts create mode 100644 lib/build-tools/src/clientBoundaryExports.ts create mode 100644 packages/web/frameworks/nextjs-sdk/src/package.test.ts diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index b8166236f..104f08ab3 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -27,11 +27,11 @@ use `@contentful/optimization-nextjs/app-router`; it provides `createNextjsAppRo an automatic factory that returns app-local bound `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route trackers for Server and Client Components. Next.js Pages Router apps use `@contentful/optimization-nextjs/pages-router` plus -`@contentful/optimization-nextjs/pages-router/server` for `getServerSideProps`. Manual `/server`, -`/client`, `/request-handler`, `/esr`, and `/tracking-attributes` helpers remain available for -lower-level control. Non-Next.js server-rendered apps can combine `@contentful/optimization-node` on -the server with `@contentful/optimization-web` or `@contentful/optimization-react-web` in the -browser. +`@contentful/optimization-nextjs/pages-router/server` for `getServerSideProps`. The Next.js package +root is intentionally not an import path; use `/client`, `/server`, `/request-handler`, `/esr`, and +`/tracking-attributes` subpaths for lower-level control. Non-Next.js server-rendered apps can +combine `@contentful/optimization-node` on the server with `@contentful/optimization-web` or +`@contentful/optimization-react-web` in the browser. Angular, Vue, Svelte, Web Components, and custom browser framework apps use `@contentful/optimization-web`. Nest.js and other Node server frameworks use @@ -67,8 +67,8 @@ Use this table to choose the primary package and the next integration guide: | Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, managed Contentful entry fetching by ID, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | | Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, managed Contentful entry fetching by ID, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | | React browser app outside Next.js integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering by entry ID, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | -| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs/app-router` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | +| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs/pages-router` plus `/pages-router/server` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | | Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution primitives. The entry-source subpath manages baseline-entry or entry-ID source lifecycle while the adapter owns rendering, tracking, and runtime policy. | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | | React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | | Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md index 8d5e36eaa..daeb80268 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md @@ -209,26 +209,26 @@ requests for you. **Integration category:** Required for first integration -The Next.js adapter is a glue package. App Router integrations should start from `/app-router` and -export app-local bound components once. Use subpaths for hooks, request handlers, schemas, ESR, or -manual escape hatches. - -| Import path | Runtime | Responsibility | -| ------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-nextjs/app-router` | App-local binding module | Preferred `createNextjsAppRouterOptimization()` factory for App Router proxy plus Server and Client Components | -| `@contentful/optimization-nextjs/client` | Client Components | Router-neutral browser hooks, manual client providers, live updates helpers, and entry components | -| `@contentful/optimization-nextjs/server` | Advanced server-only modules | Manual server SDK creation, request binding, server resolution, and SSR tracking attributes | -| `@contentful/optimization-nextjs/esr` | Route handlers, edge functions, and ESR flows | Request-rendered Optimization data and explicit response persistence | -| `@contentful/optimization-nextjs/request-handler` | Next.js proxy or middleware | Request URL capture and SDK-owned request header sanitization | -| `@contentful/optimization-nextjs/api-schemas` | Shared schema helpers | API types plus structural guards such as `isMergeTagEntry`, `isRichTextDocument`, and `isResolvedContentfulEntry` | +The Next.js adapter is a glue package with no package-root runtime export. App Router integrations +should start from `/app-router` and export app-local bound components once. Use subpaths for hooks, +request handlers, schemas, ESR, or manual escape hatches. + +| Import path | Runtime | Responsibility | +| ------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/app-router` | App-local binding module | Preferred `createNextjsAppRouterOptimization()` factory and route tracker for App Router proxy plus Server and Client Components | +| `@contentful/optimization-nextjs/client` | Client Components | Router-neutral browser hooks, manual client providers, live updates helpers, and entry components | +| `@contentful/optimization-nextjs/server` | Advanced server-only modules | Manual server SDK creation, request binding, server resolution, and SSR tracking attributes | +| `@contentful/optimization-nextjs/esr` | Route handlers, edge functions, and ESR flows | Request-rendered Optimization data and explicit response persistence | +| `@contentful/optimization-nextjs/request-handler` | Next.js proxy or middleware | Request URL capture and SDK-owned request header sanitization | +| `@contentful/optimization-nextjs/api-schemas` | Shared schema helpers | API types plus structural guards such as `isMergeTagEntry`, `isRichTextDocument`, and `isResolvedContentfulEntry` | 1. Install `@contentful/optimization-nextjs` in the Next.js app. 2. Create an app-local module, commonly `lib/optimization.ts`, that calls `createNextjsAppRouterOptimization()`. 3. Import `OptimizationRoot`, `OptimizationProvider`, the bound `OptimizedEntry`, and route trackers from the app-local module in Server and Client Components. -4. Import browser hooks, and `OptimizedEntry` for per-entry `liveUpdates` or `loadingFallback`, from - `/client` only inside Client Components marked with `'use client'`. +4. Import browser hooks, providers, and generic `OptimizedEntry` for per-entry `liveUpdates` or + `loadingFallback` from `/client` only inside Client Components marked with `'use client'`. 5. Re-export `proxy` from the app-local module in proxy or middleware code and declare the literal Next.js matcher config there. 6. Keep `/server` imports for manual server control when the bound App Router factory does not fit a @@ -1027,6 +1027,7 @@ pnpm test:e2e:nextjs-sdk_app-router | Entry views, clicks, or hovers do not emit | Interaction tracking is opted out, consent blocks the event, or no profile is available | Check factory `trackEntryInteraction`, entry props, consent state, and `states.blockedEventStream` | | Server and browser use different profiles | Cookie domain, path, readability, or consent cleanup differs between runtimes | Use a browser-readable `ctfl-opt-aid` with consistent path and clear it on withdrawal | | Server Components fail with browser globals | A Client Component hook or browser-only import crossed into a server module | Use app-local bound component imports in Server Components and `/client` hooks only in Client Components | +| Next.js 15 reports unsupported `export *` in a client boundary | The app or an old SDK version uses wildcard re-exports in a `'use client'` module | Upgrade to the fixed SDK and avoid app-authored `export *` barrels in Client Components | | Personalized HTML appears stale | Route or CDN caching is sharing profile-evaluated output | Mark personalized routes dynamic or vary cache keys on the full personalization context | ## Reference implementations to compare against diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md index e61135764..ac1c099eb 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md @@ -222,16 +222,17 @@ requests for you. **Integration category:** Required for first integration -The Next.js adapter is a glue package. Pages Router integrations should start from `/pages-router` -for bound browser components and `/pages-router/server` for `getServerSideProps` state handoff. - -| Import path | Runtime | Responsibility | -| ----------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-nextjs/pages-router` | Pages Router components | Bound `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route tracker | -| `@contentful/optimization-nextjs/pages-router/server` | `getServerSideProps` | Server Optimization data, initial page-event ownership, and anonymous ID persistence | -| `@contentful/optimization-nextjs/client` | Browser components | Router-neutral browser hooks, manual client providers, live updates helpers, and entry components | -| `@contentful/optimization-nextjs/server` | Advanced server modules | Manual server SDK creation and direct request binding | -| `@contentful/optimization-nextjs/api-schemas` | Shared schema helpers | API types plus structural guards | +The Next.js adapter is a glue package with no package-root runtime export. Pages Router integrations +should start from `/pages-router` for bound browser components and `/pages-router/server` for +`getServerSideProps` state handoff. + +| Import path | Runtime | Responsibility | +| ----------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/pages-router` | Pages Router components | Factory and route tracker for bound `OptimizationRoot`, `OptimizationProvider`, and `OptimizedEntry` | +| `@contentful/optimization-nextjs/pages-router/server` | `getServerSideProps` | Server Optimization data, initial page-event ownership, and anonymous ID persistence | +| `@contentful/optimization-nextjs/client` | Browser components | Router-neutral browser hooks, manual client providers, live updates helpers, and entry components | +| `@contentful/optimization-nextjs/server` | Advanced server modules | Manual server SDK creation and direct request binding | +| `@contentful/optimization-nextjs/api-schemas` | Shared schema helpers | API types plus structural guards | 1. Install `@contentful/optimization-nextjs` in the Next.js app. 2. Create an app-local module that calls `createNextjsPagesRouterOptimization()`. @@ -239,7 +240,8 @@ for bound browser components and `/pages-router/server` for `getServerSideProps` `/pages-router/server`. 4. Import `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and `NextPagesAutoPageTracker` from the app-local module. -5. Import browser hooks from `/client` only inside browser components. +5. Import browser hooks, providers, and generic client entry components from `/client` only inside + browser components. 6. Keep `/server` imports for manual server control when the Pages Router helper does not fit a route. @@ -504,6 +506,7 @@ pnpm test:e2e:nextjs-sdk_pages-router | Live entries do not update after `identifyUser()` or `resetUser()` | `liveUpdates` is false in the factory, provider, and entry | Set `liveUpdates={true}` on the entry, a `LiveUpdatesProvider`, or the component factory | | Entry views, clicks, or hovers do not emit | Interaction tracking is opted out, consent blocks the event, or no profile is available | Check factory `trackEntryInteraction`, entry props, consent state, and `states.blockedEventStream` | | Server and browser use different profiles | Cookie domain, path, readability, or consent cleanup differs between runtimes | Use a browser-readable `ctfl-opt-aid` with consistent path and clear it on withdrawal | +| Next.js 15 reports unsupported `export *` in a client boundary | The app or an old SDK version uses wildcard re-exports in a `'use client'` module | Upgrade to the fixed SDK and avoid app-authored `export *` barrels in browser components | | Personalized HTML appears stale | Route or CDN caching is sharing profile-evaluated output | Set response headers or cache keys for the full personalization context | ## Reference implementations to compare against diff --git a/implementations/nextjs-sdk_app-router/README.md b/implementations/nextjs-sdk_app-router/README.md index 2e96fa7bf..fb645a61f 100644 --- a/implementations/nextjs-sdk_app-router/README.md +++ b/implementations/nextjs-sdk_app-router/README.md @@ -26,10 +26,10 @@ browser-side entry resolution after startup. The implementation binds `Optimizat `OptimizedEntry`, and `NextAppAutoPageTracker` once in `@/lib/optimization` with `createNextjsAppRouterOptimization()`. Routes and shared components import those app-local components for Server Component first paint and Client Component live-update surfaces. Other SDK -runtime imports use Next.js SDK package subpaths: +runtime imports use Next.js SDK package subpaths. The package root is not imported: -- `@contentful/optimization-nextjs/app-router` in `@/lib/optimization` for bound server/client - components +- `@contentful/optimization-nextjs/app-router` in `@/lib/optimization` for the bound component + factory and route tracker - `@contentful/optimization-nextjs/client` for browser hooks and providers - `@contentful/optimization-nextjs/api-schemas` in components that need SDK schema guards - `@contentful/optimization-nextjs/app-router` in proxy through the app-local `proxy` export diff --git a/implementations/nextjs-sdk_pages-router/README.md b/implementations/nextjs-sdk_pages-router/README.md index 3911a221c..8889c57c0 100644 --- a/implementations/nextjs-sdk_pages-router/README.md +++ b/implementations/nextjs-sdk_pages-router/README.md @@ -27,10 +27,10 @@ tracker once in `pages/_app.tsx`. The implementation binds `OptimizationRoot`, `OptimizedEntry`, and `NextPagesAutoPageTracker` once in `@/lib/optimization` with `createNextjsPagesRouterOptimization()`. Browser runtime imports use -Next.js SDK package subpaths: +Next.js SDK package subpaths. The package root is not imported: -- `@contentful/optimization-nextjs/pages-router` in `@/lib/optimization` for bound Pages Router - components +- `@contentful/optimization-nextjs/pages-router` in `@/lib/optimization` for the bound component + factory and route tracker - `@contentful/optimization-nextjs/pages-router/server` in `@/lib/optimization-server` for `getServerSideProps` state handoff - `@contentful/optimization-nextjs/client` for browser hooks and providers diff --git a/lib/build-tools/README.md b/lib/build-tools/README.md index 97a35abf3..48466ad34 100644 --- a/lib/build-tools/README.md +++ b/lib/build-tools/README.md @@ -6,14 +6,15 @@ > imported by application code. `build-tools` contains shared command-line helpers used by Optimization SDK package builds, -especially declaration emission and bundle-size checks. Keep cross-package build behavior here -instead of duplicating one-off scripts in downstream package directories. +especially declaration emission, client-boundary export checks, and bundle-size checks. Keep +cross-package build behavior here instead of duplicating one-off scripts in downstream package +directories. ## When to use this package -Use this package when maintaining shared build helper behavior, declaration output handling, or -bundle-size measurement. Package-specific bundle-size budgets belong in each package's -`package.json` under `buildTools.bundleSize.gzipBudgets`. +Use this package when maintaining shared build helper behavior, declaration output handling, +client-boundary export validation, or bundle-size measurement. Package-specific bundle-size budgets +belong in each package's `package.json` under `buildTools.bundleSize.gzipBudgets`. Bundle-size checks measure configured files as entrypoints. For JavaScript files, the reported `raw` and `gzip` values include local static chunks reachable from the configured file in `dist/`, so diff --git a/lib/build-tools/src/cli.ts b/lib/build-tools/src/cli.ts index b95244c73..ce1529597 100644 --- a/lib/build-tools/src/cli.ts +++ b/lib/build-tools/src/cli.ts @@ -1,4 +1,5 @@ import { checkBundleSize } from './bundleSize' +import { checkClientBoundaryExports } from './clientBoundaryExports' import { emitDualDts } from './emitDualDts' import { preparePublishReadme } from './publishReadme' @@ -7,11 +8,13 @@ function printUsage(): void { 'Usage:\n' + ' build-tools emit-dual-dts [distDir]\n' + ' build-tools bundle-size [packageDir] [--report-only]\n' + + ' build-tools check-client-boundary-exports [path...]\n' + ' build-tools rewrite-readme prepare [packageDir]\n' + '\n' + 'Examples:\n' + ' build-tools emit-dual-dts ./dist\n' + ' build-tools bundle-size\n' + + ' build-tools check-client-boundary-exports ./src ./dist\n' + ' build-tools rewrite-readme prepare\n' + ' build-tools bundle-size ./packages/web/web-sdk --report-only\n', ) @@ -73,6 +76,20 @@ function runBundleSizeCommand(args: string[]): void { } } +function runCheckClientBoundaryExportsCommand(args: string[]): void { + const failures = checkClientBoundaryExports({ paths: args }) + + if (failures.length === 0) return + + process.stderr.write('Unsupported export * in client boundaries:\n') + + for (const failure of failures) { + process.stderr.write(`- ${failure.file}\n`) + } + + process.exitCode = 1 +} + function runRewriteReadmeCommand(args: string[]): void { const [action, packageDir = '.'] = args @@ -111,6 +128,11 @@ export function main(argv: string[]): void { return } + if (command === 'check-client-boundary-exports') { + runCheckClientBoundaryExportsCommand(rest) + return + } + if (command === 'rewrite-readme') { runRewriteReadmeCommand(rest) return diff --git a/lib/build-tools/src/clientBoundaryExports.test.ts b/lib/build-tools/src/clientBoundaryExports.test.ts new file mode 100644 index 000000000..79f0df05a --- /dev/null +++ b/lib/build-tools/src/clientBoundaryExports.test.ts @@ -0,0 +1,54 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { checkClientBoundaryExports } from './clientBoundaryExports' + +describe('checkClientBoundaryExports', () => { + it('reports files that combine a client directive with export all', () => { + const cwd = mkdtempSync(join(tmpdir(), 'build-tools-client-boundary-')) + + try { + mkdirSync(join(cwd, 'src')) + writeFileSync(join(cwd, 'src', 'client.ts'), "'use client'\nexport * from './runtime'\n") + writeFileSync(join(cwd, 'src', 'safe.ts'), "export * from './runtime'\n") + writeFileSync(join(cwd, 'src', 'types.d.ts'), "'use client'\nexport * from './runtime'\n") + + expect(checkClientBoundaryExports({ cwd, paths: ['./src'] })).toEqual([ + { file: 'src/client.ts' }, + ]) + } finally { + rmSync(cwd, { force: true, recursive: true }) + } + }) + + it('detects minified emitted export all syntax', () => { + const cwd = mkdtempSync(join(tmpdir(), 'build-tools-client-boundary-')) + + try { + mkdirSync(join(cwd, 'dist')) + writeFileSync(join(cwd, 'dist', 'client.mjs'), '\'use client\';export*from"./runtime.mjs";') + + expect(checkClientBoundaryExports({ cwd, paths: ['./dist'] })).toEqual([ + { file: 'dist/client.mjs' }, + ]) + } finally { + rmSync(cwd, { force: true, recursive: true }) + } + }) + + it('ignores client boundaries with named exports', () => { + const cwd = mkdtempSync(join(tmpdir(), 'build-tools-client-boundary-')) + + try { + mkdirSync(join(cwd, 'src')) + writeFileSync( + join(cwd, 'src', 'client.ts'), + "'use client'\nexport { value } from './runtime'\n", + ) + + expect(checkClientBoundaryExports({ cwd, paths: ['./src'] })).toEqual([]) + } finally { + rmSync(cwd, { force: true, recursive: true }) + } + }) +}) diff --git a/lib/build-tools/src/clientBoundaryExports.ts b/lib/build-tools/src/clientBoundaryExports.ts new file mode 100644 index 000000000..706cd0035 --- /dev/null +++ b/lib/build-tools/src/clientBoundaryExports.ts @@ -0,0 +1,58 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { extname, relative, resolve } from 'node:path' + +const DEFAULT_PATHS = ['./src', './dist'] as const +const CHECKED_EXTENSIONS = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']) +const CLIENT_DIRECTIVE_PATTERN = /(?:^|[\n;])\s*['"]use client['"]\s*;?/ +const EXPORT_ALL_PATTERN = /\bexport\s*\*\s*(?:as\s+[$A-Z_a-z][$\w]*\s*)?from\b/ + +export interface CheckClientBoundaryExportsOptions { + readonly cwd?: string + readonly paths?: readonly string[] +} + +export interface ClientBoundaryExportFailure { + readonly file: string +} + +export function checkClientBoundaryExports( + options: CheckClientBoundaryExportsOptions = {}, +): ClientBoundaryExportFailure[] { + const cwd = resolve(options.cwd ?? process.cwd()) + const inputPaths = options.paths?.length ? options.paths : DEFAULT_PATHS + const files = inputPaths.flatMap((inputPath) => collectFiles(resolve(cwd, inputPath))) + + return files + .sort() + .filter((filePath) => { + const source = readFileSync(filePath, 'utf8') + return CLIENT_DIRECTIVE_PATTERN.test(source) && EXPORT_ALL_PATTERN.test(source) + }) + .map((filePath) => ({ file: toRelativePath(cwd, filePath) })) +} + +function collectFiles(filePath: string): string[] { + if (!existsSync(filePath)) return [] + + const stats = statSync(filePath) + + if (stats.isDirectory()) { + return readdirSync(filePath, { withFileTypes: true }).flatMap((entry) => + collectFiles(resolve(filePath, entry.name)), + ) + } + + if (!stats.isFile()) return [] + if (isDeclarationFile(filePath)) return [] + if (!CHECKED_EXTENSIONS.has(extname(filePath))) return [] + + return [filePath] +} + +function isDeclarationFile(filePath: string): boolean { + return filePath.endsWith('.d.ts') || filePath.endsWith('.d.mts') || filePath.endsWith('.d.cts') +} + +function toRelativePath(cwd: string, filePath: string): string { + return relative(cwd, filePath) || filePath +} diff --git a/lib/build-tools/src/index.ts b/lib/build-tools/src/index.ts index c3aa0a37c..006e8a682 100644 --- a/lib/build-tools/src/index.ts +++ b/lib/build-tools/src/index.ts @@ -5,6 +5,11 @@ */ export { checkBundleSize } from './bundleSize' export type { BundleSizeFailure, BundleSizeResult, CheckBundleSizeOptions } from './bundleSize' +export { checkClientBoundaryExports } from './clientBoundaryExports' +export type { + CheckClientBoundaryExportsOptions, + ClientBoundaryExportFailure, +} from './clientBoundaryExports' export { emitDualDts } from './emitDualDts' export { getPackageName, hasPackageName } from './package' export { preparePublishReadme, rewriteReadmeForPublish } from './publishReadme' diff --git a/packages/web/frameworks/nextjs-sdk/AGENTS.md b/packages/web/frameworks/nextjs-sdk/AGENTS.md index 03bf0f7b4..f74d5966a 100644 --- a/packages/web/frameworks/nextjs-sdk/AGENTS.md +++ b/packages/web/frameworks/nextjs-sdk/AGENTS.md @@ -11,8 +11,8 @@ client. `@contentful/optimization-nextjs/app-router`; Pages Router client components live under `@contentful/optimization-nextjs/pages-router`; Pages `getServerSideProps` support lives under `@contentful/optimization-nextjs/pages-router/server`. -- Keep the package root and `/client` as client-safe lower-level exports. Do not add a bound - component factory to the package root. +- Keep `/client` as the router-neutral client-safe lower-level export. The package root is + intentionally unexported; do not add a bound component factory or client alias there. - Do not import `@contentful/optimization-core` directly. - Keep server entries free of client directives and browser-only assumptions. - Keep client entries marked with `"use client"` and free of Node-only imports. diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 6eda088cf..a897fe54b 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -21,14 +21,16 @@ > The Optimization SDK Suite is pre-release (alpha). Breaking changes can be published at any time. `@contentful/optimization-nextjs` is a thin adapter for Next.js applications. It composes the Node -SDK on the server with the React Web SDK on the client; it is not a new optimization runtime. +SDK on the server with the React Web SDK on the client; it is not a new optimization runtime. The +package root intentionally has no runtime export. Import one of the documented subpaths so the +server, client, and router boundaries stay explicit. ## What this package provides | Runtime | Import path | Responsibility | | --------------- | ----------------------------------------------------- | --------------------------------------------------------- | -| App Router | `@contentful/optimization-nextjs/app-router` | Bound App Router components and SDK proxy | -| Pages Router | `@contentful/optimization-nextjs/pages-router` | Bound Pages Router client component factory | +| App Router | `@contentful/optimization-nextjs/app-router` | App Router factory, route tracker, and SDK proxy | +| Pages Router | `@contentful/optimization-nextjs/pages-router` | Pages Router factory and route tracker | | Pages server | `@contentful/optimization-nextjs/pages-router/server` | Config-bound `getServerSideProps` state handoff | | Client | `@contentful/optimization-nextjs/client` | Router-neutral React SDK providers, hooks, and components | | Schemas | `@contentful/optimization-nextjs/api-schemas` | Shared API types, schemas, and structural guards | @@ -51,7 +53,8 @@ app-owned CDA client used by the managed entry fetching example. Start App Router integrations from `/app-router` and define app-local bound exports once. Next.js resolves that import to the automatic server implementation for Server Components and to the client -implementation for Client Components. +implementation for Client Components. The `/app-router` subpath is a factory and tracker surface; +import browser hooks, providers, and generic client entry components from `/client`. ```tsx import { createNextjsAppRouterOptimization } from '@contentful/optimization-nextjs/app-router' @@ -140,7 +143,9 @@ it does not accept injected SDK instances. ## Pages Router setup Start Pages Router integrations from `/pages-router` and pass server state through `_app.tsx` -because `getServerSideProps` delivers it through `pageProps`. +because `getServerSideProps` delivers it through `pageProps`. The `/pages-router` subpath is a +factory and tracker surface; import browser hooks, providers, and generic client entry components +from `/client`. ```tsx import { createNextjsPagesRouterOptimization } from '@contentful/optimization-nextjs/pages-router' diff --git a/packages/web/frameworks/nextjs-sdk/package.json b/packages/web/frameworks/nextjs-sdk/package.json index 2626f7161..f30b52299 100644 --- a/packages/web/frameworks/nextjs-sdk/package.json +++ b/packages/web/frameworks/nextjs-sdk/package.json @@ -8,20 +8,7 @@ "directory": "packages/web/frameworks/nextjs-sdk" }, "type": "module", - "main": "./dist/client.cjs", - "module": "./dist/client.mjs", - "types": "./dist/client.d.mts", "exports": { - ".": { - "import": { - "types": "./dist/client.d.mts", - "default": "./dist/client.mjs" - }, - "require": { - "types": "./dist/client.d.cts", - "default": "./dist/client.cjs" - } - }, "./app-router": { "react-server": { "types": "./dist/app-router-server.d.mts", @@ -149,7 +136,7 @@ "scripts": { "build": "pnpm clean && pnpm build:dist", "build:ci": "pnpm build:dist", - "build:dist": "rslib build && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/pages-router", + "build:dist": "rslib build && build-tools check-client-boundary-exports ./src ./dist && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/pages-router", "build:rsdoctor": "RSDOCTOR=true rslib build && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/pages-router", "clean": "rimraf ./.rsdoctor ./.rslib ./dist ./coverage .tsbuildinfo ./node_modules/.cache/rspack", "size:check": "pnpm build:dist && build-tools bundle-size", diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index 434637f6a..9ab7469c1 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -1,3 +1,4 @@ +import * as reactWeb from '@contentful/optimization-react-web' import type { Entry } from 'contentful' import * as appRouter from './app-router-client' import * as client from './client' @@ -56,7 +57,7 @@ describe('Next.js App Router client components', () => { serverOptimizedEntries, }) - expect(components.OptimizedEntry).toBe(appRouter.OptimizedEntry) + expect(components.OptimizedEntry).toBe(client.OptimizedEntry) expect(components.NextAppAutoPageTracker).toBe(appRouter.NextAppAutoPageTracker) expect(components.proxy).toBeUndefined() expect(components).not.toHaveProperty('config') @@ -92,8 +93,16 @@ describe('Next.js App Router client components', () => { }) it('keeps the low-level client entry free of router-specific exports', () => { + expect(Object.keys(client).sort()).toEqual(Object.keys(reactWeb).sort()) expect(client).not.toHaveProperty('NextAppAutoPageTracker') expect(client).not.toHaveProperty('NextPagesAutoPageTracker') expect(client).not.toHaveProperty('createNextjsOptimizationComponents') }) + + it('keeps the App Router entry scoped to the factory and tracker', () => { + expect(Object.keys(appRouter).sort()).toEqual([ + 'NextAppAutoPageTracker', + 'createNextjsAppRouterOptimization', + ]) + }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts index d19517ca9..0f178bd67 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts @@ -20,7 +20,6 @@ import type { NextjsOptimizationComponentsConfig, } from './bound-component-types' -export * from '@contentful/optimization-react-web' export type { BoundNextjsOptimizationRootProps, NextjsBoundOptimizedEntryProps, diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index 811e8e1f2..941153d81 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -1,3 +1,52 @@ 'use client' -export * from '@contentful/optimization-react-web' +export { + LiveUpdatesContext, + LiveUpdatesProvider, + OptimizationContext, + OptimizationProvider, + OptimizationRoot, + OptimizedEntry, + prefetchOptimizedEntries, + useCanOptimizeState, + useConsentState, + useEntryResolver, + useEventStreamState, + useLiveUpdates, + useMergeTagResolver, + useOptimization, + useOptimizationActions, + useOptimizationContext, + useOptimizedEntry, + useProfileState, + useSelectedOptimizationsState, +} from '@contentful/optimization-react-web' +export type { + AutoPageEmissionContext, + AutoPagePayload, + AutoPagePayloadOptions, + AutoPageRouteState, + ContentfulOptimizationOrNull, + InitialAutoPageEvent, + LiveUpdatesContextValue, + LiveUpdatesProviderProps, + OnStatesReady, + OptimizationContextValue, + OptimizationProviderProps, + OptimizationRootProps, + OptimizationSdk, + OptimizationWebRuntime, + OptimizedEntryErrorFallback, + OptimizedEntryLoadingFallback, + OptimizedEntryPrefetchDescriptor, + OptimizedEntryPrefetchRuntime, + OptimizedEntryProps, + OptimizedEntryRenderContext, + ServerOptimizedEntryHandoff, + TrackEntryInteractionOptions, + UseEntryResolverResult, + UseMergeTagResolverResult, + UseOptimizationActionsResult, + UseOptimizedEntryParams, + UseOptimizedEntryResult, +} from '@contentful/optimization-react-web' diff --git a/packages/web/frameworks/nextjs-sdk/src/package.test.ts b/packages/web/frameworks/nextjs-sdk/src/package.test.ts new file mode 100644 index 000000000..940bbed82 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/package.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' + +interface NextjsPackageManifest { + readonly exports?: Record + readonly main?: unknown + readonly module?: unknown + readonly types?: unknown +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNextjsPackageManifest(value: unknown): value is NextjsPackageManifest { + return isObjectRecord(value) && (value.exports === undefined || isObjectRecord(value.exports)) +} + +function readPackageManifest(): NextjsPackageManifest { + const manifest = JSON.parse(readFileSync('package.json', 'utf8')) as unknown + + if (!isNextjsPackageManifest(manifest)) { + throw new TypeError('package.json has an unexpected shape') + } + + return manifest +} + +describe('Next.js package manifest', () => { + it('requires explicit subpath imports', () => { + const manifest = readPackageManifest() + + expect(Object.hasOwn(manifest.exports ?? {}, '.')).toBe(false) + expect(manifest).not.toHaveProperty('main') + expect(manifest).not.toHaveProperty('module') + expect(manifest).not.toHaveProperty('types') + }) +}) diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx index 0d5696722..7b580d981 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx @@ -154,4 +154,11 @@ describe('Next.js Pages Router client components', () => { expect(components.NextPagesAutoPageTracker).toBe(pagesRouter.NextPagesAutoPageTracker) expect(components).not.toHaveProperty('NextAppAutoPageTracker') }) + + it('keeps the Pages Router entry scoped to the factory and tracker', () => { + expect(Object.keys(pagesRouter).sort()).toEqual([ + 'NextPagesAutoPageTracker', + 'createNextjsPagesRouterOptimization', + ]) + }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts index fd4e34b27..4f3cab823 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.ts @@ -21,7 +21,6 @@ import type { NextjsPagesRouterOptimizationComponentsConfig, } from './bound-component-types' -export * from '@contentful/optimization-react-web' export type { BoundNextjsOptimizationRootProps, NextjsOptimizationCookieConfig, diff --git a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts index ee373783d..51e1be79b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts @@ -1,12 +1,11 @@ import type NodeContentfulOptimization from '@contentful/optimization-node' -import type { OptimizedEntryProps } from '@contentful/optimization-react-web' import type WebContentfulOptimization from '@contentful/optimization-web' import type { NextjsOptimizationComponents as NextjsAppClientComponents, NextjsBoundOptimizedEntryProps, } from './app-router-client' import type { NextjsOptimizationComponents as NextjsAppServerComponents } from './app-router-server' -import type { OptimizationSdk } from './client' +import type { OptimizationSdk, OptimizedEntryProps } from './client' import type { NextjsPagesRouterOptimization } from './pages-router' import type { ContentfulOptimization as NextjsServerOptimization } from './server' diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 080edaea9..18e315bdf 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -129,7 +129,7 @@ "scripts": { "build": "pnpm clean && pnpm build:dist", "build:ci": "pnpm build:dist", - "build:dist": "rslib build && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/router", + "build:dist": "rslib build && build-tools check-client-boundary-exports ./src ./dist && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/router", "build:rsdoctor": "RSDOCTOR=true rslib build && build-tools emit-dual-dts ./dist && build-tools emit-dual-dts ./dist/router", "clean": "rimraf ./.rsdoctor ./.rslib ./dist ./coverage .tsbuildinfo ./node_modules/.cache/rspack", "dev": "rsbuild dev --host 0.0.0.0 -c dev/rsbuild.config.ts", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3b878cb3e..c79eb2bbb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -27,7 +27,6 @@ "@contentful/optimization-core/*": ["./packages/universal/core-sdk/src/*"], "@contentful/optimization-node": ["./packages/node/node-sdk/src/index.ts"], "@contentful/optimization-node/*": ["./packages/node/node-sdk/src/*"], - "@contentful/optimization-nextjs": ["./packages/web/frameworks/nextjs-sdk/src/index.ts"], "@contentful/optimization-nextjs/*": ["./packages/web/frameworks/nextjs-sdk/src/*"], "@contentful/optimization-react-web": [ "./packages/web/frameworks/react-web-sdk/src/index.ts" From e25bfea9de6c2b4d36f14ad2904dbbc822984e46 Mon Sep 17 00:00:00 2001 From: Felipe Mamud Date: Tue, 7 Jul 2026 12:26:08 +0200 Subject: [PATCH 08/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(web-sdk=5Fa?= =?UTF-8?q?ngular):=20Align=20SSR=20with=20isomorphic=20runtime=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-sdk_angular): Align SSR with isomorphic runtime seam * refactor(web-sdk,react-web-sdk): Move WebOptimizationRuntime to optimization-web/runtime * refactor(web-sdk_angular): Resolve merge tags at render time * refactor(web-sdk_angular): Split tracking-log event switch into methods * chore(web-sdk): Raise runtime subpath bundle-size budgets --- .../src/provider/OptimizationProvider.tsx | 1 - packages/web/web-sdk/src/runtime.ts | 37 ++++++------------- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index f36b5576d..feee400ee 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -25,7 +25,6 @@ import { type WebOptimizationRuntime, } from '@contentful/optimization-web/runtime' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' -import type { ServerOptimizedEntryHandoff } from '../server-optimized-entries' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index ad5d90771..c16273c63 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -4,9 +4,8 @@ * Re-exports the universal {@link OptimizationRuntime} / {@link createSnapshotRuntime} from * `@contentful/optimization-core/runtime` and adds the web-only composition * ({@link WebOptimizationRuntime} / {@link createWebSnapshotRuntime}) that stitches in - * browser-only imperative APIs (`tracking`, `trackCurrentPage`) and managed entry fetch methods - * so framework layers can bind a single runtime object across server rendering and browser - * hydration. + * browser-only imperative APIs (`tracking`, `trackCurrentPage`) so framework layers can + * bind a single runtime object across server rendering and browser hydration. * * @packageDocumentation */ @@ -28,7 +27,6 @@ export * from '@contentful/optimization-core/runtime' * @internal */ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' -type ManagedEntryFetchMembers = 'fetchContentfulEntry' | 'fetchOptimizedEntry' /** * The single runtime object framework layers (React Web, Angular, etc.) can consume. @@ -38,8 +36,7 @@ type ManagedEntryFetchMembers = 'fetchContentfulEntry' | 'fetchOptimizedEntry' * actions — safe on server and client) with the browser-only web SDK surface * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} instance * satisfies it by construction; a server / initial-render backing is produced by - * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops and managed - * entry fetch methods reject until a live SDK is available. + * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops. * * Every member is safe to reference in any environment: render-time members behave * correctly on the server, and effect-only members are no-ops there (the server never @@ -48,32 +45,22 @@ type ManagedEntryFetchMembers = 'fetchContentfulEntry' | 'fetchOptimizedEntry' * @public */ export interface WebOptimizationRuntime - extends - OptimizationRuntime, - Pick {} + extends OptimizationRuntime, Pick {} const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { - enable: noop, - disable: noop, - enableElement: noop, - disableElement: noop, - clearElement: noop, -} - -function noop(): undefined { - return undefined -} - -async function rejectSnapshotManagedEntryFetch(): Promise { - return await Promise.reject(new Error('Live SDK needed')) + enable: () => undefined, + disable: () => undefined, + enableElement: () => undefined, + disableElement: () => undefined, + clearElement: () => undefined, } /** * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. * * @param snapshot - Server-resolved optimization state for the current request. - * @returns A runtime that resolves and reads from the snapshot, with browser-only tracking APIs as - * inert no-ops and managed entry fetch methods rejected. + * @returns A runtime that resolves and reads from the snapshot, with browser-only + * tracking APIs as inert no-ops. * * @remarks * Used by framework providers during server rendering and the initial client render, @@ -86,8 +73,6 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp const runtime = createSnapshotRuntime(snapshot) return Object.assign(runtime, { - fetchContentfulEntry: rejectSnapshotManagedEntryFetch, - fetchOptimizedEntry: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) From 0960382fd57acd7fe682b818ec8b295176a3933d Mon Sep 17 00:00:00 2001 From: Charles Hudson <265039+phobetron@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:15:38 +0200 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=94=A5=20feat(cda):=20Add=20managed?= =?UTF-8?q?=20entry=20fetching=20(#360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Core `contentful` configuration, cached `fetchContentfulEntry()`, and `fetchOptimizedEntry()` APIs for stateful and request-bound stateless SDK flows. Introduce shared entry-source lifecycle support, optimized-entry metadata, and managed-entry keying so Web Components, React Web, React Native, and Next.js helpers can resolve entries from `entryId`/`entryQuery` while preserving the manual `baselineEntry` path. Add React Web SSR and hybrid entry handoff support with `serverOptimizedEntries`, prefetch helpers, snapshot-runtime fetch guards, and hydration-safe managed entry lookup. Add Next.js App Router and Pages Router managed-entry support, including server-rendered ``, Pages Router `prefetchOptimizedEntries`, server-fetched entry handoffs, and client-safe provider config. Refresh docs, guides, reference implementations, and tests for managed entry fetching across Core, Web Components, React Web, React Native, and Next.js. Adjust the Web Components UMD gzip budget for the managed-entry surface while keeping event constants readable. [[NT-3558](https://contentful.atlassian.net/browse/NT-3558)] --- .../guides/choosing-the-right-sdk.md | 4 +- .../nextjs-sdk/src/app-router-client.test.tsx | 1 - .../src/provider/OptimizationProvider.tsx | 1 + packages/web/web-sdk/src/runtime.ts | 37 +++++++++++++------ 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index 104f08ab3..50b55c3bf 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -67,8 +67,8 @@ Use this table to choose the primary package and the next integration guide: | Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, managed Contentful entry fetching by ID, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | | Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, managed Contentful entry fetching by ID, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | | React browser app outside Next.js integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering by entry ID, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs/app-router` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | -| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs/pages-router` plus `/pages-router/server` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | +| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | | Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution primitives. The entry-source subpath manages baseline-entry or entry-ID source lifecycle while the adapter owns rendering, tracking, and runtime policy. | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | | React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | | Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index 9ab7469c1..c560b29c5 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -1,4 +1,3 @@ -import * as reactWeb from '@contentful/optimization-react-web' import type { Entry } from 'contentful' import * as appRouter from './app-router-client' import * as client from './client' diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index feee400ee..f36b5576d 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -25,6 +25,7 @@ import { type WebOptimizationRuntime, } from '@contentful/optimization-web/runtime' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import type { ServerOptimizedEntryHandoff } from '../server-optimized-entries' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index c16273c63..ad5d90771 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -4,8 +4,9 @@ * Re-exports the universal {@link OptimizationRuntime} / {@link createSnapshotRuntime} from * `@contentful/optimization-core/runtime` and adds the web-only composition * ({@link WebOptimizationRuntime} / {@link createWebSnapshotRuntime}) that stitches in - * browser-only imperative APIs (`tracking`, `trackCurrentPage`) so framework layers can - * bind a single runtime object across server rendering and browser hydration. + * browser-only imperative APIs (`tracking`, `trackCurrentPage`) and managed entry fetch methods + * so framework layers can bind a single runtime object across server rendering and browser + * hydration. * * @packageDocumentation */ @@ -27,6 +28,7 @@ export * from '@contentful/optimization-core/runtime' * @internal */ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' +type ManagedEntryFetchMembers = 'fetchContentfulEntry' | 'fetchOptimizedEntry' /** * The single runtime object framework layers (React Web, Angular, etc.) can consume. @@ -36,7 +38,8 @@ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' * actions — safe on server and client) with the browser-only web SDK surface * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} instance * satisfies it by construction; a server / initial-render backing is produced by - * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops. + * {@link createWebSnapshotRuntime}, where the browser-only members are inert no-ops and managed + * entry fetch methods reject until a live SDK is available. * * Every member is safe to reference in any environment: render-time members behave * correctly on the server, and effect-only members are no-ops there (the server never @@ -45,22 +48,32 @@ type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' * @public */ export interface WebOptimizationRuntime - extends OptimizationRuntime, Pick {} + extends + OptimizationRuntime, + Pick {} const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { - enable: () => undefined, - disable: () => undefined, - enableElement: () => undefined, - disableElement: () => undefined, - clearElement: () => undefined, + enable: noop, + disable: noop, + enableElement: noop, + disableElement: noop, + clearElement: noop, +} + +function noop(): undefined { + return undefined +} + +async function rejectSnapshotManagedEntryFetch(): Promise { + return await Promise.reject(new Error('Live SDK needed')) } /** * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. * * @param snapshot - Server-resolved optimization state for the current request. - * @returns A runtime that resolves and reads from the snapshot, with browser-only - * tracking APIs as inert no-ops. + * @returns A runtime that resolves and reads from the snapshot, with browser-only tracking APIs as + * inert no-ops and managed entry fetch methods rejected. * * @remarks * Used by framework providers during server rendering and the initial client render, @@ -73,6 +86,8 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp const runtime = createSnapshotRuntime(snapshot) return Object.assign(runtime, { + fetchContentfulEntry: rejectSnapshotManagedEntryFetch, + fetchOptimizedEntry: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) From bf5a07d9e00107ceec3718160b2111c9cd21383d Mon Sep 17 00:00:00 2001 From: Charles Hudson <265039+phobetron@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:06:21 +0200 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=90=9E=20fix(nextjs):=20Fixing=20Ne?= =?UTF-8?q?xt.js=2015=20export=20bug=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js 15 rejects wildcard re-exports across client boundaries, which breaks live updates and preview panel usage when SDK client entries are compiled into a client graph. Replace Next.js SDK client-boundary wildcard barrels with explicit named exports and keep router entrypoints scoped to router factories and route trackers. Remove the ambiguous package-root export so consumers must choose the correct server, client, router, schema, ESR, request-handler, or tracking subpath. Add a shared build-tools guard that fails when a `"use client"` file contains `export *`, and run it from the React Web and Next.js SDK builds. Update tests and documentation for the new import boundaries. BREAKING CHANGE: `@contentful/optimization-nextjs` no longer exports from the package root. Use explicit subpaths such as `/app-router`, `/pages-router`, `/pages-router/server`, `/client`, `/server`, `/api-schemas`, `/esr`, `/request-handler`, or `/tracking-attributes`. BREAKING CHANGE: `/app-router` and `/pages-router` no longer re-export generic React Web hooks, providers, or components. Import those from `/client`. [[NT-3614](https://contentful.atlassian.net/browse/NT-3614)] --- documentation/guides/choosing-the-right-sdk.md | 4 ++-- .../web/frameworks/nextjs-sdk/src/app-router-client.test.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index 50b55c3bf..104f08ab3 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -67,8 +67,8 @@ Use this table to choose the primary package and the next integration guide: | Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, managed Contentful entry fetching by ID, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | | Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, managed Contentful entry fetching by ID, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | | React browser app outside Next.js integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering by entry ID, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | -| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs/app-router` | Its `/app-router` bound `OptimizationRoot`, `OptimizedEntry`, and route tracker keep personalized initial HTML before the browser SDK owns reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | +| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs/pages-router` plus `/pages-router/server` | Its `/pages-router` components and `/pages-router/server` helper pass server Optimization state through `pageProps` and avoid duplicate initial page events. | [Integrating the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | | Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution primitives. The entry-source subpath manages baseline-entry or entry-ID source lifecycle while the adapter owns rendering, tracking, and runtime policy. | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | | React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | | Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index c560b29c5..9ab7469c1 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -1,3 +1,4 @@ +import * as reactWeb from '@contentful/optimization-react-web' import type { Entry } from 'contentful' import * as appRouter from './app-router-client' import * as client from './client' From 18b9a4863b9dc4b2a1c3b28c2479ecb322fe9562 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:36:48 +0700 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=94=A7=20chore(core-sdk):=20Update?= =?UTF-8?q?=20gzip=20budget=20for=20index.mjs=20to=2019100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/universal/core-sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index 9c1eec0fa..288f779f8 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, From 711951b67ca70acf37163ec8401d972c5f18dfa8 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:41:53 +0700 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=94=A7=20chore(web-sdk):=20Update?= =?UTF-8?q?=20gzip=20budgets=20for=20index.mjs=20and=20contentful-optimiza?= =?UTF-8?q?tion-web=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/frameworks/react-web-sdk/package.json | 2 +- packages/web/web-sdk/package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 18e315bdf..387b7fa17 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -122,7 +122,7 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 4700, - "index.mjs": 4000 + "index.mjs": 4010 } } }, diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index 7413a6b73..1d6b96208 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, From 184178d816ac7e49a9f176b40b50408c007ab0a0 Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:59:19 +0700 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=A8=20feat(optimized-entry):=20Refa?= =?UTF-8?q?ctor=20OptimizedEntry=20component=20to=20improve=20error=20hand?= =?UTF-8?q?ling=20and=20loading=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `OptimizedEntryBodyProps` interface for better type safety. - Created `renderOptimizedEntryBody` function to encapsulate rendering logic for different states (error, loading, resolved). - Updated `OptimizedEntry` component to utilize the new rendering function, enhancing readability and maintainability. - Improved handling of duplicate baseline ancestors and loading fallbacks. --- .../src/optimized-entry/OptimizedEntry.tsx | 193 ++++++++++++------ 1 file changed, 126 insertions(+), 67 deletions(-) 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 c9d056a21..abf8caeb9 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, isEmptyVariant, 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 ( - - {isEmptyVariant ? null : resolveChildren(children, entry, renderContext)} - - ) + dataTestId: dataTestIdProp ?? testId, + error: managedEntry.error, + errorFallback, + hasCustomLoadingFallback, + hasDuplicateBaselineAncestor, + loadingFallback, + managedEntry: managedEntry.entry, + renderContext, + snapshot, + targetDisplay, + }) } export default OptimizedEntry From 8b88d03b7dcda6b755872a06af5c856a7014458a Mon Sep 17 00:00:00 2001 From: Lotfi Arif <52082662+Lotfi-Arif@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:49:11 +0700 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=94=A7=20chore(react-web-sdk):=20Up?= =?UTF-8?q?date=20gzip=20budgets=20for=20index.cjs=20and=20index.mjs=20to?= =?UTF-8?q?=204800=20and=204100=20respectively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/frameworks/react-web-sdk/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 387b7fa17..db9936d05 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": 4010 + "index.cjs": 4800, + "index.mjs": 4100 } } },