From 15750481bfacf5d5d9b3d0d0156cbf962f370c86 Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Fri, 3 Jul 2026 14:59:49 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20feat(nexts):=20Introducing=20bou?= =?UTF-8?q?nd=20Optimization=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce createNextjsOptimizationComponents() as the primary Next.js SDK integration surface. The factory binds application config once and returns app-local OptimizationRoot, OptimizationProvider, OptimizedEntry, and route tracker components that resolve to the correct server or client runtime implementation. - Add the root @contentful/optimization-nextjs entrypoint for bound component creation - Add automatic server components that resolve request consent, cookies, headers, server optimization data, entry variants, merge tags, and server-to-browser state handoff - Add the matching client component factory that reuses the same app-local API while removing server-only config - Remove the empty root entrypoint and make the package default runtime point at the client build with a react-server conditional export - Replace page-level NextjsOptimizationState usage with bound root/provider serverOptimizationState handoff - Extend React Web OptimizedEntry render props with getMergeTagValue and export OptimizedEntryRenderContext - Update SSR and hybrid Next.js reference implementations to use app-local bound components for server first paint, browser takeover, tracking, and merge tag rendering - Share entry-card rich text rendering helpers across server and client examples - Update Next.js, React Web, and conceptual docs for the new bound component model, manual escape hatches, consent, locale, profile synchronization, tracking, and merge tag guidance - Add tests for automatic server components and update client and React Web tests for the new render context BREAKING CHANGE: NextjsOptimizationState is removed from the client entrypoint. Pass server optimization data through OptimizationRoot or OptimizationProvider via serverOptimizationState, or use createNextjsOptimizationComponents() to let the bound root/provider handle server-to-browser state handoff. [[NT-3560](https://contentful.atlassian.net/browse/NT-3560)] --- README.md | 22 +- ...anagement-in-the-optimization-sdk-suite.md | 53 +- ...-personalization-and-variant-resolution.md | 7 +- ...king-in-node-and-stateless-environments.md | 99 +- ...-handling-in-the-optimization-sdk-suite.md | 79 +- ...nchronization-between-client-and-server.md | 59 +- .../guides/choosing-the-right-sdk.md | 36 +- ...ptimization-sdk-in-a-nextjs-app-ssr-csr.md | 934 +++++++----------- ...he-optimization-sdk-in-a-nextjs-app-ssr.md | 587 ++++++----- ...rating-the-react-web-sdk-in-a-react-app.md | 52 +- .../nextjs-sdk_hybrid/.env.example | 4 +- implementations/nextjs-sdk_hybrid/README.md | 65 +- .../nextjs-sdk_hybrid/app/layout.tsx | 16 +- .../nextjs-sdk_hybrid/app/page-two/page.tsx | 12 +- .../nextjs-sdk_hybrid/app/page.tsx | 33 +- .../components/EntryCard.tsx | 143 +-- .../components/EntryCardContent.tsx | 101 ++ .../components/LiveEntryCard.tsx | 34 + .../components/PreviewPanel.tsx | 6 +- .../nextjs-sdk_hybrid/lib/hooks.ts | 36 +- .../nextjs-sdk_hybrid/lib/optimization.ts | 50 +- implementations/nextjs-sdk_ssr/.env.example | 2 +- implementations/nextjs-sdk_ssr/AGENTS.md | 4 +- implementations/nextjs-sdk_ssr/README.md | 31 +- implementations/nextjs-sdk_ssr/app/layout.tsx | 16 +- .../nextjs-sdk_ssr/app/page-two/page.tsx | 45 +- implementations/nextjs-sdk_ssr/app/page.tsx | 61 +- .../nextjs-sdk_ssr/components/EntryCard.tsx | 110 +-- .../components/EntryCardContent.tsx | 99 ++ .../components/LiveEntryCard.tsx | 20 +- .../components/PreviewPanel.tsx | 6 +- implementations/nextjs-sdk_ssr/lib/hooks.ts | 12 +- .../nextjs-sdk_ssr/lib/optimization.ts | 33 +- implementations/react-web-sdk/README.md | 17 +- implementations/react-web-sdk/src/App.tsx | 12 +- .../src/components/AnalyticsEventDisplay.tsx | 6 +- .../src/components/ControlPanel.tsx | 6 +- .../src/components/RichTextRenderer.tsx | 12 +- .../src/sections/ContentEntry.tsx | 4 +- .../src/components/AnalyticsEventDisplay.tsx | 6 +- .../src/components/ControlPanel.tsx | 40 +- implementations/web-sdk_react/src/main.tsx | 4 +- .../src/optimization/OptimizationProvider.tsx | 4 +- .../src/optimization/hooks/useAnalytics.ts | 6 +- .../hooks/useOptimizationResolver.ts | 6 +- .../src/sections/ContentEntry.tsx | 6 +- .../src/sections/LiveUpdatesExampleEntry.tsx | 6 +- lib/e2e-web/README.md | 8 +- lib/e2e-web/e2e/ssr.spec.ts | 28 +- packages/AGENTS.md | 3 + packages/universal/core-sdk/package.json | 16 +- packages/universal/core-sdk/rslib.config.ts | 1 + .../core-sdk/src/CoreStatefulEventEmitter.ts | 2 +- .../src/bridge-support/capabilities.ts | 53 +- .../bridge-support/coreBridgeCapabilities.ts | 62 ++ .../core-sdk/src/bridge-support/index.ts | 2 +- .../universal/core-sdk/src/consent/Consent.ts | 13 +- .../src/runtime/OptimizationRuntime.ts | 33 + .../src/runtime/SnapshotRuntime.test.ts | 87 ++ .../core-sdk/src/runtime/SnapshotRuntime.ts | 249 +++++ .../universal/core-sdk/src/runtime/index.ts | 12 + .../core-sdk/src/signals/Observable.ts | 27 + packages/web/frameworks/nextjs-sdk/README.md | 134 ++- .../web/frameworks/nextjs-sdk/package.json | 22 +- .../web/frameworks/nextjs-sdk/rslib.config.ts | 2 +- .../nextjs-sdk/src/automatic-server.test.tsx | 302 ++++++ .../nextjs-sdk/src/automatic-server.tsx | 263 +++++ .../nextjs-sdk/src/bound-component-types.ts | 8 + .../frameworks/nextjs-sdk/src/client.test.tsx | 145 +-- .../web/frameworks/nextjs-sdk/src/client.ts | 124 ++- .../web/frameworks/nextjs-sdk/src/index.ts | 16 - .../nextjs-sdk/src/runtime-types.test.ts | 4 - .../nextjs-sdk/src/server-entry-renderer.tsx | 55 ++ .../frameworks/nextjs-sdk/src/server.test.tsx | 73 -- .../web/frameworks/nextjs-sdk/src/server.tsx | 55 -- .../web/frameworks/react-web-sdk/README.md | 24 +- .../dev/app/sections/HookEntrySection.tsx | 9 +- .../dev/app/sections/ProvidersSection.tsx | 8 +- .../src/context/OptimizationContext.tsx | 6 +- .../context/OptimizationContext.types.test.ts | 6 +- .../src/hooks/useMergeTagResolver.test.tsx | 2 +- .../src/hooks/useOptimization.ts | 9 +- .../src/hooks/useOptimizationState.ts | 2 +- .../react-web-sdk/src/index.test.tsx | 31 +- .../web/frameworks/react-web-sdk/src/index.ts | 1 + .../optimized-entry/OptimizedEntry.test.tsx | 55 +- .../OptimizedEntry.testUtils.tsx | 1 + .../src/optimized-entry/OptimizedEntry.tsx | 15 +- .../optimized-entry/optimizedEntryUtils.ts | 14 +- .../useOptimizedEntry.test.tsx | 4 +- .../src/optimized-entry/useOptimizedEntry.ts | 36 +- .../src/provider/LiveUpdatesProvider.tsx | 6 +- ...ptimizationProvider.onStatesReady.test.tsx | 118 ++- .../src/provider/OptimizationProvider.tsx | 76 +- .../src/router/next-app.test.tsx | 2 +- .../src/router/next-pages.test.tsx | 2 +- .../src/router/react-router.test.tsx | 2 +- .../src/router/tanstack-router.test.tsx | 2 +- .../react-web-sdk/src/runtime/webRuntime.ts | 28 + .../react-web-sdk/src/test/sdkTestUtils.tsx | 4 +- packages/web/web-sdk/package.json | 12 + packages/web/web-sdk/rslib.config.ts | 2 + .../web/web-sdk/src/ContentfulOptimization.ts | 3 +- packages/web/web-sdk/src/constants.ts | 9 + packages/web/web-sdk/src/index.ts | 1 + .../presentation/OptimizedEntryController.ts | 117 ++- .../OptimizedEntryTrackingAttributes.ts | 26 + .../web/web-sdk/src/presentation/index.ts | 6 + .../presentation/optimizationRootRuntime.ts | 63 ++ packages/web/web-sdk/src/runtime.ts | 7 + 110 files changed, 3469 insertions(+), 2041 deletions(-) create mode 100644 implementations/nextjs-sdk_hybrid/components/EntryCardContent.tsx create mode 100644 implementations/nextjs-sdk_hybrid/components/LiveEntryCard.tsx create mode 100644 implementations/nextjs-sdk_ssr/components/EntryCardContent.tsx create mode 100644 packages/universal/core-sdk/src/bridge-support/coreBridgeCapabilities.ts create mode 100644 packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts create mode 100644 packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts create mode 100644 packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts create mode 100644 packages/universal/core-sdk/src/runtime/index.ts create mode 100644 packages/web/frameworks/nextjs-sdk/src/automatic-server.test.tsx create mode 100644 packages/web/frameworks/nextjs-sdk/src/automatic-server.tsx create mode 100644 packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts delete mode 100644 packages/web/frameworks/nextjs-sdk/src/index.ts create mode 100644 packages/web/frameworks/nextjs-sdk/src/server-entry-renderer.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts create mode 100644 packages/web/web-sdk/src/runtime.ts diff --git a/README.md b/README.md index 14f0b5170..c6d956d42 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,17 @@ that surface. The published package surface is intentionally layered. The table below is a package inventory and high-level role summary. -| Package | Kind | Runtime | Role | Package README | -| -------------------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `@contentful/optimization-web` | Environment SDK | Browser | Stateful browser SDK for optimization, tracking, and consent | [Web SDK](./packages/web/web-sdk/README.md) | -| `@contentful/optimization-react-web` | Framework SDK | React on the web | React integration layer on top of the Web SDK | [React Web SDK](./packages/web/frameworks/react-web-sdk/README.md) | -| `@contentful/optimization-nextjs` | Framework SDK | Next.js | Next.js adapter for SSR, client tracking, and request composition | [Next.js SDK](./packages/web/frameworks/nextjs-sdk/README.md) | -| `@contentful/optimization-node` | Environment SDK | Node.js | Stateless Node SDK for server-side and SSR integrations | [Node SDK](./packages/node/node-sdk/README.md) | -| `@contentful/optimization-react-native` | Environment SDK | React Native | React Native SDK for mobile applications | [React Native SDK](./packages/react-native-sdk/README.md) | -| `@contentful/optimization-web-preview-panel` | Tooling package | Browser | Preview tooling package for existing Web SDK instances | [Web Preview Panel](./packages/web/preview-panel/README.md) | -| `@contentful/optimization-core` | Shared SDK foundation | Runtime-agnostic | Shared optimization foundation for runtime adapters and SDK layers | [Core SDK](./packages/universal/core-sdk/README.md) | -| `@contentful/optimization-api-client` | Universal library | Runtime-agnostic | Direct Experience API and Insights API client library | [API Client](./packages/universal/api-client/README.md) | -| `@contentful/optimization-api-schemas` | Universal library | Runtime-agnostic | Validation schemas and inferred API/content types library | [API Schemas](./packages/universal/api-schemas/README.md) | +| Package | Kind | Runtime | Role | Package README | +| -------------------------------------------- | --------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `@contentful/optimization-web` | Environment SDK | Browser | Stateful browser SDK for optimization, tracking, and consent | [Web SDK](./packages/web/web-sdk/README.md) | +| `@contentful/optimization-react-web` | Framework SDK | React on the web | React integration layer on top of the Web SDK | [React Web SDK](./packages/web/frameworks/react-web-sdk/README.md) | +| `@contentful/optimization-nextjs` | Framework SDK | Next.js | Next.js adapter for bound components, automatic App Router composition, SSR, client tracking, and request composition | [Next.js SDK](./packages/web/frameworks/nextjs-sdk/README.md) | +| `@contentful/optimization-node` | Environment SDK | Node.js | Stateless Node SDK for server-side and SSR integrations | [Node SDK](./packages/node/node-sdk/README.md) | +| `@contentful/optimization-react-native` | Environment SDK | React Native | React Native SDK for mobile applications | [React Native SDK](./packages/react-native-sdk/README.md) | +| `@contentful/optimization-web-preview-panel` | Tooling package | Browser | Preview tooling package for existing Web SDK instances | [Web Preview Panel](./packages/web/preview-panel/README.md) | +| `@contentful/optimization-core` | Shared SDK foundation | Runtime-agnostic | Shared optimization foundation for runtime adapters and SDK layers | [Core SDK](./packages/universal/core-sdk/README.md) | +| `@contentful/optimization-api-client` | Universal library | Runtime-agnostic | Direct Experience API and Insights API client library | [API Client](./packages/universal/api-client/README.md) | +| `@contentful/optimization-api-schemas` | Universal library | Runtime-agnostic | Validation schemas and inferred API/content types library | [API Schemas](./packages/universal/api-schemas/README.md) | General selection rules: 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 91d93c603..c56dc5359 100644 --- a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md @@ -83,26 +83,26 @@ the affected method calls or forwarding code in the application layer. Use the runtime surface that matches where the consent decision is applied: -| Runtime | Consent API surface | Storage and persistence | Default allow-list | Blocked-event diagnostics | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| **Web** | `defaults.consent`, `defaults.persistenceConsent`, and `optimization.consent(true \| false \| { events, persistence })` | Browser `localStorage`; readable `ctfl-opt-aid` cookie for profile continuity when persistence consent permits it. | `identify` and `page` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `optimization.states.blockedEventStream` | -| **React Web** | `OptimizationRoot` `defaults` and `useOptimizationActions().consent(true \| false \| { events, persistence })`; injected SDKs can call `sdk.consent(...)` | Same Web SDK storage: browser `localStorage` and the readable `ctfl-opt-aid` cookie when persistence consent permits it. | `identify` and `page` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `states.blockedEventStream` | -| **Next.js** | Server `getNextjsServerOptimizationData(..., { consent })`, ESR `getNextjsEsrOptimizationData(..., { consent })`, and client `OptimizationRoot` | Server and ESR paths use application-owned cookies and request state; client paths use React Web storage. | Server, ESR, and client paths follow `identify` and `page` defaults unless `allowedEventTypes` changes. | Server-side `onEventBlocked`; client `onEventBlocked` and `states.blockedEventStream` | -| **Node** | Request-scoped `optimization.forRequest({ consent })` | No SDK storage; applications own cookies, sessions, consent records, and profile ID persistence. | `identify` and `page` can emit before request event consent unless `allowedEventTypes` changes. | `onEventBlocked` only | -| **React Native** | `OptimizationRoot` `defaults` and `useOptimization().consent(true \| false \| { events, persistence })` | AsyncStorage persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `states.blockedEventStream` | -| **iOS** | `StorageDefaults(consent:)`, `client.consent(_:)`, and `client.consent(events:persistence:)` | UserDefaults persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `OptimizationConfig.onEventBlocked` and `client.blockedEventStream` | -| **Android** | `StorageDefaults(consent = true)`, `client.consent(true \| false)`, and `client.consent(events = true, persistence = false)` | SharedPreferences persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `OptimizationConfig.onEventBlocked` and `client.blockedEventStream` | +| Runtime | Consent API surface | Storage and persistence | Default allow-list | Blocked-event diagnostics | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| **Web** | `defaults.consent`, `defaults.persistenceConsent`, and `optimization.consent(true \| false \| { events, persistence })` | Browser `localStorage`; readable `ctfl-opt-aid` cookie for profile continuity when persistence consent permits it. | `identify` and `page` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `optimization.states.blockedEventStream` | +| **React Web** | `OptimizationRoot` `defaults` and `useOptimizationActions().consent(true \| false \| { events, persistence })`; injected SDKs can call `sdk.consent(...)` | Same Web SDK storage: browser `localStorage` and the readable `ctfl-opt-aid` cookie when persistence consent permits it. | `identify` and `page` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `states.blockedEventStream` | +| **Next.js** | Preferred App Router `createNextjsOptimizationComponents({ server: { enabled: true, consent } })`; manual server `getNextjsServerOptimizationData(..., { consent })`, ESR `getNextjsEsrOptimizationData(..., { consent })`, and client `OptimizationRoot` | Server and ESR paths use application-owned cookies and request state; client paths use React Web storage. | Server, ESR, and client paths follow `identify` and `page` defaults unless `allowedEventTypes` changes. | Server-side `onEventBlocked`; client `onEventBlocked` and `states.blockedEventStream` | +| **Node** | Request-scoped `optimization.forRequest({ consent })` | No SDK storage; applications own cookies, sessions, consent records, and profile ID persistence. | `identify` and `page` can emit before request event consent unless `allowedEventTypes` changes. | `onEventBlocked` only | +| **React Native** | `OptimizationRoot` `defaults` and `useOptimization().consent(true \| false \| { events, persistence })` | AsyncStorage persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `onEventBlocked` and `states.blockedEventStream` | +| **iOS** | `StorageDefaults(consent:)`, `client.consent(_:)`, and `client.consent(events:persistence:)` | UserDefaults persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `OptimizationConfig.onEventBlocked` and `client.blockedEventStream` | +| **Android** | `StorageDefaults(consent = true)`, `client.consent(true \| false)`, and `client.consent(events = true, persistence = false)` | SharedPreferences persists consent and, when persistence consent permits it, profile-continuity state across launches. | `identify` and `screen` can emit before event consent unless `allowedEventTypes` changes. | `OptimizationConfig.onEventBlocked` and `client.blockedEventStream` | ## Choose a pre-consent posture There are three common implementation postures. Choose the runtime column that matches where the SDK is initialized; server paths also bind consent on each request: -| Posture | Web, React Web, and React Native | iOS | Android | Node and Next.js server paths | Use when | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| **Strict opt-in** | Initialize with `allowedEventTypes: []`; leave `defaults.consent` unset and persistence consent unset or false. | Pass `allowedEventTypes: []`; do not pass `StorageDefaults(consent: true)`; keep persistence consent unset or false. | Pass `allowedEventTypes = emptyList()`; do not pass `StorageDefaults(consent = true)`; keep persistence consent false. | Configure the singleton with `allowedEventTypes: []`; bind `forRequest({ consent: { events: false, persistence: false } })` or the equivalent Next server/ESR helper option. | Policy does not permit non-essential event emission or durable profile-continuity storage before opt-in. | -| **Limited pre-consent context** | Leave consent unset; use the runtime default or custom `allowedEventTypes`; keep persistence consent unset or false. | Don't pass `StorageDefaults(consent: true)`; pass a narrow `allowedEventTypes` list; keep persistence consent false. | Don't pass `StorageDefaults(consent = true)`; pass a narrow `allowedEventTypes` list; keep persistence consent false. | Configure a narrow singleton `allowedEventTypes`; derive per-request `forRequest({ consent })` or Next server/ESR helper consent from application request state. | Legal review permits specific first-party context events before broader tracking consent. | -| **Default-on accepted context** | Seed `defaults.consent: true`; set `defaults.persistenceConsent: false` only when profile continuity must be deferred. | Seed `StorageDefaults(consent: true)`; set `persistenceConsent: false` only when profile continuity must be deferred. | Seed `StorageDefaults(consent = true)`; set `persistenceConsent = false` only when profile continuity must be deferred. | Bind accepted request consent with `forRequest({ consent: { events: true, persistence: true } })` or the equivalent Next server/ESR helper option. | Application policy permits SDK event emission and profile continuity at startup, with or without a separate end-user consent UI. | +| Posture | Web, React Web, and React Native | iOS | Android | Node and Next.js server paths | Use when | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Strict opt-in** | Initialize with `allowedEventTypes: []`; leave `defaults.consent` unset and persistence consent unset or false. | Pass `allowedEventTypes: []`; do not pass `StorageDefaults(consent: true)`; keep persistence consent unset or false. | Pass `allowedEventTypes = emptyList()`; do not pass `StorageDefaults(consent = true)`; keep persistence consent false. | Configure the singleton with `allowedEventTypes: []`; bind `forRequest({ consent: { events: false, persistence: false } })`, set Next App Router `server.consent` to the same value, or pass the equivalent server/ESR helper option. | Policy does not permit non-essential event emission or durable profile-continuity storage before opt-in. | +| **Limited pre-consent context** | Leave consent unset; use the runtime default or custom `allowedEventTypes`; keep persistence consent unset or false. | Don't pass `StorageDefaults(consent: true)`; pass a narrow `allowedEventTypes` list; keep persistence consent false. | Don't pass `StorageDefaults(consent = true)`; pass a narrow `allowedEventTypes` list; keep persistence consent false. | Configure a narrow singleton `allowedEventTypes`; derive consent for `forRequest({ consent })`, Next App Router `server.consent`, or server/ESR helper options from application request state. | Legal review permits specific first-party context events before broader tracking consent. | +| **Default-on accepted context** | Seed `defaults.consent: true`; set `defaults.persistenceConsent: false` only when profile continuity must be deferred. | Seed `StorageDefaults(consent: true)`; set `persistenceConsent: false` only when profile continuity must be deferred. | Seed `StorageDefaults(consent = true)`; set `persistenceConsent = false` only when profile continuity must be deferred. | Bind accepted request consent with `forRequest({ consent: { events: true, persistence: true } })`, set Next App Router `server.consent` to accepted consent, or pass the equivalent server/ESR helper option. | Application policy permits SDK event emission and profile continuity at startup, with or without a separate end-user consent UI. | For browser applications, storage policy can matter as much as event policy. `allowedEventTypes: []` prevents pre-consent event emission, while persistence consent gates durable profile-continuity @@ -176,7 +176,25 @@ unset and call the runtime's boolean or split-consent API from the CMP or banner ### Node and stateless runtimes The Node SDK does not store consent. Next.js server and ESR paths use this same request-scoped model -through adapter helpers. When the application policy permits Optimization by default, bind each +through adapter helpers. For App Router integrations, prefer the package root factory and put the +request policy in `server.consent`: + +Next.js App Router / TypeScript: + +```ts +createNextjsOptimizationComponents({ + clientId: 'your-client-id', + environment: 'main', + server: { enabled: true, consent }, +}) +``` + +`server.consent` can be a boolean, object-form consent, or resolver that receives +`{ cookies, headers }`. The bound server root or provider resolves it, calls the server page path, +and passes consent-derived client defaults and server optimization state through. Manual +`getNextjsServerOptimizationData(..., { consent })` and ESR +`getNextjsEsrOptimizationData(..., { consent })` remain lower-level paths when the application owns +request binding directly. When the application policy permits Optimization by default, bind each request with accepted event and persistence consent: Node / TypeScript: @@ -378,6 +396,8 @@ server and the Web or React Web SDK in the browser. Use the same consent decisio - The server reads consent before calling `page()`, `identify()`, or follow-up tracking methods and calls the Node SDK only when the application policy allows the server event. +- Next.js App Router bound components read consent from `server.consent`; manual server and ESR + paths pass the same decision through their `{ consent }` option. - The browser calls `consent(true)` or `consent(false)` from the same CMP state after hydration. - Both runtimes clear profile continuity when withdrawal requires it. - The server does not set `ctfl-opt-aid` when the browser is not allowed to use profile continuity. @@ -467,6 +487,9 @@ Before releasing a consent-aware Optimization SDK integration, verify these impl - Bind Node SDK calls with `forRequest()`; use `allowedEventTypes` only for intentionally permitted pre-consent server events, and never persist returned IDs unless `requestOptimization.canPersistProfile` is true. +- For Next.js App Router, put the same request policy in `server.consent` on + `createNextjsOptimizationComponents()`; use manual server or ESR `{ consent }` options only when + direct request binding is needed. - Clear active in-memory profile state with the runtime reset method and server profile cookies when revocation requires it. - Use JavaScript or React Native `consent({ events, persistence })`, iOS diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index be3cb69ee..432f112b9 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -88,8 +88,11 @@ the runtime: | iOS | `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` | SwiftUI `OptimizedEntry`; UIKit can call the client directly | | Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` | -Next.js uses the Node server and React Web client surfaces, plus Next.js adapter components such as -`ServerOptimizedEntry` for server-rendered entries. +For Next.js App Router integrations, prefer the app-local bound `OptimizedEntry` returned by +`createNextjsOptimizationComponents()`. In Server Components it resolves through the Node SDK and +server data; in Client Components the same app-local name resolves through React Web. Routes that +resolve entries manually can call `getServerTrackingAttributes()` when they need SSR tracking +attributes. ## Inputs and constraints 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 b7b8e6f73..9b25fb025 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -51,13 +51,13 @@ between server and browser, see Choose the runtime path before designing the event flow. The SDK that renders or observes the 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-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: server helpers and `ServerOptimizedEntry` from `@contentful/optimization-nextjs/server`, request helpers from `@contentful/optimization-nextjs/request-handler`, tracking helpers from `@contentful/optimization-nextjs/tracking-attributes`, and client wrappers from `@contentful/optimization-nextjs/client`. | Next.js apps need server-owned personalization, request and cookie helpers, SSR tracking attributes, and client tracking boundaries. | -| First-party browser collector plus Node SDK | Observe browser interactions in application code, post observations to an app endpoint, validate policy, and call request-bound Node SDK tracking methods. | The browser cannot run the Web SDK, but the app can own DOM observation, payload mapping, profile continuity, and retries. | +| 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-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: the preferred root `createNextjsOptimizationComponents()` factory returns app-local bound roots, providers, route trackers, and `OptimizedEntry`; lower-level server, request, tracking-attribute, and client helpers remain available for manual paths. | Next.js App Router apps need server-owned personalization, automatic profile handoff, SSR tracking attributes, and client tracking boundaries. | +| First-party browser collector plus Node SDK | Observe browser interactions in application code, post observations to an app endpoint, validate policy, and call request-bound Node SDK tracking methods. | The browser cannot run the Web SDK, but the app can own DOM observation, payload mapping, profile continuity, and retries. | ## Constraints that decide delivery @@ -74,11 +74,13 @@ Apply these constraints before choosing server-only, hybrid, or manual tracking: entry views and flag views before consent; `flag` narrows pre-consent admission to Custom Flag views without entry views. - Browser Insights delivery needs a current Web SDK profile. In direct Web SDK initialization, the - profile can come from `defaults.profile`. In React Web and Next.js provider handoff, pass - server-returned Optimization data through `serverOptimizationState`. In Next.js page-level - handoff, render `NextjsOptimizationState` under existing SDK context. The profile can also come - from browser-persisted profile state that persistence consent allows the SDK to load, or a browser - Experience API call such as `page()`, `identify()`, `track()`, or sticky `trackView()`. + profile can come from `defaults.profile`. In React Web provider handoff and manual Next.js + provider or root setup, pass server-returned Optimization data through `serverOptimizationState`. + In the preferred Next.js bound setup, the server root or provider from + `createNextjsOptimizationComponents()` hands off server data for the browser side automatically. + The profile can also come from browser-persisted profile state that persistence consent allows the + SDK to load, or a browser Experience API call such as `page()`, `identify()`, `track()`, or sticky + `trackView()`. - Browser storage is best-effort. The Web SDK uses `localStorage` and the `ctfl-opt-aid` cookie when persistence consent permits continuity; if storage fails or is unavailable, continuity is limited to in-memory state. @@ -311,13 +313,14 @@ of tracking that can only be measured in the browser. ### Render tracking metadata on resolved entries -Use SDK helpers when available instead of copying the attribute map into application code. In -Next.js, `ServerOptimizedEntry` renders the Web SDK tracking attributes from the baseline entry and -the `ResolvedData` returned by `resolveOptimizedEntry()`. For custom SSR wrappers, call -`getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes`. Non-Next -runtimes can call `resolveOptimizedEntryTrackingAttributes()` from -`@contentful/optimization-web/tracking-attributes` when they already have the same baseline entry -and resolved data shape. +Use SDK wrappers or helpers when available instead of copying the attribute map into application +code. In Next.js App Router integrations, the preferred wrapper is the app-local bound +`OptimizedEntry` returned by `createNextjsOptimizationComponents()`. In Server Components, it +resolves the baseline entry, renders the server-selected entry, and emits the Web SDK tracking +attributes through server internals. For custom SSR wrappers, call `getServerTrackingAttributes()` +from `@contentful/optimization-nextjs/tracking-attributes`. Non-Next runtimes can call +`resolveOptimizedEntryTrackingAttributes()` from `@contentful/optimization-web/tracking-attributes` +when they already have the same baseline entry and resolved data shape. ```tsx import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' @@ -393,9 +396,10 @@ delivery. Choose one of these patterns before enabling interaction tracking: - **Bootstrap the server profile.** For direct Web SDK initialization, serialize the `profile` returned by the server's `page()` or `identify()` call and pass it as `defaults.profile`. For - React Web and Next.js, pass the server `OptimizationData` through `serverOptimizationState`, or - render `NextjsOptimizationState` under an existing SDK context when a Next.js page owns the data. - Use this when the same server response already rendered personalized HTML from that profile. + React Web and manual Next.js provider or root setup, pass the server `OptimizationData` through + `serverOptimizationState`. Preferred Next.js bound roots and providers hand off server data + automatically. Use this when the same server response already rendered personalized HTML from that + profile. - **Re-evaluate in the browser.** Persist `ctfl-opt-aid` on the server, initialize the Web SDK in the browser, call `page()` after your consent policy allows it, then enable tracking after the page response populates browser profile state. @@ -406,9 +410,9 @@ delivery. Choose one of these patterns before enabling interaction tracking: In Next.js SSR integrations, `initialPageEvent="skip"` intentionally avoids the initial browser Experience API `page()` request when the server already emitted that page event. If that skip leaves -the browser without `serverOptimizationState` or `NextjsOptimizationState`, and without a prior -persisted browser profile, automatic entry views, clicks, and hovers cannot deliver until a later -browser Experience API call populates profile state. +the browser with neither a bound server root or provider handoff nor manual +`serverOptimizationState`, and without a prior persisted browser profile, automatic entry views, +clicks, and hovers cannot deliver until a later browser Experience API call populates profile state. If the Web SDK must read `ctfl-opt-aid`, do not mark that cookie as `HttpOnly`. Configure `path`, `domain`, and `SameSite` so the server route and browser code refer to the same profile. @@ -432,38 +436,45 @@ remains server-owned. The [Next.js SDK SSR reference implementation](../../implementations/nextjs-sdk_ssr/README.md) is one concrete example of the same tracking-only browser pattern. In Next.js, prefer the -`@contentful/optimization-nextjs` adapter subpaths so app code uses the adapter's server, -request-handler, and client entries rather than wiring the lower-level Node and React Web packages -directly. The same ownership guidance applies to any React-based meta-framework that can render -React code on the server and hydrate part of that tree in the browser. +`@contentful/optimization-nextjs` package root factory so app code imports app-local bound +`OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route trackers from one binding +module. Use adapter subpaths for manual server, tracking-attribute, request, or client control when +the bound App Router path does not fit. The same ownership guidance applies to any React-based +meta-framework that can render React code on the server and hydrate part of that tree in the +browser. Keep personalization server-owned by enforcing these boundaries: -- Server-only modules, routes, loaders, middleware, actions, or Server Components import server-only - SDK entrypoints, call the request-bound page path, call `sdk.resolveOptimizedEntry(...)`, and - render plain React elements. In Next.js, those imports come from - `@contentful/optimization-nextjs/server` and `@contentful/optimization-nextjs/request-handler`. +- Next.js App Router Server Components can import the app-local bound `OptimizationRoot`, + `OptimizationProvider`, `OptimizedEntry`, and route trackers from the binding module created by + `createNextjsOptimizationComponents()`. The bound server root or provider owns request data and + profile handoff, and the bound server `OptimizedEntry` resolves and renders tracked entries. +- Lower-level Next.js server modules can still import `/server` helpers, call the request-bound page + path, call `sdk.resolveOptimizedEntry(...)`, and render attributes from + `getServerTrackingAttributes()` when the app needs manual request control. - Server-rendered entry wrappers include adapter/server-generated `data-ctfl-*` tracking attributes, so the browser tracking runtime can observe them after hydration. -- Client-only modules import browser entrypoints for `OptimizationRoot`, router page tracking, - consent controls, identify controls, and automatic interaction tracking. In Next.js, those imports - come from `@contentful/optimization-nextjs/client`. -- `OptimizationRoot` and router page trackers stay behind the framework's client-only boundary, so +- Client-only modules use the same app-local bound exports for the preferred Next.js path. Use + `/client` imports when a manual browser-only setup needs direct browser entrypoints. +- Bound `OptimizationRoot` and route trackers stay behind the framework's client-only boundary, so the browser runtime is not instantiated during SSR. Next.js App Router can render the adapter's Client Component exports from a Server Component layout; other frameworks need the equivalent client-only island, lazy hydration, or browser-only wrapper. -- The client tree does not use `OptimizedEntry`, `useOptimizedEntry`, or browser-side - `resolveOptimizedEntry()`. +- Client Components that only hydrate controls around server-rendered entries do not re-render those + same entries through client `OptimizedEntry`, `useOptimizedEntry`, or browser-side + `resolveOptimizedEntry()`. Use the bound client `OptimizedEntry` only for live or browser-owned + surfaces that intentionally resolve variants after startup. - Client rendering does not consume `defaults.selectedOptimizations` or `states.selectedOptimizations` to choose entry variants. When persistence consent is true, the Web SDK can load persisted selected optimizations for state continuity, but tracking-only client code must not use browser selected-optimization state to render already server-rendered entries. -This split avoids the common accidental-client-personalization path in React apps. `OptimizedEntry` -is the React Web SDK's browser-personalization component; using it in a hydrated client tree can -cause the browser to resolve variants from client SDK state. For server-only personalization, render -the resolved entry in the server-owned render path and use the client SDK only for tracking and -controls. +This split avoids the common accidental-client-personalization path in React apps. In Next.js, the +app-local `OptimizedEntry` has server behavior in Server Components and client behavior in Client +Components. Use it for server-owned entries from Server Components. In hydrated Client Components, +using `OptimizedEntry` means browser resolution from client SDK state. For server-only +personalization, render the resolved entry in the server-owned render path and use the client SDK +only for tracking and controls. After hydration, client actions can still update the profile. For example, `sdk.identify()` can associate the visitor with a known user, and `sdk.consent(true)` can allow interaction tracking. 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 b46048c27..e795fa3d2 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -39,15 +39,15 @@ package setup, use the relevant integration guide or package README. Each runtime exposes the SDK Experience/event locale through its own API surface: -| Runtime | Locale API surface | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Web** | `new ContentfulOptimization({ locale })`, `optimization.locale`, `optimization.states.locale`, `optimization.setLocale(locale)`, and `` | -| **React Web** | ``, provider-owned ``, and `useOptimization()` access to `sdk.locale`, `sdk.states.locale`, and `sdk.setLocale(locale)` | -| **Next.js** | Server `createNextjsOptimization({ locale })`, request-scoped `getNextjsServerOptimizationData(sdk, { locale })`, ESR `getNextjsEsrOptimizationData(sdk, { locale })`, and client `OptimizationRoot locale` | -| **Node** | `new ContentfulOptimization({ locale })` for a default, `optimization.forRequest({ locale })` for request scope, and `experienceOptions.locale` as an advanced pass-through when request `locale` is absent | -| **React Native** | ``, provider-owned ``, `ContentfulOptimization.create({ locale })`, `sdk.locale`, and `sdk.setLocale(locale)` | -| **iOS** | `OptimizationConfig(locale:)`, `OptimizationRoot(config:)`, `OptimizationClient.locale`, and `OptimizationClient.setLocale(_:)` | -| **Android** | `OptimizationConfig(locale = ...)`, Compose `OptimizationRoot(config = ...)`, XML Views `OptimizationManager.initialize(config = ...)`, `OptimizationClient.locale`, and `OptimizationClient.setLocale(locale)` | +| Runtime | Locale API surface | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Web** | `new ContentfulOptimization({ locale })`, `optimization.locale`, `optimization.states.locale`, `optimization.setLocale(locale)`, and `` | +| **React Web** | ``, provider-owned ``, and `useOptimization()` access to `sdk.locale`, `sdk.states.locale`, and `sdk.setLocale(locale)` | +| **Next.js** | Preferred App Router `createNextjsOptimizationComponents({ locale })` bound components, lower-level server `createNextjsOptimization({ locale })`, request-scoped `getNextjsServerOptimizationData(sdk, { locale })`, ESR `getNextjsEsrOptimizationData(sdk, { locale })`, and manual client `OptimizationRoot locale` | +| **Node** | `new ContentfulOptimization({ locale })` for a default, `optimization.forRequest({ locale })` for request scope, and `experienceOptions.locale` as an advanced pass-through when request `locale` is absent | +| **React Native** | ``, provider-owned ``, `ContentfulOptimization.create({ locale })`, `sdk.locale`, and `sdk.setLocale(locale)` | +| **iOS** | `OptimizationConfig(locale:)`, `OptimizationRoot(config:)`, `OptimizationClient.locale`, and `OptimizationClient.setLocale(_:)` | +| **Android** | `OptimizationConfig(locale = ...)`, Compose `OptimizationRoot(config = ...)`, XML Views `OptimizationManager.initialize(config = ...)`, `OptimizationClient.locale`, and `OptimizationClient.setLocale(locale)` | ## The locale channels @@ -155,13 +155,37 @@ const entry = await contentfulClient.getEntry(entryId, { ## Next.js adapter -Next.js composes the stateless Node SDK on the server with the React Web SDK on the client. The -server default locale comes from `createNextjsOptimization({ locale })`, but per-request locale -binding belongs in `getNextjsServerOptimizationData(sdk, { locale })` for App Router Server -Components or `getNextjsEsrOptimizationData(sdk, { locale })` for request-rendered ESR flows. The -client `OptimizationRoot locale` prop follows the React Web behavior. +Next.js composes the stateless Node SDK on the server with the React Web SDK on the client. For App +Router integrations, prefer the package root `createNextjsOptimizationComponents({ locale })` +factory. It returns app-local bound `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, +and route trackers. Next.js resolves those bound exports to the automatic server implementation in +Server Components and to React Web-backed client exports in Client Components. -Next.js server runtime (TypeScript): +Next.js App Router binding module (TypeScript): + +```ts +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId, + locale: appLocale, + server: { + enabled: true, + consent, + }, + }) +``` + +The bound server root and bound server `OptimizedEntry` use the factory `locale` when they load +server Optimization data. The bound client root applies the same locale through React Web +configuration. + +Keep lower-level/manual surfaces for direct request control. `createNextjsOptimization({ locale })` +sets the server SDK default locale, `getNextjsServerOptimizationData(sdk, { locale })` binds an App +Router Server Component request, `getNextjsEsrOptimizationData(sdk, { locale })` binds a +request-rendered ESR flow, and manual client `OptimizationRoot locale` follows the React Web +behavior. + +Next.js manual server runtime (TypeScript): ```ts const optimization = createNextjsOptimization({ @@ -179,16 +203,15 @@ const { data } = await getNextjsServerOptimizationData(optimization, { export const proxy = createNextjsOptimizationContextHandler() ``` -Use the request-scoped `locale` path when a route can serve different locales. A module-level -`createNextjsOptimization({ locale })` value is a default for the server SDK instance, not the -current request locale. Server Components pass `headers()` to `getNextjsServerOptimizationData()` so -the SDK can derive page context from the request URL captured by the Next.js proxy or middleware -helper. +Use the request-scoped manual `locale` path when a manual route can serve different locales. A +module-level `createNextjsOptimization({ locale })` value is a default for the server SDK instance, +not the current request locale. Manual Server Components pass `headers()` to +`getNextjsServerOptimizationData()` so the SDK can derive page context from the request URL captured +by the Next.js proxy or middleware helper. Locale handoff is separate from server optimization state handoff. When the browser provider has the -server data at its boundary, pass it with `serverOptimizationState` on `OptimizationRoot`. When a -shared App Router layout owns the provider and the page owns request-local data, render -`NextjsOptimizationState` near the server-rendered optimized content. Keep `defaults` for +server data at its boundary, pass it with `serverOptimizationState` on `OptimizationRoot`. With the +automatic Next.js components, the bound server root handles that state handoff. Keep `defaults` for configuration or default state such as consent policy, not for server-returned profile, selected optimizations, or changes. @@ -230,9 +253,12 @@ Pass direct single-locale field values to the runtime-specific entry resolution - Web and Node `resolveOptimizedEntry()`. - React Web and React Native `OptimizedEntry` and `useEntryResolver()`. -- React Web and Next.js client `useOptimizedEntry()`. -- Next.js server `resolveOptimizedEntry()`; pass the baseline entry and returned `ResolvedData` to - `ServerOptimizedEntry` when server-rendered tracking attributes are needed. +- React Web and manually wired Next.js client `useOptimizedEntry()`. +- Next.js bound `OptimizedEntry` from `createNextjsOptimizationComponents()` for App Router Server + and Client Components. +- Manual Next.js server `resolveOptimizedEntry()`; pass the baseline entry and returned + `ResolvedData` to `getServerTrackingAttributes()` when server-rendered tracking attributes are + needed. - iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI `OptimizedEntry(entry:)`. - Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML @@ -252,6 +278,7 @@ Applications own: state. - Passing the Contentful locale to CDA and CPA requests. - Passing the SDK Experience/event locale through top-level SDK `locale`, provider `locale`, Next.js + `createNextjsOptimizationComponents({ locale })`, lower-level Next.js `getNextjsServerOptimizationData({ locale })`, Next.js ESR `getNextjsEsrOptimizationData({ locale })`, native config `locale`, native `setLocale`, or Node `forRequest({ locale })`. diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 3099e2b7b..c31e3c276 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -103,10 +103,11 @@ profile-changing events: - **Configured defaults** - `defaults.profile`, `defaults.selectedOptimizations`, `defaults.changes`, and consent defaults bootstrap one runtime. They do not synchronize server and client state unless both runtimes also use the same profile ID or the same Experience API - response. In React Web and Next.js handoff, keep `defaults` for configuration or default state - such as consent policy, and pass server-returned Optimization data through - `serverOptimizationState` when the provider or root receives the data directly. In Next.js, render - `NextjsOptimizationState` only as a page-level marker under existing SDK context. + response. In React Web provider handoff and manual Next.js provider or root handoff, keep + `defaults` for configuration or default state such as consent policy, and pass server-returned + Optimization data through `serverOptimizationState` when the provider or root receives the data + directly. In automatic Next.js bound components from `createNextjsOptimizationComponents()`, the + bound server root or provider owns that handoff. For consent gates, see [Consent management in the Optimization SDK Suite](./consent-management-in-the-optimization-sdk-suite.md). @@ -140,11 +141,11 @@ The shared cookie is enough when the browser performs personalization after hydr enough when the server already rendered profile-derived HTML and the browser must continue from the same evaluated data before its first client-side Experience response. -| Path | Use when | Browser startup contract | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **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 React Web and Next.js direct provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. For Next.js page-level handoff, render `NextjsOptimizationState` under existing SDK context. | -| **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. | +| Path | Use when | Browser startup contract | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **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 automatic Next.js bound components, the bound server root or provider hands off the server `OptimizationData`. 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. | Direct Web SDK bootstrapping must use the same `OptimizationData` response that drove the server render: @@ -163,11 +164,11 @@ const optimization = new ContentfulOptimization({ If the browser re-resolves entries from stale localStorage while the server rendered from a newer profile evaluation, the user can see a mismatched variant or profile-derived value. For direct Web -SDK initialization, use explicit defaults. For React Web and Next.js, pass the server -`OptimizationData` to `serverOptimizationState` when the provider or root receives the data -directly, or render `NextjsOptimizationState` under an existing SDK context when a Next.js page owns -the data. A fresh client-side `page()` response or a render boundary can also prevent stale cached -state from driving visible content. +SDK initialization, use explicit defaults. Automatic Next.js bound components hand off server +`OptimizationData` through the bound server root or provider. For React Web and manual Next.js +provider or root handoff, pass the server `OptimizationData` to `serverOptimizationState` when the +provider or root receives the data. A fresh client-side `page()` response or a render boundary can +also prevent stale cached state from driving visible content. Personalized HTML is not shared-cache safe unless the cache varies on all personalization inputs. Raw Contentful entries are the safer cache boundary; resolve variants per request or per profile @@ -495,17 +496,17 @@ cookie or session value in the same user flow. The following cases are common sources of profile-sync bugs: -| Case | What happens | Mitigation | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctfl-opt-aid` is `HttpOnly` | The server can read it, but the Web SDK cannot adopt it. | Use a readable cookie for hybrid Node and Web SDK continuity. | -| Cookie domain or path mismatch | The browser and server use different profile IDs or no shared ID. | Set `path: '/'` and a domain that covers the pages that initialize the Web SDK. | -| Cookie differs from localStorage | The Web SDK clears cached profile-continuity data and adopts the cookie ID when persistence consent is `true`. | Treat this as expected when the server changes identity. | -| Cookie changes after SDK construction | The running Web SDK does not continuously watch cookies. | Reinitialize intentionally after teardown or update identity through SDK event flows. | -| Multiple browser tabs | Tabs share storage, but in-memory signals are per runtime and do not auto-sync from storage events. | Let each tab refresh state through Experience events or reload-sensitive application flows. | -| Offline browser Experience events | Events queue locally and no new profile data is available until a successful flush. | Design UI so cached selections are acceptable while offline. | -| Missing browser profile for Insights | Insights delivery is skipped because stateful Insights events use the current profile signal. | Ensure an Experience call has returned a profile before relying on Insights-only tracking. For direct Web SDK initialization, bootstrap a valid `defaults.profile` when the server already evaluated the profile. For React Web and Next.js direct provider handoff, use `serverOptimizationState`. For Next.js page-level handoff, use `NextjsOptimizationState` under existing SDK context. | -| Server uses `preflight` for normal flows | The API evaluates without storing the mutation, which breaks durable profile continuity expectations. | Reserve `preflight` for preview or non-persistent evaluation. | -| Full profile serialized unnecessarily | More profile data reaches the browser than the UI needs. | Share only the profile ID unless hydration needs profile data, changes, or selections. | +| Case | What happens | Mitigation | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` is `HttpOnly` | The server can read it, but the Web SDK cannot adopt it. | Use a readable cookie for hybrid Node and Web SDK continuity. | +| Cookie domain or path mismatch | The browser and server use different profile IDs or no shared ID. | Set `path: '/'` and a domain that covers the pages that initialize the Web SDK. | +| Cookie differs from localStorage | The Web SDK clears cached profile-continuity data and adopts the cookie ID when persistence consent is `true`. | Treat this as expected when the server changes identity. | +| Cookie changes after SDK construction | The running Web SDK does not continuously watch cookies. | Reinitialize intentionally after teardown or update identity through SDK event flows. | +| Multiple browser tabs | Tabs share storage, but in-memory signals are per runtime and do not auto-sync from storage events. | Let each tab refresh state through Experience events or reload-sensitive application flows. | +| Offline browser Experience events | Events queue locally and no new profile data is available until a successful flush. | Design UI so cached selections are acceptable while offline. | +| Missing browser profile for Insights | Insights delivery is skipped because stateful Insights events use the current profile signal. | Ensure an Experience call has returned a profile before relying on Insights-only tracking. For direct Web SDK initialization, bootstrap a valid `defaults.profile` when the server already evaluated the profile. For automatic Next.js bound components, let the bound server root or provider hand off server data. For React Web and manual Next.js provider or root handoff, use `serverOptimizationState`. | +| Server uses `preflight` for normal flows | The API evaluates without storing the mutation, which breaks durable profile continuity expectations. | Reserve `preflight` for preview or non-persistent evaluation. | +| Full profile serialized unnecessarily | More profile data reaches the browser than the UI needs. | Share only the profile ID unless hydration needs profile data, changes, or selections. | ## Implementation checklist @@ -523,10 +524,10 @@ Use this checklist when implementing a hybrid Node and browser profile flow: - Confirm persistence consent resolves to `true` before expecting the Web SDK to load persisted profile-continuity state or adopt `ctfl-opt-aid`. - Render from the `OptimizationData` response that matches the current identity state. -- Bootstrap direct Web SDK `defaults`, use React Web or Next.js `serverOptimizationState` for direct - provider handoff, or use `NextjsOptimizationState` under existing Next.js SDK context for - page-level handoff, when server-rendered personalized output must match client-side resolution - before the first browser Experience response. +- Bootstrap direct Web SDK `defaults`, use automatic Next.js bound component handoff, or pass + `serverOptimizationState` through React Web or manual Next.js provider or root handoff when + server-rendered personalized output must match client-side resolution before the first browser + Experience response. - Clear both browser state and server persistence when consent revocation must end profile continuity. - Cache raw Contentful delivery payloads, not profile-evaluated SDK responses or personalized HTML diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index c995b247c..08ebeaf09 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -23,11 +23,13 @@ tooling, and platform defaults. Use lower-level packages only when you are build tooling, tests, or first-party integrations that need shared SDK primitives or raw API access. For mixed server and browser applications, use the adapter when one exists. Next.js App Router apps -use `@contentful/optimization-nextjs`; the adapter composes the Node SDK on the server with the -React Web SDK on the client and exposes Next.js-specific `server`, `client`, `request-handler`, and -`esr`, and `tracking-attributes` subpaths. 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. +use `@contentful/optimization-nextjs`; its root import provides +`createNextjsOptimizationComponents()`, an automatic factory that returns app-local bound +`OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route trackers for Server and +Client Components. 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. Angular, Vue, Svelte, Web Components, and custom browser framework apps use `@contentful/optimization-web`. Nest.js and other Node server frameworks use @@ -47,18 +49,18 @@ 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 App Router 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 that stays static after hydration | `@contentful/optimization-nextjs` | It uses Next.js server, client, and request-handler entrypoints for SSR personalization, request URL capture, tracking markup, browser-side tracking, and state handoff. | [Integrating the Optimization Next.js SDK in a Next.js app (SSR)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | It keeps the personalized initial HTML and then lets the browser SDK own reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js app (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.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, 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 App Router 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 that stays static after hydration | `@contentful/optimization-nextjs` | Its bound App Router components cover server and client rendering from the root import, while helpers cover SSR personalization, request URL capture, tracking markup, browser tracking, and state handoff. | [Integrating the Optimization Next.js SDK in a Next.js app (SSR)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Its bound `OptimizationRoot`, `OptimizedEntry`, and route trackers 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 (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.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 diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md index 0937c11d1..3be674a1a 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md @@ -4,21 +4,22 @@ Use this guide when you want a Next.js App Router route to personalize the initi optimization state, then let the browser take over entry resolution after server-to-browser state handoff when consent, identity, profile, preview, or route state changes. -This pattern uses `@contentful/optimization-nextjs`. The server entry composes the stateless Node -SDK, the client entry composes the React Web SDK, and 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. +This pattern uses `@contentful/optimization-nextjs`. The package-root factory binds app-local +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. If browser-side actions do not need to change visible content until the next request, use the [Next.js SSR guide](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) instead. ## Quick start -This quick start assumes your application policy permits accepted SDK startup. It uses the Server -Component helper as the only initial page-event owner so the route can render personalized HTML -before browser takeover. If consent depends on a consent management platform (CMP), regional rule, -account preference, or user choice, keep the same structure and apply the policy-dependent consent -section before release. +This quick start assumes your application policy permits accepted SDK startup. It uses the bound +server root as the initial page-event owner so the route can render personalized HTML before browser +takeover. If consent depends on a consent management platform (CMP), regional rule, account +preference, or user choice, keep the same structure and apply the policy-dependent consent section +before release. 1. Install the Next.js adapter package. @@ -28,220 +29,114 @@ section before release. pnpm add @contentful/optimization-nextjs ``` -2. Create one server SDK singleton and a cached request helper that returns server optimization - state for the current request. The Next.js proxy or middleware captures request context for this - helper. +2. Bind app-local SDK components once from the package root. Server Components resolve this import + to the automatic server implementation, and Client Components resolve it to client exports. **Copy this:** ```ts - // lib/optimization-server.ts - import { - createNextjsOptimization, - getNextjsServerOptimizationData, - } from '@contentful/optimization-nextjs/server' - import { cookies, headers } from 'next/headers' - import { cache } from 'react' + // lib/optimization.ts + import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' export const APP_LOCALE = 'en-US' - // Keep one server SDK instance; bind request state through adapter helpers. - export const optimization = createNextjsOptimization({ - clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', - environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', - locale: APP_LOCALE, - api: { - experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL, - insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL, - }, - app: { - name: 'my-next-app', - version: '1.0.0', - }, - logLevel: 'error', - }) - - export const getOptimizationData = cache(async () => { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - - // Accepted startup allows the server page call and returns profile data for browser handoff. - const { data } = await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, + export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', + environment: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', locale: APP_LOCALE, + defaults: { consent: true, persistenceConsent: true }, + server: { + enabled: true, + consent: { events: true, persistence: true }, + }, + api: { + experienceBaseUrl: process.env.NEXT_PUBLIC_CONTENTFUL_EXPERIENCE_API_BASE_URL, + insightsBaseUrl: process.env.NEXT_PUBLIC_CONTENTFUL_INSIGHTS_API_BASE_URL, + }, + app: { + name: 'my-next-app', + version: '1.0.0', + }, + logLevel: 'error', }) - - return data - }) ``` -3. Create a Client Component boundary that renders server content outside the SDK context, then - reveals the browser takeover island after browser SDK state is ready. +3. Forward request context for Server Components that use the bound server path. - **Adapt this to your use case:** + **Copy this:** - ```tsx - // components/HybridTakeoverBoundary.tsx - 'use client' - - import { - NextAppAutoPageTracker, - OptimizationRoot, - type NextAppAutoPageTrackerProps, - type OptimizationRootProps, - } from '@contentful/optimization-nextjs/client' - import { Suspense, useState, type ReactNode } from 'react' - - export function HybridTakeoverBoundary({ - children, - defaults, - initialPageEvent, - locale, - serverContent, - serverOptimizationState, - }: { - children: ReactNode - defaults: OptimizationRootProps['defaults'] - initialPageEvent: NextAppAutoPageTrackerProps['initialPageEvent'] - locale: string - serverContent: ReactNode - serverOptimizationState: OptimizationRootProps['serverOptimizationState'] - }) { - const [takeoverReady, setTakeoverReady] = useState(false) + ```ts + // proxy.ts + import { createNextjsOptimizationContextHandler } from '@contentful/optimization-nextjs/request-handler' - return ( - <> - - { - // Reveal browser takeover only after browser SDK state is ready. - setTakeoverReady(true) - }} - > - - {/* Skip only when this route's server helper emitted the initial page event. */} - - - - - - ) + export const proxy = createNextjsOptimizationContextHandler() + + export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'], } ``` -4. Fetch single-locale Contentful entries in a Server Component, resolve the initial HTML with the - server data, and pass both baseline and server-resolved data to the takeover island. +4. Mount the bound root once in the App Router layout. The bound server root reads request cookies + and headers, calls the server SDK, and passes server optimization state into the browser root. **Adapt this to your use case:** ```tsx - // app/page.tsx - import { HybridEntryList } from '@/components/HybridEntryList' - import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary' - import { fetchEntriesFromContentful } from '@/lib/contentful-client' - import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server' - import type { Entry } from 'contentful' + // app/layout.tsx + import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' + import { Suspense, type ReactNode } from 'react' - function EntryCard({ entry }: { entry: Entry }) { - return

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

- } - - function ServerEntryList({ entries }: { entries: Entry[] }) { + export default async function RootLayout({ children }: { children: ReactNode }) { return ( - <> - {entries.map((entry) => ( - - ))} - - ) - } - - export default async function Home() { - const [baselineEntries, optimizationData] = await Promise.all([ - fetchEntriesFromContentful(['home-hero', 'home-offer']), - getOptimizationData(), - ]) - - // Resolve locally against request-selected optimizations, with baseline fallback. - const serverResolvedData = baselineEntries.map((baselineEntry) => - optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations), - ) - const serverEntries = serverResolvedData.map(({ entry }) => entry) - const defaults = { consent: true } - - return ( - } - serverOptimizationState={optimizationData} - > - - + + + + + {/* The bound server root owns the initial page event for this request. */} + + + {children} + + + ) } ``` -5. Render the browser takeover entries through `OptimizedEntry`. Use the server-resolved entry as - the loading fallback so takeover content matches the initial HTML while the browser SDK becomes - ready. +5. Fetch single-locale Contentful entries in a Server Component and render first-paint content with + the bound `OptimizedEntry`. **Adapt this to your use case:** ```tsx - // components/HybridEntryList.tsx - 'use client' - - import { OptimizedEntry } from '@contentful/optimization-nextjs/client' + // app/page.tsx + import { OptimizedEntry } from '@/lib/optimization' + import { fetchEntriesFromContentful } from '@/lib/contentful-client' import type { Entry } from 'contentful' function EntryCard({ entry }: { entry: Entry }) { return

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

} - export function HybridEntryList({ - baselineEntries, - serverResolvedData, - }: { - baselineEntries: Entry[] - serverResolvedData: { entry: Entry }[] - }) { + export default async function Home() { + const entries = await fetchEntriesFromContentful(['home-hero', 'home-offer']) + return ( <> - {baselineEntries.map((baselineEntry, index) => { - const serverEntry = serverResolvedData[index]?.entry ?? baselineEntry - - // loadingFallback matches the server-selected content during browser takeover. - return ( - } - > - {(resolvedEntry) => } - - ) - })} + {entries.map((entry) => ( + + {(resolvedEntry) => } + + ))} ) } ``` 6. Verify the first run. The route source must contain the server-selected content or baseline - fallback, that content must remain visible after browser takeover, and the browser must not emit - a duplicate initial page event when `initialPageEvent="skip"` is used with server optimization - state. + fallback, that content must remain visible after hydration, and the browser must not emit a + duplicate initial page event when `initialPageEvent="skip"` is used with the bound server root.
Table of Contents @@ -250,11 +145,11 @@ section before release. - [Required setup](#required-setup) - [Core integration](#core-integration) - [Package entry points and runtime boundary](#package-entry-points-and-runtime-boundary) - - [Server SDK and request helper](#server-sdk-and-request-helper) + - [App-local bound components](#app-local-bound-components) - [Proxy request context](#proxy-request-context) - [Contentful fetching and entry shape](#contentful-fetching-and-entry-shape) - [Server first render and fallback behavior](#server-first-render-and-fallback-behavior) - - [Browser root and server optimization state](#browser-root-and-server-optimization-state) + - [Bound root and server optimization state](#bound-root-and-server-optimization-state) - [Client takeover and live re-resolution](#client-takeover-and-live-re-resolution) - [Page events and App Router navigation](#page-events-and-app-router-navigation) - [Entry interaction tracking](#entry-interaction-tracking) @@ -265,6 +160,7 @@ section before release. - [Preview panel](#preview-panel) - [Advanced integrations](#advanced-integrations) - [Route-level SSR, hybrid, and browser-owned islands](#route-level-ssr-hybrid-and-browser-owned-islands) + - [Manual server and client escape hatches](#manual-server-and-client-escape-hatches) - [Caching and request deduplication](#caching-and-request-deduplication) - [Strict consent and duplicate-event controls](#strict-consent-and-duplicate-event-controls) - [Production checks](#production-checks) @@ -278,29 +174,29 @@ section before release. Use this table as the setup inventory for the full hybrid integration: -| Setup item | Category | Required for quick start | Where to configure | -| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------ | -| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` | -| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | -| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and client takeover `OptimizationRoot` props | -| Server-only and browser-exposed environment variables | Required for first integration | Yes | Runtime environment, including `NEXT_PUBLIC_` variables for browser config | -| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | -| 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` | -| Server request helper for Optimization data | Required for first integration | Yes | Server-only page-event owner using `getNextjsServerOptimizationData()` and `cache()` | -| Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` for server helpers that need request context | -| Browser takeover boundary with `OptimizationRoot` | Required for first integration | Yes | Client boundary around takeover content, not around server-rendered initial HTML | -| Server-resolved entry data or baseline fallback | Required for first integration | Yes | Server Components before passing entries to browser takeover components | -| Client takeover with `OptimizedEntry` or `useOptimizedEntry()` | Required for first integration | Yes | Client Components that render personalized entries after state handoff | -| App Router page tracker | Required for first integration | Yes | `NextAppAutoPageTracker` under `OptimizationRoot` | -| Consent and persistence policy | Common but policy-dependent | Conditional | Server calls, browser consent defaults, CMP, or controls | -| Anonymous ID cookie continuity | Common but policy-dependent | Conditional | Server helper cookies, browser state handoff, ESR persistence, and `ctfl-opt-aid` | -| Browser identify, profile state, and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks | -| Entry interaction tracking for views, clicks, and hovers | Common but policy-dependent | No | `trackEntryInteraction`, `OptimizedEntry` props, or server tracking attributes | -| Analytics or tag-manager forwarding | Optional | No | `OptimizationRoot` `onStatesReady` subscription and app-owned forwarding code | -| Merge tag and Custom Flag rendering | Optional | No | Rich Text renderers, flag readers, and live-update components | -| Preview panel package | Optional | No | Environment-gated preview attachment in non-production app environments | -| Strict pre-consent allowlist, storage, queue, and cookie policy | Advanced or production-only | No | SDK config, server helper consent, CMP integration, and application cleanup | -| Personalized response caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, CDN rules, server helper structure, and tracker settings | +| Setup item | Category | Required for quick start | Where to configure | +| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------- | +| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` | +| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | +| App-local bound components from the package root | Required for first integration | Yes | `lib/optimization.ts` with `createNextjsOptimizationComponents()` | +| 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 | +| 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 | +| Bound `OptimizedEntry` for server first-paint/static content | Required for first integration | Yes | Server Components that render Contentful entries | +| App-local `OptimizedEntry` for live/browser surfaces | Required for first integration | No | Client Components that render personalized entries after browser startup | +| App Router page tracker | Required for first integration | Yes | `NextAppAutoPageTracker` under the bound `OptimizationRoot` | +| Consent and persistence policy | Common but policy-dependent | Conditional | `server.consent`, browser consent defaults, CMP, or controls | +| Anonymous ID cookie continuity | Common but policy-dependent | Conditional | Bound server state handoff, ESR persistence, and `ctfl-opt-aid` | +| Browser identify, profile state, and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks | +| Entry interaction tracking for views, clicks, and hovers | Common but policy-dependent | No | Factory `trackEntryInteraction`, `OptimizedEntry` props, or manual browser tracking | +| Analytics or tag-manager forwarding | Optional | No | Factory `onStatesReady` subscription and app-owned forwarding code | +| Merge tag and Custom Flag rendering | Optional | No | `OptimizedEntry` render props, Rich Text renderers, flag readers, and live surfaces | +| Preview panel package | Optional | No | Environment-gated preview attachment in non-production app environments | +| Manual server or client SDK wiring | Advanced or production-only | No | `/server`, `/client`, and explicit `serverOptimizationState` escape hatches | +| Strict pre-consent allowlist, storage, queue, and cookie policy | Advanced or production-only | No | Factory config, `server.consent`, CMP integration, and application cleanup | +| 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 @@ -312,26 +208,30 @@ requests for you. **Integration category:** Required for first integration -The Next.js adapter is a glue package. Keep application imports on the adapter subpaths instead of -importing lower-level Node, Web, or React Web packages directly. +The Next.js adapter is a glue package. App Router integrations should start from the package root +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/server` | Server Components and server-only modules | Server SDK creation, request binding, server resolution, server-owned page calls, and SSR tracking attributes | +| `@contentful/optimization-nextjs` | App-local binding module | Preferred `createNextjsOptimizationComponents()` factory for App Router Server and Client Components | +| `@contentful/optimization-nextjs/client` | Client Components | Browser hooks, manual client providers, live updates helpers, and route trackers | +| `@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/client` | Client Components and takeover islands | React provider, hooks, entry primitives, live updates, and Next.js page route tracker | | `@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. Import server helpers only from the `/server` subpath in server-only modules and Server - Components. -3. Import `OptimizationRoot`, hooks, `OptimizedEntry`, and router trackers only from the `/client` - subpath in Client Components or layouts rendering Client Components. -4. Import `createNextjsOptimizationContextHandler()` only from the `/request-handler` subpath in - proxy or middleware code. -5. Import schema guards and API types from the `/api-schemas` subpath, not from `/client`. -6. Keep Client Components that call SDK hooks marked with `'use client'`. +2. Create an app-local module, commonly `lib/optimization.ts`, that calls + `createNextjsOptimizationComponents()`. +3. Import `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route trackers from the + app-local module in Server and Client Components. +4. Import browser hooks from `/client` only inside Client Components marked with `'use client'`. +5. Import `createNextjsOptimizationContextHandler()` only from `/request-handler` in proxy or + middleware code. +6. Keep `/server` imports for manual server control when the bound App Router factory does not fit a + route. +7. Import schema guards and API types from `/api-schemas`, not from `/client`. **Copy this:** @@ -342,88 +242,76 @@ pnpm add @contentful/optimization-nextjs The adapter package lists Next.js, React, and React DOM as application-owned peer dependencies. It uses the runtime that is already installed by the app. -### Server SDK and request helper +### App-local bound components **Integration category:** Required for first integration -Create the Next.js server SDK once at module level, then bind request-specific cookies, headers, -consent, locale, and profile through request helpers. Use the proxy or middleware context helper so -Server Components can derive page context from forwarded request context headers. +Create the bound components once at module level. The package root resolves to the automatic server +implementation for Server Components and to client exports for Client Components. The bound server +root and bound server `OptimizedEntry` create the server SDK, read `cookies()` and `headers()`, +resolve `server.consent`, call the server page helper, and pass server data through +`serverOptimizationState` internally. -1. Read the Optimization client ID and environment from server-only runtime configuration. +1. Read browser-safe Optimization client ID, environment, locale, and endpoint values in the + app-local binding module. 2. Pass the application Experience/event locale to the SDK singleton. 3. Configure API endpoint overrides only when your app uses mocks, a proxy, or non-default hosts. -4. Wrap `getNextjsServerOptimizationData()` in React `cache()` when more than one Server Component - in the same render pass needs the same request-local Optimization data. -5. Add the request-context proxy or middleware helper for routes that call the server helper. -6. Return `undefined` instead of calling the SDK when application policy does not permit server - personalization for the request. +4. Set `server.enabled: true` and provide `server.consent` when the server path should own first + paint personalization and initial page data. +5. Put application consent policy in `server.consent`; return `false` when the request is not + allowed to personalize. +6. Configure `defaults` for the browser SDK's startup state. +7. Add the request-context proxy or middleware helper for routes that use the bound server path. **Adapt this to your use case:** ```ts -// lib/optimization-server.ts -import { - createNextjsOptimization, - getNextjsServerOptimizationData, -} from '@contentful/optimization-nextjs/server' -import { cookies, headers } from 'next/headers' -import { cache } from 'react' +// lib/optimization.ts +import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' export const APP_LOCALE = 'en-US' const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' -// Keep one server SDK instance; bind request state through adapter helpers. -export const optimization = createNextjsOptimization({ - clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', - environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', - locale: APP_LOCALE, - api: { - experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL, - insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL, - }, - app: { - name: 'my-next-app', - version: '1.0.0', - }, - logLevel: 'error', -}) - -export const getOptimizationData = cache(async () => { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - const appConsent = cookieStore.get(APP_PERSONALIZATION_CONSENT_COOKIE)?.value - - if (appConsent === 'denied') return undefined - - // Accepted startup allows the server page call and returns profile data for browser handoff. - const { data } = await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizationProvider, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', + environment: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', locale: APP_LOCALE, + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: ({ cookies }) => + cookies.get(APP_PERSONALIZATION_CONSENT_COOKIE)?.value === 'granted' + ? { events: true, persistence: true } + : false, + }, + api: { + experienceBaseUrl: process.env.NEXT_PUBLIC_CONTENTFUL_EXPERIENCE_API_BASE_URL, + insightsBaseUrl: process.env.NEXT_PUBLIC_CONTENTFUL_INSIGHTS_API_BASE_URL, + }, + app: { + name: 'my-next-app', + version: '1.0.0', + }, + logLevel: 'error', }) - - return data -}) ``` -`getNextjsServerOptimizationData()` calls `page()` through the request-bound Node SDK and returns -the profile, selected optimizations, and changes for that request. Treat that call as the initial -server page event for the route. The request-scoped `locale` is sent to the Experience API and used -as the default event context locale. In Server Components, pass `headers()` so the SDK can derive -page context from the request URL captured by the Next.js proxy or middleware helper. +The bound server path treats its request-local page call as the initial server page event for the +route. It caches that request data inside one React render pass, so the root and server +`OptimizedEntry` calls share the same profile, selected optimizations, and changes. ### Proxy request context **Integration category:** Common but policy-dependent -Use a Next.js proxy or middleware handler to capture request context before Server Components call -`getNextjsServerOptimizationData()`. +Use a Next.js proxy or middleware handler to capture request context before Server Components use +the bound server root or bound server `OptimizedEntry`. 1. Export the context handler from `proxy.ts` or `middleware.ts`. -2. Match routes whose Server Components call `getNextjsServerOptimizationData()`. -3. Keep consent, locale, and profile policy in the Server Component helper call. +2. Match routes whose Server Components use the bound server path. +3. Keep consent, locale, and profile policy in the app-local component factory. **Adapt this to your use case:** @@ -439,7 +327,7 @@ export const config = { ``` The context handler strips incoming SDK-owned request headers and forwards sanitized request context -headers including the SDK-owned request URL header for the server SDK helper. +headers including the SDK-owned request URL header for the bound server path. Do not mark `ctfl-opt-aid` as `HttpOnly` when the browser SDK must adopt it through browser state handoff or an explicit server persistence flow. For deeper mechanics, see @@ -450,15 +338,15 @@ 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 the server resolver or -client entry primitives. +Contentful locale and resolved optimization links before passing them to bound server or client +entry primitives. 1. Choose the application Contentful locale in routing, i18n, request policy, or app configuration. 2. Pass that locale to CDA requests. 3. Include linked optimization entries and variant entries. The common Contentful CDA setting is `include: 10`. 4. Do not pass all-locale CDA responses from `contentful.js` `withAllLocales` or raw CDA `locale=*` - into `resolveOptimizedEntry()`, `OptimizedEntry`, or `useOptimizedEntry()`. + into bound `OptimizedEntry`, `resolveOptimizedEntry()`, or `useOptimizedEntry()`. 5. Use the same locale as the SDK Experience/event locale when localized Experience responses and rendered content need to match. @@ -466,7 +354,7 @@ client entry primitives. ```ts import { createClient, type Entry } from 'contentful' -import { APP_LOCALE } from './optimization-server' +import { APP_LOCALE } from './optimization' const contentfulClient = createClient({ accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN ?? '', @@ -504,216 +392,135 @@ For the full locale model, see **Integration category:** Required for first integration -Use the server SDK result for the content decision that starts the route. The resolver is local and -synchronous: it joins the current `selectedOptimizations` with optimization links already present in -the Contentful payload. +Use the app-local bound `OptimizedEntry` in Server Components for first-paint or static content. The +server implementation resolves locally against request-selected optimizations and renders tracking +metadata for browser observers. -1. Fetch Contentful entries and request Optimization data in the same Server Component render. -2. Call `optimization.resolveOptimizedEntry()` for each baseline entry. -3. Render the resolved entry outside an owned `OptimizationRoot` boundary when the initial HTML must - contain personalized content. -4. Treat missing optimization data, unresolved links, all-locale CDA payloads, and API failures as - baseline fallback cases. +1. Fetch Contentful entries in the Server Component that owns the route content. +2. Render each baseline entry through the app-local `OptimizedEntry`. +3. Render the `resolvedEntry` from the render prop. +4. Use the render-prop `getMergeTagValue` when the same render path needs merge tags. +5. Treat denied consent, missing optimization data, unresolved links, all-locale CDA payloads, and + API failures as baseline fallback cases. **Adapt this to your use case:** ```tsx // app/page.tsx -import { HybridEntryList } from '@/components/HybridEntryList' -import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary' +import { OptimizedEntry } from '@/lib/optimization' import { fetchEntriesFromContentful } from '@/lib/contentful-client' -import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server' import type { Entry } from 'contentful' -function ServerEntryList({ entries }: { entries: Entry[] }) { - return ( - <> - {entries.map((entry) => ( -

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

- ))} - - ) +function EntryCard({ entry }: { entry: Entry }) { + return

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

} export default async function Home() { - const [baselineEntries, optimizationData] = await Promise.all([ - fetchEntriesFromContentful(['home-hero', 'home-offer']), - getOptimizationData(), - ]) - - // Resolve locally against request-selected optimizations, with baseline fallback. - const serverResolvedData = baselineEntries.map((baselineEntry) => - optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations), - ) - const serverEntries = serverResolvedData.map(({ entry }) => entry) - const defaults = { consent: true } + const entries = await fetchEntriesFromContentful(['home-hero', 'home-offer']) return ( - } - serverOptimizationState={optimizationData} - > - - + <> + {entries.map((entry) => ( + + {(resolvedEntry) => } + + ))} + ) } ``` -When a route renders entry HTML without client takeover, wrap the resolved entry with -`ServerOptimizedEntry` from the server entrypoint so the browser interaction tracker can read the -same `data-ctfl-*` metadata after browser startup. - -**Follow this pattern:** - -```tsx -import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server' +The default App Router path does not need a custom `HybridTakeoverBoundary` or a manual +`resolveOptimizedEntry()` call. Use those only when an advanced route needs direct server SDK +control. -function ServerRenderedEntry({ baselineEntry, resolvedData }: ServerRenderedEntryProps) { - return ( - // Render data-ctfl-* attributes for browser entry-interaction tracking. - -

{String(resolvedData.entry.fields.title ?? '')}

-
- ) -} -``` - -### Browser root and server optimization state +### Bound root and server optimization state **Integration category:** Required for first integration -The SDK context comes from `OptimizationRoot` or `OptimizationProvider` through the React Web layer. -The browser SDK can initialize after the first server render, so the takeover island can return -`null` until SDK state is ready. When initial HTML personalization is required, render the -server-resolved content outside that provider boundary and pass server-returned Optimization data as -`serverOptimizationState` to the takeover island. - -1. Pass server-returned profile, selected optimizations, and changes through - `serverOptimizationState` on `OptimizationRoot` or `OptimizationProvider`. -2. Keep `defaults` for configuration or default state such as consent policy. -3. Render server-resolved entry HTML outside `OptimizationRoot`. -4. Mount `OptimizationRoot` around the Client Components that take over after state handoff. -5. Pass browser-exposed values such as `NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID` into the - client provider. -6. Include `defaults.consent: true` only when application policy permits accepted browser startup. -7. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks. +The app-local `OptimizationRoot` is already bound to the factory config. In Server Component layouts +it loads request Optimization data and passes that data to the browser root as +`serverOptimizationState` internally. In Client Components it uses the client implementation with +server-only config removed. + +1. Mount the app-local `OptimizationRoot` once around the route subtree that shares SDK state. +2. Configure `defaults`, `onStatesReady`, `liveUpdates`, and `trackEntryInteraction` in + `createNextjsOptimizationComponents()`, not as per-render root props. +3. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks. +4. Use `initialPageEvent="skip"` when the bound server root owns the initial request page event. +5. Use `OptimizationProvider` from the same app-local module only when a nested provider boundary is + needed. **Adapt this to your use case:** ```tsx -'use client' - -import { - NextAppAutoPageTracker, - OptimizationRoot, - type NextAppAutoPageTrackerProps, - type OptimizationRootProps, -} from '@contentful/optimization-nextjs/client' -import { Suspense, useState, type ReactNode } from 'react' - -export function HybridTakeoverBoundary({ - children, - defaults, - initialPageEvent, - locale, - serverContent, - serverOptimizationState, -}: { - children: ReactNode - defaults: OptimizationRootProps['defaults'] - initialPageEvent: NextAppAutoPageTrackerProps['initialPageEvent'] - locale: string - serverContent: ReactNode - serverOptimizationState: OptimizationRootProps['serverOptimizationState'] -}) { - const [takeoverReady, setTakeoverReady] = useState(false) +// app/layout.tsx +import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' +import { Suspense, type ReactNode } from 'react' +export default async function RootLayout({ children }: { children: ReactNode }) { return ( - <> - - { - // Provider children mount only after the browser SDK exists. - setTakeoverReady(true) - }} - > - - - - - - + + + + + + + {children} + + + ) } ``` -If a route only needs server-seeded browser takeover, and does not need personalized initial HTML, -it can render takeover content directly under `OptimizationRoot` with a loading fallback. That path -does not make a server-personalized HTML promise. The hybrid path in this guide renders the initial -content outside the provider, then lets browser-side `identify()`, `reset()`, `track()`, route page -events, and live updates call the APIs after browser startup. +Manual `serverOptimizationState` props still exist for lower-level `/client` roots, but they are an +advanced escape hatch. The bound App Router root handles state handoff for the default path. ### Client takeover and live re-resolution **Integration category:** Required for first integration -Render takeover content with React Web entry primitives from the Next.js client entrypoint. -`liveUpdates` defaults to `false`, so set it globally on `OptimizationRoot` or per `OptimizedEntry` -when visible content must react to profile changes. +Render browser-owned or live-update surfaces with the same app-local `OptimizedEntry` from Client +Components. The package root resolves that import to the client implementation when the file has +`'use client'`. `liveUpdates` defaults to `false`, so set it in the factory config, a +`LiveUpdatesProvider`, or per `OptimizedEntry` when visible content must react to profile changes. 1. Create Client Components for entries that need browser-side re-resolution. -2. Pass the baseline Contentful entry into `OptimizedEntry`. +2. Import `OptimizedEntry` from the app-local binding module, not directly from the package client + entrypoint. 3. Pass `liveUpdates={true}` for entries that must update after `identify()`, `consent()`, `reset()`, preview changes, or selected-optimization state changes. -4. Use the server-resolved entry as `loadingFallback` when you need the first rendered content to - stay stable while the browser SDK becomes ready. -5. Use `useOptimizedEntry()` or `useEntryResolver()` only when a component needs custom rendering - control that the `OptimizedEntry` wrapper does not provide. +4. Use `loadingFallback` only for client-only islands that need stable UI while the browser SDK + becomes ready. +5. Use `/client` hooks such as `useOptimizedEntry()` or `useEntryResolver()` only when a component + needs custom rendering control that the `OptimizedEntry` wrapper does not provide. **Adapt this to your use case:** ```tsx 'use client' -import { OptimizedEntry } from '@contentful/optimization-nextjs/client' +import { OptimizedEntry } from '@/lib/optimization' import type { Entry } from 'contentful' function EntryCard({ entry }: { entry: Entry }) { return
{String(entry.fields.title ?? '')}
} -export function HybridEntry({ - baselineEntry, - serverResolvedEntry, -}: { - baselineEntry: Entry - serverResolvedEntry: Entry -}) { +export function LiveEntry({ baselineEntry }: { baselineEntry: Entry }) { // liveUpdates keeps this entry reactive after identify, consent, reset, or preview changes. - // loadingFallback preserves server-selected content while the browser SDK initializes. return ( - } - > + {(resolvedEntry) => } ) } ``` -For shared live-update controls, set `liveUpdates={true}` on `OptimizationRoot` or wrap a subtree in -`LiveUpdatesProvider`. Per-entry `liveUpdates={true}` or `liveUpdates={false}` overrides the global -setting for that component. +For shared live-update controls, set `liveUpdates: true` in the component factory or wrap a subtree +in `LiveUpdatesProvider`. Per-entry `liveUpdates={true}` or `liveUpdates={false}` overrides the +global setting for that component. Verify live re-resolution only after the takeover path is working: @@ -727,28 +534,27 @@ Verify live re-resolution only after the takeover path is working: **Integration category:** Required for first integration -Choose one server owner for the initial route page event, then use `NextAppAutoPageTracker` for -browser App Router route changes. +Choose one owner for the initial route page event, then use `NextAppAutoPageTracker` for browser App +Router route changes. -1. Use `getNextjsServerOptimizationData()` as the owner when the route needs returned - `selectedOptimizations` for personalized initial HTML. +1. Let the bound server root own the initial page event when `server.enabled` is `true` for the + route subtree. 2. The context proxy does not emit page events. It only forwards request context headers for the - server helper. -3. Mount `NextAppAutoPageTracker` under `OptimizationRoot`. -4. Set `initialPageEvent="skip"` only when the server helper already emitted a page event for the - same initial route. -5. Use `initialPageEvent="emit"` when the server did not request Optimization data, such as denied - consent, browser-owned islands, or routes where the server helper returned `undefined`. -6. Use `pagePayload` on the server helper when your initial route event needs app-specific - properties. + bound server path. +3. Mount `NextAppAutoPageTracker` under the app-local `OptimizationRoot`. +4. Set `initialPageEvent="skip"` when the bound server path owns the same initial route event. +5. Use `initialPageEvent="emit"` when a route is browser-owned and does not use the bound server + path for first request data. +6. Use manual `/server` helpers only when the initial server page event needs per-route payload + control that the bound factory does not expose. **Follow this pattern:** ```tsx - {/* Skip only when the selected server owner emitted this route's initial page event. */} + {/* Skip only when the bound server path owns this route's initial page event. */} ({ properties: { routeGroup: pathname.startsWith('/account') ? 'account' : 'public', @@ -766,30 +572,29 @@ event. **Integration category:** Common but policy-dependent -Entry interaction tracking is browser-side. The server or React component renders metadata, and the -browser SDK observes views, clicks, and hovers after consent permits the detectors and event -delivery. +Entry interaction tracking is browser-side. Bound `OptimizedEntry` renders the metadata on the +server or client, and the browser SDK observes views, clicks, and hovers after consent permits the +detectors and event delivery. 1. Leave the default view, click, and hover interactions enabled when your consent policy permits - them; use `trackEntryInteraction` only to opt out of interaction types the app must not observe. + them; use factory `trackEntryInteraction` only to opt out of interaction types the app must not + observe. 2. Use `OptimizedEntry` props such as `clickable`, `trackViews`, `trackClicks`, `trackHovers`, `viewDurationUpdateIntervalMs`, and `hoverDurationUpdateIntervalMs` for per-entry control. -3. Use `ServerOptimizedEntry` for server-rendered entries that need the same tracking metadata. -4. Use `sdk.tracking.enableElement(...)` from `useOptimization()` only for app-owned manual +3. Use `sdk.tracking.enableElement(...)` from `useOptimization()` only for app-owned manual observation cases. +4. Use `getServerTrackingAttributes()` only when a route also uses manual `/server` resolution. 5. Verify consent gates. Page events can be allowed before full consent, but entry views, clicks, and hovers are blocked unless consent or `allowedEventTypes` permits them. **Follow this pattern:** ```tsx - - {children} - + trackEntryInteraction: { hovers: false }, +}) ``` **Follow this pattern:** @@ -811,12 +616,12 @@ Consent, identity, and profile continuity are application policy decisions. The runtime controls, but your application owns the consent record, privacy notice, CMP integration, identity source, and server cookie cleanup. -1. If policy permits accepted startup, bind accepted consent on the server and seed accepted consent - in browser consent defaults. -2. If policy depends on user choice, read the choice before server SDK calls and call `consent()` - from the Client Component that owns the browser decision. +1. If policy permits accepted startup, set accepted `server.consent` and seed accepted consent in + browser defaults. +2. If policy depends on user choice, read the choice in `server.consent` and call `consent()` from + the Client Component that owns the browser decision. 3. Store the policy decision in the same CMP, account preference, session, or cookie that the server - helper can read on the next request. + consent resolver can read on the next request. 4. Call `identify()` from browser flows when a visitor becomes known. 5. Call `reset()` and clear application-owned profile cookies when withdrawal or sign-out must end active-session personalization. @@ -837,7 +642,7 @@ const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' function setAppConsentCookie(consented: boolean): void { const value = consented ? 'granted' : 'denied' - // Store app consent where the server helper can read it on the next request. + // Store app consent where server.consent can read it on the next request. document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax` } @@ -894,10 +699,10 @@ Use analytics forwarding when your app needs to send approved Optimization conte customer-data platform, warehouse, or analytics destination. The SDK still sends events to Contentful; forwarding is application-owned. -1. Keep server and browser forwarding separate. Server-resolved attribution comes from the - request-local result, while browser activity comes from browser state subscriptions. -2. Register browser subscriptions with `onStatesReady` so event observers attach before child - effects such as route trackers emit events. +1. Keep server and browser forwarding separate. Server-rendered attribution comes from the request + that resolved the entry, while browser activity comes from browser state subscriptions. +2. Register browser subscriptions with factory `onStatesReady` so event observers attach before + child effects such as route trackers emit events. 3. Dedupe forwarded events by `messageId` or destination-specific semantic keys. 4. Store forwarded message IDs in module or app state so remounts do not forward the same event again. If the destination must receive only future SDK events, read the current `messageId` @@ -910,37 +715,36 @@ Contentful; forwarding is application-owned. ```tsx const forwardedMessageIds = new Set() - { - // Subscribe before child effects, such as route trackers, emit events. - const initialMessageId = states.eventStream.current?.messageId +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + // ...clientId, environment, locale, server, defaults + onStatesReady: (states) => { + // Subscribe before child effects, such as route trackers, emit events. + const initialMessageId = states.eventStream.current?.messageId + + const eventSubscription = states.eventStream.subscribe((event) => { + if (!event) return + if (forwardedMessageIds.has(event.messageId)) return + if (event.messageId === initialMessageId) { + forwardedMessageIds.add(event.messageId) + return + } + if (!canForwardSdkEvent(event)) return - const eventSubscription = states.eventStream.subscribe((event) => { - if (!event) return - if (forwardedMessageIds.has(event.messageId)) return - if (event.messageId === initialMessageId) { forwardedMessageIds.add(event.messageId) - return + analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event)) + }) + + const blockedSubscription = states.blockedEventStream.subscribe((blockedEvent) => { + if (blockedEvent) diagnostics.recordBlockedOptimizationEvent(blockedEvent) + }) + + return () => { + eventSubscription.unsubscribe() + blockedSubscription.unsubscribe() } - if (!canForwardSdkEvent(event)) return - - forwardedMessageIds.add(event.messageId) - analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event)) - }) - - const blockedSubscription = states.blockedEventStream.subscribe((blockedEvent) => { - if (blockedEvent) diagnostics.recordBlockedOptimizationEvent(blockedEvent) - }) - - return () => { - eventSubscription.unsubscribe() - blockedSubscription.unsubscribe() - } - }} -> - {children} - + }, + }) ``` Use @@ -955,8 +759,8 @@ and governance guidance. Use merge tags and Custom Flags when entries or components render profile-backed values that are not entry replacements. -1. Resolve Rich Text merge tag entries during rendering with SDK merge-tag helpers exposed through - the Next.js client entrypoint or server entrypoint. +1. Resolve Rich Text merge tag entries with the `getMergeTagValue` function passed to + `OptimizedEntry` render props. 2. Keep the SDK locale aligned with the rendered Contentful locale when merge tags reference localized profile fields such as `location.city` or `location.country`. 3. Use flag state from the browser SDK for components that need to react after browser startup. @@ -966,20 +770,34 @@ entry replacements. **Follow this pattern:** ```tsx -'use client' - -import { useMergeTagResolver } from '@contentful/optimization-nextjs/client' +import { OptimizedEntry } from '@/lib/optimization' +import type { Entry } from 'contentful' -function MergeTagText({ mergeTagEntry }: { mergeTagEntry: unknown }) { - const { getMergeTagValue } = useMergeTagResolver() +function EntryCard({ + entry, + getMergeTagValue, +}: { + entry: Entry + getMergeTagValue: (mergeTagEntry: unknown) => string | undefined +}) { + return {getMergeTagValue(entry.fields.greeting) ?? ''} +} - // Resolve against the current browser profile state. - return {getMergeTagValue(mergeTagEntry) ?? ''} +export function EntryWithMergeTags({ entry }: { entry: Entry }) { + return ( + + {(resolvedEntry, { getMergeTagValue }) => ( + + )} + + ) } ``` Merge tags and entry replacement use different mechanics. Entry replacement uses -`selectedOptimizations`; merge tags read profile-backed values from current SDK state. +`selectedOptimizations`; merge tags read profile-backed values from current SDK state. Use +`useMergeTagResolver()` from `/client` only in Client Components that need merge tags outside an +`OptimizedEntry` render prop. ### Preview panel @@ -1049,23 +867,39 @@ Next.js config and attaches the preview panel only when that flag is `true`. App Router applications can mix route strategies. Choose the strategy per route instead of forcing one rendering model across the whole app. -| Route need | Use this pattern | -| ----------------------------------------------------------- | ------------------------------------------------------------------------- | -| Server is the only content source until the next request | Use the SSR guide and render with server helpers only | -| Server first render plus browser-side reactivity | Use this hybrid guide with server optimization state and `OptimizedEntry` | -| Browser-owned personalization after startup | Render baseline or loading UI on the server and let the browser own it | -| Highly interactive account, dashboard, or settings surfaces | Prefer Client Components with live updates and explicit consent state | +| Route need | Use this pattern | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Server is the only content source until the next request | Use the SSR guide and render with bound server `OptimizedEntry` | +| Server first render plus browser-side reactivity | Use this hybrid guide with bound root handoff and app-local `OptimizedEntry` | +| Browser-owned personalization after startup | Render baseline or loading UI on the server and let Client Components own it | +| Highly interactive account, dashboard, or settings surfaces | Prefer Client Components with live updates and explicit consent state | 1. Keep SEO-sensitive content in Server Components when the content must be visible in the initial HTML. 2. Use Client Components for controls that call hooks, `identify()`, `consent()`, `reset()`, live flag state, or manual browser tracking. -3. Use the same `OptimizationRoot` for browser takeover subtrees that share browser SDK state. +3. Use the same app-local `OptimizationRoot` for browser takeover subtrees that share browser SDK + state. 4. Reuse the same Contentful locale and anonymous ID continuity rules across mixed route strategies. -The important boundary is ownership: server routes render from request-local Optimization data, and -browser takeover components render from browser SDK state seeded by server optimization state and -updated by later browser events. +The important boundary is ownership: Server Components render through the bound server +`OptimizedEntry`, and Client Components render through the same app-local `OptimizedEntry` after +browser startup. + +### Manual server and client escape hatches + +**Integration category:** Advanced or production-only + +Use manual helpers only when the bound App Router factory cannot express a route's control needs. + +1. Use `createNextjsOptimization()` and `getNextjsServerOptimizationData()` from `/server` when a + route needs direct request SDK control, custom server page payloads, or app-owned request + deduplication. +2. Pass `serverOptimizationState` to `/client` `OptimizationRoot` or `OptimizationProvider` only in + manual server/client setups. +3. Use `getServerTrackingAttributes()` only with manual `resolveOptimizedEntry()` results. +4. Keep a custom takeover boundary only when the app needs staged reveal behavior that the bound + root plus server/client `OptimizedEntry` split does not cover. ### Caching and request deduplication @@ -1075,15 +909,17 @@ Personalized server rendering is request-specific. Keep shared caches on raw Con not on profile-evaluated SDK results or personalized HTML unless your cache key varies on every personalization input. -1. Use `cache()` to deduplicate `getNextjsServerOptimizationData()` inside one React Server - Component render pass. -2. Avoid sharing the result of `getNextjsServerOptimizationData()` across requests. It emits a page - event and returns profile-specific data. -3. Cache raw Contentful entries by entry ID, locale, environment, and include depth when your app +1. Let the bound component factory's internal `cache()` deduplicate request Optimization data inside + one React Server Component render pass. +2. Create the bound components once in an app-local module. Multiple factories can create multiple + server page calls. +3. Avoid sharing server Optimization data across requests. It is profile-specific and tied to the + request page event. +4. Cache raw Contentful entries by entry ID, locale, environment, and include depth when your app cache policy permits it. -4. Mark personalized routes dynamic or otherwise exclude them from full-route caching unless your +5. Mark personalized routes dynamic or otherwise exclude them from full-route caching unless your deployment varies the cache on the full profile state. -5. Re-check cache rules when adding `generateStaticParams`, route segment cache settings, CDN +6. Re-check cache rules when adding `generateStaticParams`, route segment cache settings, CDN caching, or reverse proxy caching. **Copy this:** @@ -1103,9 +939,8 @@ you. Strict consent and duplicate-event controls are production policy work. Configure them only after your privacy, analytics, and platform owners agree on the event posture. -1. Use `allowedEventTypes: []` when no SDK events can emit before consent. -2. Return denied consent from the selected server owner and skip server Optimization requests while - consent is unknown or denied. +1. Use `allowedEventTypes: []` in the factory config when no SDK events can emit before consent. +2. Return `false` from `server.consent` while consent is unknown or denied. 3. Clear `ctfl-opt-aid` and application-owned consent or profile cookies when withdrawal must end profile continuity. 4. Use `initialPageEvent="skip"` only for a matching server page event. Use `emit` when the browser @@ -1116,19 +951,18 @@ your privacy, analytics, and platform owners agree on the event posture. **Adapt this to your use case:** ```ts -export async function getOptimizationData() { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - const granted = cookieStore.get('app-personalization-consent')?.value === 'granted' - - if (!granted) return undefined - - return await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: APP_LOCALE, - }) -} +createNextjsOptimizationComponents({ + // ...clientId, environment, locale + allowedEventTypes: [], + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: ({ cookies }) => + cookies.get('app-personalization-consent')?.value === 'granted' + ? { events: true, persistence: true } + : false, + }, +}) ``` Blocked events are not replayed when consent later changes. If the current route, flag, or entry @@ -1138,17 +972,17 @@ state still qualifies after consent, the SDK can emit a fresh current-state even Run these checks before release: -- Confirm the server SDK uses the intended Optimization client ID, environment, API endpoints, - locale, app metadata, and log level. +- Confirm the app-local component factory uses the intended Optimization client ID, environment, API + endpoints, locale, app metadata, and log level. - Confirm browser-exposed `NEXT_PUBLIC_` variables contain only values that can be shipped to the client. - Confirm Contentful fetches use one concrete locale and include resolved optimization entries and variants. -- Confirm server request consent, browser SDK consent, anonymous ID persistence, and CMP or account +- Confirm `server.consent`, browser SDK consent, anonymous ID persistence, and CMP or account preference state stay aligned across first load, route navigation, opt-in, opt-out, sign-in, sign-out, and reset. -- Confirm exactly one server owner emits the initial page event and `NextAppAutoPageTracker` does - not duplicate the first route event. +- Confirm the bound server path owns the initial page event and `NextAppAutoPageTracker` does not + duplicate the first route event. - Confirm `identify()`, `consent()`, and `reset()` re-resolve only the entries that are configured for live updates. - Confirm entry views, clicks, hovers, flag views, page events, business events, and forwarded @@ -1158,7 +992,7 @@ Run these checks before release: - Confirm personalized routes are not shared-cache safe unless the cache varies on every personalization input. - Confirm local validation uses the reference implementation flow when you need end-to-end evidence - for server-to-browser state handoff, proxy request context forwarding, entry tracking, live + for bound server-to-browser state handoff, proxy request context forwarding, entry tracking, live updates, page events, and offline queue behavior. **Copy this:** @@ -1171,21 +1005,21 @@ pnpm test:e2e:nextjs-sdk_hybrid ## Troubleshooting -| Symptom | Likely cause | Check | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Entries stay on baseline | Missing selected optimizations, denied consent, unresolved Contentful links, or all-locale CDA | Inspect `selectedOptimizations`, fetch with one `locale`, and use enough `include` depth | -| Two server-side page events appear for one request | A route calls `getNextjsServerOptimizationData()` more than once without request deduplication | Wrap the server helper in `cache()` and reuse the helper result inside one render pass | -| Browser sends a duplicate first page event | `initialPageEvent="emit"` is used after the server already emitted the same route event | Use `skip` only when the selected server owner emitted a page event for the same initial request | -| Browser does not send the first page event | `initialPageEvent="skip"` is used when the server skipped Optimization | Use `emit` when consent is denied, server data is unavailable, or the browser owns first page tracking | -| Live entries do not update after `identify()` or `reset()` | `liveUpdates` is false globally and on the entry | Set `liveUpdates={true}` on the entry or on `OptimizationRoot` | -| Entry views, clicks, or hovers do not emit | Interaction tracking is opted out, consent blocks the event, or no profile is available | Check `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 | Keep server imports on `/server` and browser imports on `/client` | -| 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 | +| Symptom | Likely cause | Check | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Entries stay on baseline | Missing selected optimizations, denied consent, unresolved Contentful links, or all-locale CDA | Check `server.consent`, fetch with one `locale`, and use enough `include` depth | +| Two server-side page events appear for one request | Multiple bound factories or a manual helper also calls the server page path | Create bound components once and keep manual `getNextjsServerOptimizationData()` out of the route | +| Browser sends a duplicate first page event | `initialPageEvent="emit"` is used after the bound server path already emitted the same route | Use `skip` only when the bound server path owns the same initial request | +| Browser does not send the first page event | `initialPageEvent="skip"` is used on a browser-owned route without a matching server event | Use `emit` when the browser owns first page tracking | +| Live entries do not update after `identify()` or `reset()` | `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 | +| 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 | +| 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 - [Next.js SDK hybrid reference implementation](../../implementations/nextjs-sdk_hybrid/README.md): - Working App Router application using `@contentful/optimization-nextjs` for server request data, + Working App Router application using app-local bound components for server first paint, server-to-browser state handoff, client takeover, live updates, consent controls, page events, entry interaction tracking, preview attachment, and Playwright E2E coverage. diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md index c21923139..922b4dcd6 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md @@ -2,8 +2,9 @@ Use this guide when you want to add personalization to a Next.js App Router application where the server is the source of truth for the content shown on each request. The Next.js adapter resolves -entries in Server Components before HTML leaves the server, then hands server optimization state to -the browser SDK for page events, entry interaction tracking, consent controls, identify, and reset. +entries through app-local bound components before HTML leaves the server, then hands server +optimization state to the browser SDK for page events, entry interaction tracking, consent controls, +identify, and reset. If the page must re-resolve entries immediately after a browser-side identify, consent, or reset action, use the @@ -26,82 +27,89 @@ preference, or regional rule, use the policy-dependent consent section before re pnpm add @contentful/optimization-nextjs ``` -2. Create one server SDK singleton. +2. Create app-local bound Optimization components. **Copy this:** ```ts - // lib/optimization-server.ts - import { createNextjsOptimization } from '@contentful/optimization-nextjs/server' + // lib/optimization.ts + import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' export const APP_LOCALE = 'en-US' - // Keep one server SDK instance; bind request state through adapter helpers. - export const optimization = createNextjsOptimization({ - clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', - environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', - locale: APP_LOCALE, - logLevel: 'error', - }) + export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', + environment: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', + locale: APP_LOCALE, + logLevel: 'error', + // This quick start permits events but does not persist a profile. + defaults: { consent: true, persistenceConsent: false }, + server: { + enabled: true, + consent: { events: true, persistence: false }, + }, + }) ``` -3. Fetch one Contentful entry in a Server Component, resolve it with request-local Optimization - data, and render the resolved entry. +3. Mount the app-local bound root and App Router page tracker in the root layout. + + **Copy this:** + + ```tsx + // app/layout.tsx + import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' + import { Suspense, type ReactNode } from 'react' + + export default async function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + {/* The server root already emits the first page event. */} + + + {children} + + + + ) + } + ``` + +4. Fetch one Contentful entry in a Server Component and render it through the bound + `OptimizedEntry`. In this snippet, `fetchEntryFromContentful()` is an app-owned Contentful CDA helper. It must - return one single-locale entry with linked optimization entries and variants included. The - `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`. - `` is valid when this page renders under SDK context provided by - `OptimizationRoot` or `OptimizationProvider`, such as a shared App Router layout. If you have not - added that provider yet, omit the marker until you complete the client provider section. + return one single-locale entry with linked optimization entries and variants included. **Adapt this to your use case:** ```tsx // app/page.tsx - import { APP_LOCALE, optimization } from '@/lib/optimization-server' - import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' - import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' - import { cookies, headers } from 'next/headers' + import { APP_LOCALE, OptimizedEntry } from '@/lib/optimization' export default async function Home() { - const [cookieStore, headerStore, baselineEntry] = await Promise.all([ - cookies(), - headers(), - fetchEntryFromContentful({ - entryId: 'homepage-hero', - include: 10, - locale: APP_LOCALE, - }), - ]) - - // Bind request state to the server page call without durable profile persistence. - const { data: optimizationData } = await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: false }, - cookies: cookieStore, - headers: headerStore, + const baselineEntry = await fetchEntryFromContentful({ + entryId: 'homepage-hero', + include: 10, locale: APP_LOCALE, }) - // The resolver returns the baseline entry when no selected optimization matches. - const resolvedData = optimization.resolveOptimizedEntry( - baselineEntry, - optimizationData?.selectedOptimizations, - ) - const resolvedEntry = resolvedData.entry - return (
- -

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

+ + {(resolvedEntry) =>

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

} +
) } ``` -4. Verify the first page load by inspecting the server-rendered HTML response or page source. The - rendered heading must match the selected variant when `selectedOptimizations` contains a matching - entry decision, or the baseline entry when no matching decision or Optimization data exists. +5. Verify the first page load by inspecting the server-rendered HTML response or page source. The + rendered heading must match the selected variant when the server request returns a matching entry + decision, or the baseline entry when no matching decision or Optimization data exists.
Table of Contents @@ -109,19 +117,20 @@ preference, or regional rule, use the policy-dependent consent section before re - [Required setup](#required-setup) - [Core integration](#core-integration) - - [Package entry points and server singleton](#package-entry-points-and-server-singleton) + - [App-local bound components and package entry points](#app-local-bound-components-and-package-entry-points) - [Request context, consent, and anonymous ID continuity](#request-context-consent-and-anonymous-id-continuity) - [Server-side Contentful fetching and entry resolution](#server-side-contentful-fetching-and-entry-resolution) - - [Client provider, state handoff, and route tracking](#client-provider-state-handoff-and-route-tracking) + - [Bound root, state handoff, and route tracking](#bound-root-state-handoff-and-route-tracking) - [Consent, identity, and SSR update timing](#consent-identity-and-ssr-update-timing) - [Optional integrations](#optional-integrations) - [Entry interaction tracking](#entry-interaction-tracking) - [Analytics forwarding](#analytics-forwarding) - [Advanced integrations](#advanced-integrations) + - [Manual server SDK and state handoff](#manual-server-sdk-and-state-handoff) - [Locale and request options](#locale-and-request-options) - [Caching and request deduplication](#caching-and-request-deduplication) - [Edge/request-rendered personalization](#edgerequest-rendered-personalization) - - [Unsupported SSR concerns and hybrid handoff](#unsupported-ssr-concerns-and-hybrid-handoff) + - [Client islands and hybrid handoff](#client-islands-and-hybrid-handoff) - [Production checks](#production-checks) - [Troubleshooting](#troubleshooting) - [Reference implementations to compare against](#reference-implementations-to-compare-against) @@ -137,71 +146,75 @@ Use this table as the setup inventory for the full SSR integration: | ------------------------------------------------------------------ | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------ | | Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` | | `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | -| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and `OptimizationRoot` props for browser integrations | +| Optimization client ID and environment | Required for first integration | Yes | `createNextjsOptimizationComponents()` config | +| App-local bound component module | Required for first integration | Yes | `lib/optimization.ts` or equivalent | | Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | | Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with `include: 10` and one `locale` | -| Server Component entry resolution | Required for first integration | Yes | App Router pages and server components | +| Bound `OptimizedEntry` server rendering | Required for first integration | Yes | App Router pages and Server Components | +| Bound `OptimizationRoot` and `NextAppAutoPageTracker` | Required for first integration | Yes | App Router layout | | Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` | -| Browser SDK context, state handoff, and route tracker | Required for first integration | Conditional | App Router layout and pages | -| Server request consent policy | Common but policy-dependent | Yes | Server calls, browser controls, CMP, or account controls | -| Profile persistence and anonymous ID cookie continuity | Common but policy-dependent | No | Server helper cookies, browser state handoff, ESR persistence, and `ctfl-opt-aid` | +| Server request consent policy | Common but policy-dependent | Yes | Factory `server.consent`, CMP, account controls, or session policy | +| Profile persistence and anonymous ID cookie continuity | Common but policy-dependent | No | Factory consent defaults, browser state handoff, ESR persistence, and `ctfl-opt-aid` | | Browser identify and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks | -| Experience API and Insights API endpoint overrides | Advanced or production-only | No | SDK `api` config for mock, proxy, or regional endpoints | -| Entry interaction tracking | Optional | No | `ServerOptimizedEntry`, `getServerTrackingAttributes()`, and `trackEntryInteraction` | -| Third-party analytics forwarding | Optional | No | `OptimizationRoot` `onStatesReady` subscription and app-owned analytics code | -| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, server helper structure, and tracker settings | +| Experience API and Insights API endpoint overrides | Advanced or production-only | No | Factory `api` config for mock, proxy, or regional endpoints | +| Entry interaction tracking | Optional | No | Bound `OptimizedEntry` props and factory `trackEntryInteraction` | +| Third-party analytics forwarding | Optional | No | Factory `onStatesReady` and app-owned analytics code | +| Manual server SDK, request helper, and state handoff | Advanced or production-only | No | `/server` and `/client` imports in custom server flows | +| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, factory setup, and tracker settings | | Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide | The application owns Contentful fetching, locale selection, route policy, consent policy, identity -policy, and component rendering. The Next.js adapter owns SDK composition: the server entry -delegates to the stateless Node SDK, the client entry delegates to the React Web SDK, and the -request handler forwards sanitized request context headers for Server Components. +policy, and component rendering. The Next.js adapter owns SDK composition: the package root resolves +to automatic server components in Server Components and client exports in Client Components, while +the request handler forwards sanitized request context headers for Server Components. ## Core integration -### Package entry points and server singleton +### App-local bound components and package entry points **Integration category:** Required for first integration -The adapter exposes runtime-specific subpaths. Keep imports on these subpaths so Server Components -do not import browser code and Client Components do not import server-only code. +Start App Router integrations from the package root. Put `createNextjsOptimizationComponents()` in +one app-local module and import the bound exports from that module everywhere else. -| Import path | Runtime | Responsibility | -| ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-nextjs/server` | Server Components and server-only modules | SDK creation, request binding, and server entry resolution wrapper | -| `@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 context capture and SDK-owned request header sanitization | -| `@contentful/optimization-nextjs/client` | Client Components and browser layout children | React provider, state handoff marker, hooks, App Router page tracker, and entry interaction tracking | -| `@contentful/optimization-nextjs/api-schemas` | Shared schema helpers | API types plus structural guards such as `isMergeTagEntry`, `isRichTextDocument`, and `isResolvedContentfulEntry` | -| `@contentful/optimization-nextjs/tracking-attributes` | Shared server-rendering helpers | Lower-level SSR `data-ctfl-*` tracking attributes | +| Import path | Runtime | Responsibility | +| ----------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs` | App Router Server and Client Components | Preferred bound component factory. Server Components resolve to automatic server components; Client Components resolve to client exports | +| `@contentful/optimization-nextjs/client` | Client Components and manual browser modules | React provider, hooks, App Router page tracker, and manual browser-only wiring | +| `@contentful/optimization-nextjs/server` | Advanced server-only modules | Manual SDK creation, request binding, state handoff, and request-local server data | +| `@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 context 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` | +| `@contentful/optimization-nextjs/tracking-attributes` | Shared server-rendering helpers | Lower-level SSR `data-ctfl-*` tracking attributes | -1. Create the server SDK once at module level with `createNextjsOptimization()`. +1. Define app-local bound exports once with `createNextjsOptimizationComponents()`. 2. Pass shared values such as `clientId`, `environment`, `locale`, endpoint overrides, app metadata, - and `logLevel` into that singleton. -3. Reuse the same singleton from Server Components and explicit custom server or ESR code that emits - SDK events. The context handler does not need the SDK singleton. -4. Do not create a new server SDK instance per request. Bind request-specific consent, cookies, - headers, locale, profile, and page context through adapter helpers instead. + browser defaults, and `logLevel` into that factory. +3. Set `server.enabled: true` and provide `server.consent` when Server Components should make the + server page call and resolve entries before the HTML response leaves the server. +4. Import `OptimizationRoot`, `OptimizationProvider`, `OptimizedEntry`, and route trackers from the + app-local module, not directly from package subpaths, for the default App Router path. +5. Use `/server` helpers only for manual flows that cannot use the bound root and entry components. ### Request context, consent, and anonymous ID continuity **Integration category:** Common but policy-dependent -The request handler captures request context for Server Components. It strips incoming SDK-owned -headers, forwards sanitized request context headers including the SDK-owned request URL header, and -leaves consent, page calls, and cookie persistence to the server helper or to an explicit -request-rendered ESR flow. +The request handler captures request URL context for Server Components. The bound server root and +entry components read `cookies()` and `headers()`, apply `server.consent`, call the server data +helper internally, and pass the returned state to the browser provider. 1. Export `createNextjsOptimizationContextHandler()` from `proxy.ts` or `middleware.ts` for routes - whose Server Components call `getNextjsServerOptimizationData()`. -2. Read application consent from a request-scoped source such as a CMP cookie, account preference, - or session before calling the server helper. -3. Pass `cookies()`, `headers()`, `consent`, and `locale` to `getNextjsServerOptimizationData()`. + whose bound Server Components need request URL and referrer context. +2. Read application consent in `server.consent` from a request-scoped source such as a CMP cookie, + account preference, or session. +3. Return `false` when the server must not emit Optimization events for the request. 4. Leave `ctfl-opt-aid` browser-readable when browser state handoff must continue the same profile. Do not mark it `HttpOnly`. -5. Use `persistNextjsAnonymousId()` only from custom server code that owns the outgoing response. +5. Use `persistNextjsAnonymousId()` only from manual custom server code that owns the outgoing + response. -**Adapt this to your use case:** +**Copy this:** ```ts // proxy.ts @@ -210,6 +223,18 @@ import { createNextjsOptimizationContextHandler } from '@contentful/optimization export const proxy = createNextjsOptimizationContextHandler() ``` +**Adapt this to your use case:** + +```ts +server: { + enabled: true, + consent: ({ cookies }) => + cookies.get('app-personalization-consent')?.value === 'granted' + ? { events: true, persistence: true } + : false, +} +``` + For deeper consent mechanics, see [Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md). @@ -218,44 +243,34 @@ For deeper consent mechanics, see **Integration category:** Required for first integration The SDK does not fetch Contentful entries. Your application fetches the baseline entries, including -linked optimization entries and variants, then passes the baseline entry and request-local -`selectedOptimizations` into `resolveOptimizedEntry()`. +linked optimization entries and variants, then renders the entry through the app-local bound +`OptimizedEntry`. 1. Fetch Contentful entries with one application Contentful locale. 2. Use enough include depth for `nt_experiences`, their configuration, and `nt_variants`; the reference implementation uses `include: 10`. -3. Call `getNextjsServerOptimizationData()` with the same request cookies, headers, consent, and - locale policy that apply to the rendered response. -4. Pass `optimizationData?.selectedOptimizations` to `optimization.resolveOptimizedEntry()`. -5. Render the returned `entry`. If no optimization data or matching variant is available, the - resolver returns the baseline entry. +3. Pass the baseline entry to the bound `OptimizedEntry` in a Server Component. +4. Render props receive `(resolvedEntry, { getMergeTagValue })`. Use `getMergeTagValue` when + rendering Rich Text merge tags. +5. If no Optimization data or matching variant is available, the server component renders the + baseline entry. -In this example, `cookieStore` and `headerStore` are the values returned by Next.js `cookies()` and -`headers()`. `fetchEntriesFromContentful()` is an app-owned CDA helper that must return -single-locale entries with linked optimization entries and variants included. +In this example, `fetchEntriesFromContentful()` is an app-owned CDA helper that must return +single-locale entries with linked optimization entries and variants included. `HeroCard` is an +application component that renders the resolved Contentful entry. **Adapt this to your use case:** ```tsx -const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted' - -const [baselineEntries, optimizationData] = await Promise.all([ - fetchEntriesFromContentful({ include: 10, locale: APP_LOCALE }), - // Only request Optimization data when app policy permits profile-producing calls. - appConsent - ? getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: APP_LOCALE, - }).then(({ data }) => data) - : undefined, -]) - -const resolvedEntries = baselineEntries.map((entry) => - // The resolver returns the baseline entry when no selected optimization matches. - optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations), -) +const baselineEntries = await fetchEntriesFromContentful({ include: 10, locale: APP_LOCALE }) + +return baselineEntries.map((baselineEntry) => ( + + {(resolvedEntry, { getMergeTagValue }) => ( + + )} + +)) ``` Do not pass all-locale CDA payloads from `contentful.js` `withAllLocales` or raw CDA `locale=*`. @@ -264,66 +279,60 @@ single-locale fields such as `fields.nt_experiences` and `fields.nt_variants`. F contract, see [Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract). -### Client provider, state handoff, and route tracking +### Bound root, state handoff, and route tracking **Integration category:** Required for first integration SSR content rendering does not need browser JavaScript, but page tracking, entry interactions, -consent controls, identify, and reset run in the browser through the Next.js client entry. +consent controls, identify, and reset run in the browser through the bound root. 1. Render `OptimizationRoot` in the App Router layout. -2. Pass browser-safe configuration to `OptimizationRoot`. If a Client Component reads environment - variables directly, use `NEXT_PUBLIC_` variables. A Server Component layout can also read - server-side config and pass the values as props intentionally. -3. Use `serverOptimizationState={optimizationData}` on `OptimizationRoot` or `OptimizationProvider` - when that provider or root receives the server data directly. When a shared layout owns the SDK - context and cannot receive page data, render - `` under that context near the server-rendered - optimized content. -4. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks. -5. Set `initialPageEvent="skip"` when the server already emitted the page event for the initial - route. Leave route changes enabled so client-side navigation continues to emit page events. - -**Adapt this to your use case:** +2. Keep browser-safe configuration in the `createNextjsOptimizationComponents()` call. Use + `NEXT_PUBLIC_` values or another deliberate public config source for browser-readable values. +3. Let the bound `OptimizationRoot` and `OptimizationProvider` pass server data as + `serverOptimizationState`; do not wire this prop manually in the default App Router path. +4. Wrap the bound `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation + hooks. +5. Set `initialPageEvent="skip"` when the bound server root already emitted the page event for the + initial route. Leave route changes enabled so client-side navigation continues to emit page + events. + +**Copy this:** ```tsx - - - {/* Skip only when the server already emitted the first page event. */} - - - {children} - +// app/layout.tsx +import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' +import { Suspense, type ReactNode } from 'react' + +export default async function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + {/* Skip only when the server already emitted the first page event. */} + + + {children} + + + + ) +} ``` -For policy-dependent consent, derive the initial tracker behavior from the same source that the -server used: +For policy-dependent consent, derive `server.consent` from the same application source that powers +browser consent controls. When `server.consent` returns `false`, use `initialPageEvent="emit"` only +for routes where browser consent policy should decide whether the first page event is delivered. **Adapt this to your use case:** ```tsx -const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted' - - - - {/* Emit from the browser when the server skipped Optimization for this request. */} - - - {children} - +const initialPageEvent = appConsent ? 'skip' : 'emit' + + + + ``` ### Consent, identity, and SSR update timing @@ -407,51 +416,30 @@ export function OptimizationControls() { **Integration category:** Optional The browser client can automatically observe server-rendered entry wrappers when the markup contains -the `data-ctfl-*` tracking attributes. Use `ServerOptimizedEntry` to render those attributes from -the same baseline entry and resolved data used for SSR content. - -1. Wrap server-rendered entry content with `ServerOptimizedEntry`. -2. Pass the original baseline entry and the full `ResolvedData` returned by - `resolveOptimizedEntry()`. -3. Use `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes` - when an existing server-rendered element or design-system component must own the wrapper markup. -4. Use `trackEntryInteraction` on `OptimizationRoot` only to opt out of interaction types the app - must not observe. -5. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only +the `data-ctfl-*` tracking attributes. The bound `OptimizedEntry` renders those attributes for +Server Components and accepts the same per-entry tracking props as the React Web component. + +1. Configure global interaction tracking in the `createNextjsOptimizationComponents()` call with + `trackEntryInteraction`. +2. Use bound `OptimizedEntry` for server-rendered entries that should expose `data-ctfl-*` + attributes to the browser observer. +3. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only when an entry needs per-element tracking behavior. +4. Use `getServerTrackingAttributes()` only in manual server flows that bypass the bound component. **Adapt this to your use case:** ```tsx - -

{resolvedData.entry.fields.title}

-
-``` - -Use the lower-level helper when the wrapper element comes from your component library. The component -must forward the `data-ctfl-*` attributes to the DOM element that the browser SDK observes: - -**Adapt this to your use case:** - -```tsx -import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' - -const trackingAttributes = getServerTrackingAttributes(baselineEntry, resolvedData, { - clickable: true, -}) - -return ( - -

{resolvedData.entry.fields.title}

-
-) + {(resolvedEntry, { getMergeTagValue }) => ( + + )} + ``` Automatic interaction tracking is still gated by browser-side SDK consent. If consent is denied or @@ -476,7 +464,7 @@ Forwarding Optimization context to a tag manager, customer-data platform, or ana is application-owned. The Optimization SDK still sends its own events to Contentful; forwarding copies only the fields your governance policy allows. -1. Subscribe once through `OptimizationRoot` `onStatesReady` so the subscription exists before child +1. Subscribe once through the factory `onStatesReady` option so the subscription exists before child route trackers emit events. 2. Read browser activity from `states.eventStream`. 3. Deduplicate exact event records with `messageId` so current snapshots, subscriber remounts, @@ -496,36 +484,38 @@ destination. **Adapt this to your use case:** ```tsx +import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' + const forwardedMessageIds = new Set() - { - const initialMessageId = states.eventStream.current?.messageId - - const subscription = states.eventStream.subscribe((event) => { - if (!event) return - // Message IDs prevent duplicate forwarding to app-owned destinations. - if (forwardedMessageIds.has(event.messageId)) return - if (event.messageId === initialMessageId) { - forwardedMessageIds.add(event.messageId) - return - } - if (!canForwardSdkEvent(event)) return +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '', + environment: process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main', + server: { enabled: true, consent: { events: true, persistence: true } }, + // Attach subscriptions before child route trackers and interaction observers emit. + onStatesReady: (states) => { + const initialMessageId = states.eventStream.current?.messageId + + const subscription = states.eventStream.subscribe((event) => { + if (!event) return + // Message IDs prevent duplicate forwarding to app-owned destinations. + if (forwardedMessageIds.has(event.messageId)) return + if (event.messageId === initialMessageId) { + forwardedMessageIds.add(event.messageId) + return + } + if (!canForwardSdkEvent(event)) return - forwardedMessageIds.add(event.messageId) - if (!shouldForwardContentfulEvent(event)) return + forwardedMessageIds.add(event.messageId) + if (!shouldForwardContentfulEvent(event)) return - analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event)) - }) + analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event)) + }) - return () => subscription.unsubscribe() - }} -> - {children} - + return () => subscription.unsubscribe() + }, + }) ``` Use @@ -535,18 +525,78 @@ governance guidance. ## Advanced integrations +### Manual server SDK and state handoff + +**Integration category:** Advanced or production-only + +Use manual helpers only when the bound App Router components cannot own the request path. The common +reasons are custom server code that needs direct access to `requestOptimization`, a nonstandard +state handoff boundary, or a wrapper element that cannot be rendered by bound `OptimizedEntry`. + +1. Import `createNextjsOptimization()` and `getNextjsServerOptimizationData()` from + `@contentful/optimization-nextjs/server`. +2. Create the server SDK once at module level. +3. Call `getNextjsServerOptimizationData()` with the request `cookies()`, `headers()`, consent, and + locale that apply to the rendered response. +4. Pass the returned `data` to a manual `/client` `OptimizationRoot` or `OptimizationProvider` as + `serverOptimizationState`. +5. Use `getServerTrackingAttributes()` only for manual markup that cannot use the bound + `OptimizedEntry`. + +**Follow this pattern:** + +```tsx +import { OptimizationRoot } from '@contentful/optimization-nextjs/client' +import { + createNextjsOptimization, + getNextjsServerOptimizationData, +} from '@contentful/optimization-nextjs/server' +import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' +import { cookies, headers } from 'next/headers' + +const sdk = createNextjsOptimization({ clientId: 'client-id', environment: 'main' }) + +export default async function ManualPage() { + const [cookieStore, headerStore, baselineEntry] = await Promise.all([ + cookies(), + headers(), + fetchEntryFromContentful({ include: 10, locale: APP_LOCALE }), + ]) + + const { data } = await getNextjsServerOptimizationData(sdk, { + consent: { events: true, persistence: true }, + cookies: cookieStore, + headers: headerStore, + locale: APP_LOCALE, + }) + + const resolvedData = sdk.resolveOptimizedEntry(baselineEntry, data?.selectedOptimizations) + const trackingAttributes = getServerTrackingAttributes(baselineEntry, resolvedData) + + return ( + +
+

{String(resolvedData.entry.fields.title ?? '')}

+
+
+ ) +} +``` + ### Locale and request options **Integration category:** Advanced or production-only -Most apps use the same `appLocale` for Contentful CDA requests, the Next.js server helper, and the -browser provider. The SDK does not choose Contentful locales or modify CDA requests for you. +Most apps use the same `appLocale` for Contentful CDA requests and the +`createNextjsOptimizationComponents()` config. The SDK does not choose Contentful locales or modify +CDA requests for you. 1. Choose the application Contentful locale from routing, i18n, account, or request policy. 2. Pass that locale to Contentful CDA requests. -3. Pass the same locale to `getNextjsServerOptimizationData({ locale })` when Experience API - responses and event context must match the rendered content language. -4. Pass the same locale to `OptimizationRoot` so browser route events use the same locale. +3. Pass the same locale to `createNextjsOptimizationComponents({ locale })` so the bound server data + request, bound entry resolution, and browser route events use the same locale. +4. In manual server flows, pass the same locale to `getNextjsServerOptimizationData({ locale })` and + the manual browser provider. 5. Use `experienceOptions` and `insightsOptions` only for lower-level request overrides such as preflight, custom beacon handling, IP overrides, or endpoint-specific options. @@ -560,13 +610,13 @@ For the broader locale model, see Personalized SSR creates request-local data. Cache raw Contentful delivery payloads in the application layer, but do not cache profile-evaluated SDK results across visitors. -1. Treat `getNextjsServerOptimizationData()` and request-bound `page()` results as non-cacheable - across requests because they perform side effects and return profile-specific data. +1. Treat the bound server data request and manual `getNextjsServerOptimizationData()` calls as + non-cacheable across requests because they perform side effects and return profile-specific data. 2. Cache raw Contentful entries only by safe content keys such as entry ID, environment, include depth, and application Contentful locale. 3. Disable or vary Next.js full-route caching for personalized routes. -4. Deduplicate repeated server helper calls inside one render pass with an app-owned cached helper - when multiple Server Components need the same request-local optimization data. +4. Let the bound root and bound entries share the factory's per-request React cache. Add an + app-owned cached helper only for manual server flows that call the lower-level helper directly. 5. Use `initialPageEvent="skip"` for the first browser route when a server page call already emitted the initial page event. @@ -584,8 +634,7 @@ export const dynamic = 'force-dynamic' Use ESR when a route handler, edge function, or other request-rendered surface owns the incoming `Request` and outgoing `Response`. Do not use ESR for the default App Router Server Component path -when `cookies()`, `headers()`, `getNextjsServerOptimizationData()`, and `NextjsOptimizationState` -fit the route. +when the bound `OptimizationRoot` and `OptimizedEntry` fit the route. 1. Import `getNextjsEsrOptimizationData()` from `@contentful/optimization-nextjs/esr`. 2. Pass the incoming `Request` or `NextRequest`, request consent, locale, and optional page payload. @@ -621,16 +670,17 @@ export async function GET(request: Request) { **Integration category:** Advanced or production-only -The SSR pattern keeps `ServerOptimizedEntry` content server-authoritative. Content resolved and +The SSR pattern keeps bound `OptimizedEntry` content server-authoritative. Content resolved and rendered on the server stays fixed after browser startup until the next server request. Client-side SDK actions such as `identify()`, `consent()`, `reset()`, live updates, or preview-panel changes do not rewrite that server-rendered markup in place. SSR routes can still include browser-owned islands when the page needs a localized reactive area. -Render those islands with the client entry, such as `OptimizedEntry`, `useOptimizedEntry()`, or -`LiveUpdatesProvider`, and treat that island as browser-owned after hydration. This is useful for -secondary widgets, preview/editor tools, or content blocks where `liveUpdates` and preview-panel -variant changes are acceptable without changing the route's primary server-first content model. +Import the same app-local `OptimizedEntry` from a Client Component, or use client hooks such as +`useOptimizedEntry()` and `LiveUpdatesProvider`, then treat that island as browser-owned after +hydration. This is useful for secondary widgets, preview/editor tools, or content blocks where +`liveUpdates` and preview-panel variant changes are acceptable without changing the route's primary +server-first content model. Use the hybrid guide when browser takeover is the route's main content model: the same primary entry must render server-personalized HTML for first paint and then continue re-resolving in the browser @@ -642,21 +692,22 @@ matter more than immediate browser-side changes to the main content. Before releasing a Next.js SSR integration, verify these checks: -- **Credentials and runtime configuration** - Server SDK config, browser provider config, endpoint - overrides, environment IDs, and app metadata point to the intended environment. -- **Consent behavior** - Default-on startup is backed by application policy, or Server Components, +- **Credentials and runtime configuration** - Factory config, endpoint overrides, environment IDs, + public browser values, and app metadata point to the intended environment. +- **Consent behavior** - Default-on startup is backed by application policy, or `server.consent`, browser controls, and cookies all read the same consent source. -- **Event delivery** - Initial server page events, browser route page events, entry interactions, - identify, reset, and blocked-event diagnostics behave as expected for accepted and denied consent. - Automatic detectors can remain stopped for denied or unset consent and might not produce a - per-element blocked payload. Use `blockedEventStream` and `onEventBlocked` for direct SDK calls - that reach Core and are blocked by consent or `allowedEventTypes`. +- **Event delivery** - Initial server page events from the bound root, browser route page events, + entry interactions, identify, reset, and blocked-event diagnostics behave as expected for accepted + and denied consent. Automatic detectors can remain stopped for denied or unset consent and might + not produce a per-element blocked payload. Use `blockedEventStream` and `onEventBlocked` for + direct SDK calls that reach Core and are blocked by consent or `allowedEventTypes`. - **Content fallback behavior** - Missing optimization data, unmatched selected optimizations, - unresolved Contentful links, all-locale CDA payloads, and API failures render baseline content. + unresolved Contentful links, all-locale CDA payloads, and API failures make bound `OptimizedEntry` + render baseline content. - **Duplicate tracking prevention** - The initial browser route uses `initialPageEvent="skip"` when - the server already emitted the page event, the app does not mount multiple page trackers for the - same route tree, exact analytics records are deduplicated by `messageId`, and sticky-view - exposures use semantic dedupe when the destination wants one exposure. + the bound server root already emitted the page event, the app does not mount multiple page + trackers for the same route tree, exact analytics records are deduplicated by `messageId`, and + sticky-view exposures use semantic dedupe when the destination wants one exposure. - **Privacy and governance** - The `ctfl-opt-aid` cookie is written only when persistence consent permits it, is cleared on withdrawal when required, and is forwarded to third parties only through approved app-owned mapping. @@ -666,19 +717,19 @@ Before releasing a Next.js SSR integration, verify these checks: ## Troubleshooting -| Symptom | Likely cause | Check | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| The page always renders baseline content | No optimization data, missing consent, all-locale CDA payloads, or unresolved optimization links | Confirm the server helper returned `selectedOptimizations`, fetch with one `locale`, and use `include: 10` | -| The browser emits a duplicate first page event | The initial page tracker emitted after a server page call | Set `initialPageEvent="skip"` when the server already emitted the initial page event | -| Entry view, click, or hover events do not appear | Missing `data-ctfl-*` attributes, opted-out `trackEntryInteraction`, or denied browser consent | Render `ServerOptimizedEntry`, inspect opt-out settings, and inspect blocked-event state | -| A Server Component fails with browser globals or hook errors | A server file imported the Next.js client entry or React SDK hooks | Move hook usage to a Client Component with `'use client'` and keep server files on the server entry | -| Identify works but content does not change immediately | Expected SSR behavior | Navigate or refresh so the next server request resolves entries with the updated profile | -| Anonymous profile continuity is lost | The anonymous ID cookie is absent, `HttpOnly`, denied by persistence consent, or cleared on reset | Inspect `ctfl-opt-aid`, server or ESR persistence, browser consent state, and withdrawal logic | +| Symptom | Likely cause | Check | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| The page always renders baseline content | `server.enabled` is off, `server.consent` returned false, all-locale CDA payloads, or unresolved links | Confirm the bound module enables server consent, fetch with one `locale`, and use `include: 10` | +| The browser emits a duplicate first page event | The initial page tracker emitted after the bound server root page call | Set `initialPageEvent="skip"` when the server already emitted the initial page event | +| Entry view, click, or hover events do not appear | Missing bound `OptimizedEntry` wrapper, opted-out `trackEntryInteraction`, or denied browser consent | Render through bound `OptimizedEntry`, inspect opt-out settings, and inspect blocked-event state | +| A Server Component fails with browser globals or hook errors | A server file imported `/client` hooks or manual browser components directly | Import bound components from the app-local module and move hook usage to a Client Component with `'use client'` | +| Identify works but content does not change immediately | Expected SSR behavior | Navigate or refresh so the next server request resolves entries with the updated profile | +| Anonymous profile continuity is lost | The anonymous ID cookie is absent, `HttpOnly`, denied by persistence consent, or cleared on reset | Inspect `ctfl-opt-aid`, browser persistence consent, manual server or ESR persistence, and withdrawal logic | ## Reference implementations to compare against - [`implementations/nextjs-sdk_ssr`](../../implementations/nextjs-sdk_ssr/README.md) - Working - Next.js App Router SSR application using `@contentful/optimization-nextjs/server`, - `@contentful/optimization-nextjs/request-handler`, and `@contentful/optimization-nextjs/client`. - Use it to compare proxy request context forwarding, server entry resolution, - `ServerOptimizedEntry`, App Router layout tracking, and browser controls. + Next.js App Router SSR application using `@contentful/optimization-nextjs` for app-local bound + components plus `@contentful/optimization-nextjs/request-handler` for request context forwarding. + Use it to compare the bound `OptimizationRoot`, bound `OptimizedEntry`, App Router layout + tracking, consent wiring, and browser controls. 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 4062d9700..0dc47670d 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 @@ -649,22 +649,62 @@ For cross-runtime identity behavior, see Use merge tags when Contentful Rich Text contains embedded personalization fields. Use Custom Flags when application UI branches on a named flag rather than an optimized entry. -1. Use `useMergeTagResolver()` in the component that renders Rich Text embedded entries. -2. Use `optimization.getFlag(name)` only for direct reads, such as event handlers or render paths +1. For optimized entry content, read `getMergeTagValue` from the `OptimizedEntry` render context and + pass it into your app-owned Rich Text renderer. +2. Use `useMergeTagResolver()` only when a component resolves merge tags outside an `OptimizedEntry` + render prop. +3. Use `optimization.getFlag(name)` only for direct reads, such as event handlers or render paths that don't need to update when the flag changes. -3. Subscribe to `optimization.states.flag(name)` when React UI must rerender after profile, route, +4. Subscribe to `optimization.states.flag(name)` when React UI must rerender after profile, route, or preview changes update the flag value. -4. Treat flag reads as analytics exposure. In the stateful Web runtime, reading a flag can emit +5. Treat flag reads as analytics exposure. In the stateful Web runtime, reading a flag can emit flag-view tracking when consent allows it. **Adapt this to your use case:** ```tsx -import { useMergeTagResolver, useOptimization } from '@contentful/optimization-react-web' +import { + OptimizedEntry, + type OptimizedEntryRenderContext, + useMergeTagResolver, + useOptimization, +} from '@contentful/optimization-react-web' import type { Entry } from 'contentful' import { useEffect, useState } from 'react' -function PersonalizedRichText({ mergeTagEntry }: { mergeTagEntry: Entry }) { +type GetMergeTagValue = OptimizedEntryRenderContext['getMergeTagValue'] + +function RichTextRenderer({ + getMergeTagValue, + mergeTagEntry, +}: { + getMergeTagValue: GetMergeTagValue + mergeTagEntry: Entry +}) { + // Call this from the Rich Text embedded-entry renderer that visits merge-tag entries. + return {getMergeTagValue(mergeTagEntry) ?? ''} +} + +function PersonalizedEntry({ + baselineEntry, + mergeTagEntry, +}: { + baselineEntry: Entry + mergeTagEntry: Entry +}) { + return ( + + {(resolvedEntry, { getMergeTagValue }) => ( +
+

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

+ +
+ )} +
+ ) +} + +function StandalonePersonalizedRichText({ mergeTagEntry }: { mergeTagEntry: Entry }) { const { getMergeTagValue } = useMergeTagResolver() return {getMergeTagValue(mergeTagEntry) ?? ''} diff --git a/implementations/nextjs-sdk_hybrid/.env.example b/implementations/nextjs-sdk_hybrid/.env.example index 16ec62662..cbbbe3659 100644 --- a/implementations/nextjs-sdk_hybrid/.env.example +++ b/implementations/nextjs-sdk_hybrid/.env.example @@ -1,6 +1,6 @@ DOTENV_CONFIG_QUIET=true -E2E_FLAGS=CSR,HYDRATION +E2E_FLAGS=CSR,HYDRATION,SSR APP_PORT=3002 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false" @@ -17,4 +17,4 @@ PUBLIC_CONTENTFUL_ENVIRONMENT="master" PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" -PUBLIC_CONTENTFUL_BASE_PATH="contentful" \ No newline at end of file +PUBLIC_CONTENTFUL_BASE_PATH="contentful" diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md index 8dde2705a..5ce4dd0f4 100644 --- a/implementations/nextjs-sdk_hybrid/README.md +++ b/implementations/nextjs-sdk_hybrid/README.md @@ -21,11 +21,15 @@ > The Optimization SDK Suite is pre-release (alpha). Breaking changes can be published at any time. Reference implementation demonstrating `@contentful/optimization-nextjs` in a Next.js App Router -application with server-provided Optimization state handoff and browser-side entry resolution after -startup. For SDK runtime APIs, app code imports Next.js SDK package subpaths: - -- `@contentful/optimization-nextjs/server` in Server Components and server-only modules -- `@contentful/optimization-nextjs/client` in Client Components +application with bound server/client components, server-provided Optimization state handoff, and +browser-side entry resolution after startup. The implementation binds `OptimizationRoot`, +`OptimizedEntry`, and `NextAppAutoPageTracker` once in `@/lib/optimization` with +`createNextjsOptimizationComponents()`. 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: + +- `@contentful/optimization-nextjs` in `@/lib/optimization` for bound server/client components +- `@contentful/optimization-nextjs/client` for browser hooks and providers - `@contentful/optimization-nextjs/api-schemas` in components that need SDK schema guards - `@contentful/optimization-nextjs/request-handler` in proxy @@ -35,19 +39,23 @@ runtime work. ## What this demonstrates -Use this implementation when you need a Next.js example where the server fetches Contentful entries -and Optimization state, then the browser SDK resolves entries and owns reactive updates after state -handoff. It demonstrates: +Use this implementation when you need a Next.js example where Server Components fetch Contentful +entries, the bound server root prepares Optimization state for handoff, and the browser SDK resolves +live surfaces after startup. It demonstrates: +- App-local bound components from `createNextjsOptimizationComponents()` - Server request context forwarding through proxy -- Server-to-browser state handoff through `NextjsOptimizationState` -- Browser-side entry resolution with `OptimizedEntry` after browser startup +- Server-resolved first paint and static content with bound `OptimizedEntry` +- Browser-side entry resolution with the same app-local `OptimizedEntry` in Client Components +- Rich Text merge tags passed from the `OptimizedEntry` render-prop `getMergeTagValue` into shared + render options - Live re-resolution after consent, identify, reset, and client-side route changes -- `initialPageEvent="skip"` when the server request helper already emitted the initial page event +- `initialPageEvent="skip"` because the bound server root owns the initial page event - Preview panel attachment behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` -This hybrid pattern keeps App Router server fetching in place, hands Optimization state to the -browser, and lets the browser SDK own entry resolution and reactive updates after startup. +This hybrid pattern keeps App Router server fetching in place, lets the bound `OptimizationRoot` +hand server state to the browser, uses Server Components for first paint and static content, and +uses Client Components for live-update surfaces that need browser takeover. ## Architecture @@ -58,21 +66,21 @@ First request forwards sanitized request URL context for Server Components lib/optimization.ts - createNextjsOptimization() - getOptimizationData() - calls getNextjsServerOptimizationData() with cookies() and headers() + createNextjsOptimizationComponents() + exports app-local OptimizationRoot, OptimizedEntry, and NextAppAutoPageTracker app/page.tsx and app/page-two/page.tsx fetch CDA entries server-side - render NextjsOptimizationState with server Optimization data + render server first-paint entries through the bound OptimizedEntry app/layout.tsx - owns one OptimizationRoot for browser takeover and route tracking + renders one bound OptimizationRoot without per-request config props + renders NextAppAutoPageTracker with initialPageEvent="skip" Browser runtime - NextjsOptimizationState hydrates Optimization data into the nearest runtime + Bound OptimizationRoot hydrates server state and owns browser handoff NextAppAutoPageTracker emits route page events - OptimizedEntry resolves entries from current selectedOptimizations + The same app-local OptimizedEntry resolves entries from current selectedOptimizations LiveUpdatesProvider controls reactive re-resolution ``` @@ -91,13 +99,17 @@ and ## Route strategy -Use Server Components for routes that fetch Contentful entries and request Optimization state, and -Client Components for entry surfaces that resolve and react after browser startup. This -implementation demonstrates both: +Use Server Components for routes that fetch Contentful entries and render first-paint/static content +through the bound `OptimizedEntry`. Use Client Components for entry surfaces that resolve and react +after browser startup. The bound `OptimizationRoot` handles server state handoff; route pages do not +render `NextjsOptimizationState` or call `getOptimizationData()`. This implementation demonstrates +both: -- The home route fetches entries server-side and passes them into client rendering +- The home route fetches entries server-side, renders static first-paint entries on the server, and + keeps merge-tag and live-update examples on the client - The page-two route demonstrates client navigation and browser-observable page events -- `OptimizedEntry` owns client re-resolution without implementation-local resolver glue +- The same app-local bound `OptimizedEntry` chooses the server or client implementation from the + component boundary ## Prerequisites @@ -154,8 +166,7 @@ pnpm test:e2e:nextjs-sdk_hybrid The E2E suite reuses the shared `lib/e2e-web` browser scenarios for CSR and hydration behavior under the hybrid app configuration. It covers shared variant resolution, tracking, navigation, live updates, offline queue recovery, and the hybrid-specific hydration check that a consented server -handoff does not issue a duplicate client Experience request. Package unit tests cover lower-level -Next.js adapter request-context, `ServerOptimizedEntry`, and initial page-event helper behavior. +handoff does not issue a duplicate client Experience request. Use Playwright UI or codegen when needed: diff --git a/implementations/nextjs-sdk_hybrid/app/layout.tsx b/implementations/nextjs-sdk_hybrid/app/layout.tsx index dce121e4d..9e55d17de 100644 --- a/implementations/nextjs-sdk_hybrid/app/layout.tsx +++ b/implementations/nextjs-sdk_hybrid/app/layout.tsx @@ -2,8 +2,8 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' -import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' import type { Metadata } from 'next' import { cookies } from 'next/headers' @@ -33,19 +33,7 @@ export default async function RootLayout({ return ( - + diff --git a/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx b/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx index 5f780bfd2..94460c1f4 100644 --- a/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx @@ -2,17 +2,12 @@ import { ControlPanel } from '@/components/ControlPanel' import { CustomViewTracker } from '@/components/CustomViewTracker' import { EntryCard } from '@/components/EntryCard' import { loadPageEntries } from '@/lib/contentful' -import { getOptimizationData } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { PAGES } from 'e2e-web' import Link from 'next/link' export default async function PageTwo() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.pageTwo.ids), - getOptimizationData(), - ]) + const entries = await loadPageEntries(PAGES.pageTwo.ids) const entriesById = toIdMap(entries) const autoEntry = entriesById.get(PAGES.pageTwo.auto) const manualEntry = entriesById.get(PAGES.pageTwo.manual) @@ -28,7 +23,6 @@ export default async function PageTwo() { -
@@ -37,7 +31,7 @@ export default async function PageTwo() {
{autoEntry ? ( - + ) : (

Auto tracked entry is unavailable.

)} @@ -50,7 +44,7 @@ export default async function PageTwo() {
{manualEntry ? ( - + ) : (

Manual tracked entry is unavailable.

)} diff --git a/implementations/nextjs-sdk_hybrid/app/page.tsx b/implementations/nextjs-sdk_hybrid/app/page.tsx index 1e0e294c1..58da4e976 100644 --- a/implementations/nextjs-sdk_hybrid/app/page.tsx +++ b/implementations/nextjs-sdk_hybrid/app/page.tsx @@ -1,18 +1,12 @@ import { ControlPanel } from '@/components/ControlPanel' import { EntryCard } from '@/components/EntryCard' +import { LiveEntryCard } from '@/components/LiveEntryCard' import { loadPageEntries } from '@/lib/contentful' -import { getOptimizationData } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' -const NESTED_CONTENT_TYPE = 'nestedContent' - export default async function Home() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.home.ids), - getOptimizationData(), - ]) + const entries = await loadPageEntries(PAGES.home.ids) const entriesById = toIdMap(entries) const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates) @@ -27,7 +21,6 @@ export default async function Home() {
-
@@ -41,17 +34,17 @@ export default async function Home() {

Default (inherits global setting)

- +

Always On (liveUpdates=true)

- +

Locked (liveUpdates=false)

- +
) : ( @@ -68,15 +61,13 @@ export default async function Home() { {PAGES.home.auto.flatMap((id) => { const entry = entriesById.get(id) if (!entry) return [] - if (entry.sys.contentType.sys.id === NESTED_CONTENT_TYPE) { - return [ -
- -
, - ] - } return [ - , + , ] })}
@@ -90,7 +81,7 @@ export default async function Home() { {PAGES.home.manual.flatMap((id) => { const entry = entriesById.get(id) if (!entry) return [] - return [] + return [] })}
diff --git a/implementations/nextjs-sdk_hybrid/components/EntryCard.tsx b/implementations/nextjs-sdk_hybrid/components/EntryCard.tsx index ff415f832..7dc6d8c9d 100644 --- a/implementations/nextjs-sdk_hybrid/components/EntryCard.tsx +++ b/implementations/nextjs-sdk_hybrid/components/EntryCard.tsx @@ -1,126 +1,55 @@ -'use client' - -import type { ContentEntrySkeleton, ContentEntry as ContentEntryType } from '@/lib/contentful' -import { useManualViewTracking } from '@/lib/hooks' -import { - isMergeTagEntry, - isRecord, - isResolvedContentfulEntry, - isRichTextDocument, - isUnresolvedEntryLink, -} from '@contentful/optimization-nextjs/api-schemas' -import { OptimizedEntry, useMergeTagResolver } from '@contentful/optimization-nextjs/client' -import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' -import { INLINES } from '@contentful/rich-text-types' +import type { ContentEntry } from '@/lib/contentful' +import { OptimizedEntry } from '@/lib/optimization' import type { EntryClickScenario } from 'e2e-web' import type { JSX } from 'react' - -export interface EntryCardProps { - clickScenario?: EntryClickScenario - entry: ContentEntryType - liveUpdates?: boolean - manualTracking?: boolean - testId?: string -} +import { createRichTextRenderOptions, EntryCardContent } from './EntryCardContent' const HOVER_DURATION_UPDATE_INTERVAL_MS = 1000 -function useRichTextRenderer(): Options { - const { getMergeTagValue } = useMergeTagResolver() - return { - renderNode: { - [INLINES.EMBEDDED_ENTRY]: (node): string => { - const { data } = node - if (!isRecord(data) || !('target' in data)) return '[Merge Tag]' - const { target } = data - if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return '[Merge Tag]' - return getMergeTagValue(target) ?? '' - }, - }, - } +interface EntryCardProps { + baselineEntry: ContentEntry + clickScenario?: EntryClickScenario + manualTracking: boolean } export function EntryCard({ + baselineEntry, clickScenario, - entry, - liveUpdates, manualTracking, - testId, }: EntryCardProps): JSX.Element { - const updateManualViewElement = useManualViewTracking(manualTracking) - const renderOptions = useRichTextRenderer() - const autoTracking = !manualTracking - const id = testId ?? entry.sys.id + const autoTrackViews = !manualTracking return ( -
+
- {(resolvedEntry: ContentEntryType) => { - const richText = Object.values(resolvedEntry.fields).find(isRichTextDocument) - const nested = Array.isArray(resolvedEntry.fields.nested) - ? resolvedEntry.fields.nested.filter(isResolvedContentfulEntry) - : [] - - const content = ( -
updateManualViewElement(el, resolvedEntry.sys.id) - : undefined - } - className="entry-card" - data-ctfl-entry-id={resolvedEntry.sys.id} - data-test-entry-id={resolvedEntry.sys.id} - data-testid={`content-${id}`} - > -
- {richText ? ( - <>{documentToReactComponents(richText, renderOptions)} - ) : ( -

- {typeof resolvedEntry.fields.text === 'string' - ? resolvedEntry.fields.text - : 'No content'} -

- )} -

{`[Entry: ${resolvedEntry.sys.id}]`}

-
- - {clickScenario === 'descendant' ? ( - - ) : null} - - {nested.length > 0 ? ( -
- {nested.map((nestedEntry) => ( - - ))} -
- ) : null} -
+ {(entry, { getMergeTagValue }) => { + const resolvedEntry = entry as ContentEntry + + return ( + ( + + )} + renderOptions={createRichTextRenderOptions(getMergeTagValue)} + testId={baselineEntry.sys.id} + /> ) - - if (autoTracking && clickScenario === 'ancestor') { - return ( -
- {content} -
- ) - } - - return content }}
diff --git a/implementations/nextjs-sdk_hybrid/components/EntryCardContent.tsx b/implementations/nextjs-sdk_hybrid/components/EntryCardContent.tsx new file mode 100644 index 000000000..71aa36e15 --- /dev/null +++ b/implementations/nextjs-sdk_hybrid/components/EntryCardContent.tsx @@ -0,0 +1,101 @@ +import type { ContentEntry, ContentEntrySkeleton } from '@/lib/contentful' +import { + isMergeTagEntry, + isRecord, + isResolvedContentfulEntry, + isRichTextDocument, + isUnresolvedEntryLink, + type MergeTagEntry, +} from '@contentful/optimization-nextjs/api-schemas' +import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' +import { INLINES } from '@contentful/rich-text-types' +import type { EntryClickScenario } from 'e2e-web' +import type { JSX, ReactNode } from 'react' + +type MergeTagValueResolver = (entry: MergeTagEntry) => string | undefined + +interface EntryCardContentProps { + className?: string + clickScenario?: EntryClickScenario + clickableAncestor?: boolean + emptyText?: string + entry: ContentEntry + labelEntryId?: string + renderNestedEntry?: (entry: ContentEntry) => ReactNode + renderOptions?: Options + testId?: string + textAriaLabel?: string +} + +export function createRichTextRenderOptions(getMergeTagValue?: MergeTagValueResolver): Options { + return { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: (node): string => { + const { data } = node + if (!isRecord(data) || !('target' in data)) return '[Merge Tag]' + const { target } = data + if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return '[Merge Tag]' + return getMergeTagValue?.(target) ?? '' + }, + }, + } +} + +export function EntryCardContent({ + className, + clickScenario, + clickableAncestor, + emptyText = '', + entry, + labelEntryId = entry.sys.id, + renderNestedEntry, + renderOptions = createRichTextRenderOptions(), + testId, + textAriaLabel = `Entry: ${labelEntryId}`, +}: EntryCardContentProps): JSX.Element { + const id = testId ?? entry.sys.id + const richText = Object.values(entry.fields).find(isRichTextDocument) + const nested = Array.isArray(entry.fields.nested) + ? entry.fields.nested.filter(isResolvedContentfulEntry) + : [] + + const content = ( +
+
+ {richText ? ( + <>{documentToReactComponents(richText, renderOptions)} + ) : ( +

{typeof entry.fields.text === 'string' ? entry.fields.text : emptyText}

+ )} +

{`[Entry: ${labelEntryId}]`}

+
+ + {clickScenario === 'descendant' ? ( + + ) : null} + + {renderNestedEntry && nested.length > 0 ? ( +
{nested.map(renderNestedEntry)}
+ ) : null} +
+ ) + + return clickableAncestor ? ( +
+ {content} +
+ ) : ( + content + ) +} diff --git a/implementations/nextjs-sdk_hybrid/components/LiveEntryCard.tsx b/implementations/nextjs-sdk_hybrid/components/LiveEntryCard.tsx new file mode 100644 index 000000000..5bb49a558 --- /dev/null +++ b/implementations/nextjs-sdk_hybrid/components/LiveEntryCard.tsx @@ -0,0 +1,34 @@ +'use client' + +import type { ContentEntry } from '@/lib/contentful' +import { OptimizedEntry } from '@/lib/optimization' +import type { JSX } from 'react' +import { createRichTextRenderOptions, EntryCardContent } from './EntryCardContent' + +interface LiveEntryCardProps { + entry: ContentEntry + liveUpdates?: boolean + testId: string +} + +export function LiveEntryCard({ entry, liveUpdates, testId }: LiveEntryCardProps): JSX.Element { + return ( + + {(resolvedEntry, { getMergeTagValue }) => { + const asCf = resolvedEntry as ContentEntry + const text = typeof asCf.fields.text === 'string' ? asCf.fields.text : '' + const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]` + + return ( + + ) + }} + + ) +} diff --git a/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx b/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx index 4161f6fcf..d6c6891c9 100644 --- a/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx +++ b/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx @@ -5,10 +5,10 @@ import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect, type JSX } from 'react' export function PreviewPanel(): JSX.Element | null { - const { isReady, sdk } = useOptimizationContext() + const { sdk } = useOptimizationContext() useEffect(() => { - if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined) { + if (!appConfig.previewPanelEnabled || sdk === undefined) { return } @@ -25,7 +25,7 @@ export function PreviewPanel(): JSX.Element | null { .catch((error: unknown) => { console.warn('Failed to attach the Contentful Optimization preview panel.', error) }) - }, [isReady, sdk]) + }, [sdk]) return null } diff --git a/implementations/nextjs-sdk_hybrid/lib/hooks.ts b/implementations/nextjs-sdk_hybrid/lib/hooks.ts index 153c739c8..7cbc05372 100644 --- a/implementations/nextjs-sdk_hybrid/lib/hooks.ts +++ b/implementations/nextjs-sdk_hybrid/lib/hooks.ts @@ -2,7 +2,6 @@ import { useConsentState, - useOptimization, useOptimizationActions, useOptimizationContext, } from '@contentful/optimization-nextjs/client' @@ -44,7 +43,7 @@ export function useEventStream( parse: (event: unknown, id: string) => T | undefined, update: (previous: T[], next: T) => T[], ): EventStreamState { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [events, setEvents] = useState([]) const [rawCount, setRawCount] = useState(0) const nextId = useRef(0) @@ -52,7 +51,7 @@ export function useEventStream( const updateRef = useRef(update) useEffect(() => { - if (!isReady || sdk === undefined) return + if (sdk === undefined) return const subscription = sdk.states.eventStream.subscribe((event: unknown) => { const id = `event-${nextId.current}` @@ -69,45 +68,22 @@ export function useEventStream( setRawCount(0) nextId.current = 0 } - }, [isReady, sdk]) + }, [sdk]) return { events, rawCount } } export function useFlagSubscription(flagName: string): unknown { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [value, setValue] = useState(undefined) useEffect(() => { - if (!sdk || !isReady) return + if (sdk === undefined) return const subscription = sdk.states.flag(flagName).subscribe(setValue) return () => { subscription.unsubscribe() } - }, [isReady, sdk, flagName]) + }, [sdk, flagName]) return value } - -export function useManualViewTracking( - manualTracking: boolean | undefined, -): (element: HTMLDivElement | null, entryId: string) => void { - const sdk = useOptimization() - const trackedElement = useRef(null) - - useEffect( - () => () => { - const { current } = trackedElement - if (current) sdk.tracking.clearElement('views', current) - }, - [sdk.tracking], - ) - - return (element: HTMLDivElement | null, entryId: string): void => { - const { current: previous } = trackedElement - if (previous && previous !== element) sdk.tracking.clearElement('views', previous) - trackedElement.current = element - if (!element || !manualTracking) return - sdk.tracking.enableElement('views', element, { data: { entryId } }) - } -} diff --git a/implementations/nextjs-sdk_hybrid/lib/optimization.ts b/implementations/nextjs-sdk_hybrid/lib/optimization.ts index 48015e8da..db1c15d3b 100644 --- a/implementations/nextjs-sdk_hybrid/lib/optimization.ts +++ b/implementations/nextjs-sdk_hybrid/lib/optimization.ts @@ -1,37 +1,23 @@ -import { - createNextjsOptimization, - getNextjsServerOptimizationData, -} from '@contentful/optimization-nextjs/server' -import { cookies, headers } from 'next/headers' -import { cache } from 'react' +import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' import { appConfig } from './config' import { getAppConsent } from './util' -export const optimization = createNextjsOptimization({ - clientId: appConfig.clientId, - environment: appConfig.environment, - locale: appConfig.locale, - logLevel: 'debug', - api: appConfig.api, - app: { - name: 'Contentful Optimization Next.js SDK Hybrid (Server)', - version: '0.1.0', - }, -}) - -// cache() deduplicates repeated calls for the same request during one render. -export const getOptimizationData = cache(async () => { - const cookieStore = await cookies() - const headerStore = await headers() - - if (!getAppConsent(cookieStore)) return undefined - - const { data } = await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: appConfig.clientId, + environment: appConfig.environment, locale: appConfig.locale, + logLevel: 'debug', + api: appConfig.api, + trackEntryInteraction: { views: true, clicks: true, hovers: true }, + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: ({ cookies }) => + getAppConsent(cookies) ? { events: true, persistence: true } : false, + }, + app: { + name: 'Contentful Optimization Next.js SDK Hybrid', + version: '0.1.0', + }, }) - - return data -}) diff --git a/implementations/nextjs-sdk_ssr/.env.example b/implementations/nextjs-sdk_ssr/.env.example index 07e791524..4906d2aad 100644 --- a/implementations/nextjs-sdk_ssr/.env.example +++ b/implementations/nextjs-sdk_ssr/.env.example @@ -1,7 +1,7 @@ DOTENV_CONFIG_QUIET=true # SKIP_NO_JS skips the JavaScript-disabled variant resolution suite (SSR, JavaScript disabled). -E2E_FLAGS=SSR,SKIP_NO_JS +E2E_FLAGS=SSR APP_PORT=3001 PUBLIC_NINETAILED_CLIENT_ID="mock-client-id" diff --git a/implementations/nextjs-sdk_ssr/AGENTS.md b/implementations/nextjs-sdk_ssr/AGENTS.md index 64fe94551..8627dfe48 100644 --- a/implementations/nextjs-sdk_ssr/AGENTS.md +++ b/implementations/nextjs-sdk_ssr/AGENTS.md @@ -16,9 +16,7 @@ server/client SDK composition; app code imports only Next.js SDK subpaths. ## E2E tests - All E2E tests live in `lib/e2e-web`. Hydration-specific specs are gated with `runIf('HYDRATION')` - and run automatically when `E2E_FLAGS=CSR,HYDRATION`. This implementation uses - `E2E_FLAGS=SSR,SKIP_NO_JS`: SSR enables the SSR suite, and `SKIP_NO_JS` skips the - JavaScript-disabled variant resolution suite (`Variant Resolution (SSR, JavaScript disabled)`). + and run automatically when `E2E_FLAGS=CSR,HYDRATION`. This implementation uses `E2E_FLAGS=SSR`. - Entry cards must expose `data-ctfl-entry-id` on the `content-*` element so shared selectors work. - `test:e2e` delegates to `lib/e2e-web`. diff --git a/implementations/nextjs-sdk_ssr/README.md b/implementations/nextjs-sdk_ssr/README.md index caebe6f96..3b89e23b7 100644 --- a/implementations/nextjs-sdk_ssr/README.md +++ b/implementations/nextjs-sdk_ssr/README.md @@ -22,8 +22,9 @@ Reference implementation demonstrating `@contentful/optimization-nextjs` in a Next.js App Router application with server-rendered personalization and browser-side tracking. The application imports -public Next.js SDK subpaths for SDK-owned behavior: +public Next.js SDK entrypoints and subpaths for SDK-owned behavior: +- `@contentful/optimization-nextjs` in `@/lib/optimization` for app-local bound components - `@contentful/optimization-nextjs/server` in Server Components and server-only modules - `@contentful/optimization-nextjs/client` in Client Components - `@contentful/optimization-nextjs/api-schemas` for shared schema guards @@ -39,14 +40,15 @@ dynamically loads `@contentful/optimization-web-preview-panel`. Use this implementation when you need a server-rendered Next.js example where personalized content is resolved before browser startup. It demonstrates: -- Server-rendered personalized first paint with `getNextjsServerOptimizationData()` -- Server-rendered tracking markup with `ServerOptimizedEntry` +- App-local bound components from `createNextjsOptimizationComponents()` +- Server-rendered personalized first paint with the bound `OptimizedEntry` server component +- Layout-level SDK setup with the bound `OptimizationRoot` - Request URL capture through `createNextjsOptimizationContextHandler()` - Browser-side page, view, click, and hover tracking through the adapter client entry -- A client-side live updates island with `OptimizedEntry` +- A client-side live updates island with the same bound `OptimizedEntry` name - `initialPageEvent="skip"` when the server already resolved the same initial page -In this SSR pattern, content rendered through `ServerOptimizedEntry` is static after browser +In this SSR pattern, content rendered through server `OptimizedEntry` is static after browser startup. Client actions such as consent, identify, and reset update browser SDK state and analytics, but server-rendered content changes only on the next server request. The live updates section is a client-side island that uses `OptimizedEntry` to demonstrate browser-side re-resolution. @@ -61,15 +63,13 @@ Request app/page.tsx fetches CDA entries - calls getNextjsServerOptimizationData() with cookies() and headers() - resolves entries through the request-bound SDK - renders NextjsOptimizationState near optimized content for server-to-browser state handoff - renders children through ServerOptimizedEntry + renders entries through the bound OptimizedEntry server component + passes getMergeTagValue into shared Rich Text render options Browser startup app/layout.tsx - mounts OptimizationRoot from @contentful/optimization-nextjs/client - passes initialPageEvent="skip" only after consented server page resolution + mounts the bound OptimizationRoot from the app-local optimization module + passes initialPageEvent="skip" because the bound server root owns the initial page call browser tracking NextAppAutoPageTracker handles route page events @@ -140,11 +140,10 @@ pnpm setup:e2e:nextjs-sdk_ssr pnpm test:e2e:nextjs-sdk_ssr ``` -The SSR E2E run uses `E2E_FLAGS=SSR,SKIP_NO_JS` from `.env.example`. It runs the shared navigation, -tracking, and live updates specs against SSR-rendered markup; `SKIP_NO_JS` skips the -JavaScript-disabled variant-resolution block. Hydration-only no-client-Experience-request checks -remain behind `HYDRATION`, and request URL forwarding plus tracking-attribute mapping are covered by -`@contentful/optimization-nextjs` package unit tests. +The SSR E2E run uses `E2E_FLAGS=SSR` from `.env.example`. It runs the shared navigation, tracking, +live updates, and JavaScript-disabled SSR specs against server-rendered markup. Hydration-only +no-client-Experience-request checks remain behind `HYDRATION`, and request URL forwarding plus +tracking-attribute mapping are covered by `@contentful/optimization-nextjs` package unit tests. Use Playwright UI or codegen when needed: diff --git a/implementations/nextjs-sdk_ssr/app/layout.tsx b/implementations/nextjs-sdk_ssr/app/layout.tsx index a2c84a055..959954560 100644 --- a/implementations/nextjs-sdk_ssr/app/layout.tsx +++ b/implementations/nextjs-sdk_ssr/app/layout.tsx @@ -2,8 +2,8 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' -import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' import type { Metadata } from 'next' import { cookies } from 'next/headers' @@ -23,19 +23,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac return ( - + diff --git a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx index 8de467ce3..94460c1f4 100644 --- a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx @@ -1,40 +1,16 @@ import { ControlPanel } from '@/components/ControlPanel' import { CustomViewTracker } from '@/components/CustomViewTracker' import { EntryCard } from '@/components/EntryCard' -import { appConfig } from '@/lib/config' import { loadPageEntries } from '@/lib/contentful' -import { optimization } from '@/lib/optimization' -import { getAppConsent, toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' -import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' +import { toIdMap } from '@/lib/util' import { PAGES } from 'e2e-web' -import { cookies, headers } from 'next/headers' import Link from 'next/link' export default async function PageTwo() { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.pageTwo.ids), - getAppConsent(cookieStore) - ? getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: appConfig.locale, - }).then(({ data }) => data) - : undefined, - ]) - + const entries = await loadPageEntries(PAGES.pageTwo.ids) const entriesById = toIdMap(entries) const autoEntry = entriesById.get(PAGES.pageTwo.auto) const manualEntry = entriesById.get(PAGES.pageTwo.manual) - const autoResolved = autoEntry - ? optimization.resolveOptimizedEntry(autoEntry, optimizationData?.selectedOptimizations) - : undefined - const manualResolved = manualEntry - ? optimization.resolveOptimizedEntry(manualEntry, optimizationData?.selectedOptimizations) - : undefined return (
@@ -47,7 +23,6 @@ export default async function PageTwo() { -
@@ -55,12 +30,8 @@ export default async function PageTwo() {

Auto-observed

- {autoEntry && autoResolved ? ( - + {autoEntry ? ( + ) : (

Auto tracked entry is unavailable.

)} @@ -72,12 +43,8 @@ export default async function PageTwo() {

Manually-observed

- {manualEntry && manualResolved ? ( - + {manualEntry ? ( + ) : (

Manual tracked entry is unavailable.

)} diff --git a/implementations/nextjs-sdk_ssr/app/page.tsx b/implementations/nextjs-sdk_ssr/app/page.tsx index 49e00b721..2e7c014a2 100644 --- a/implementations/nextjs-sdk_ssr/app/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page.tsx @@ -1,47 +1,13 @@ import { ControlPanel } from '@/components/ControlPanel' import { EntryCard } from '@/components/EntryCard' import { LiveEntryCard } from '@/components/LiveEntryCard' -import { appConfig } from '@/lib/config' -import { type ContentEntry, loadPageEntries } from '@/lib/contentful' -import { optimization } from '@/lib/optimization' -import { getAppConsent, toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' -import { - type ServerTrackingResolvedData, - getNextjsServerOptimizationData, -} from '@contentful/optimization-nextjs/server' +import { loadPageEntries } from '@/lib/contentful' +import { toIdMap } from '@/lib/util' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' -import { cookies, headers } from 'next/headers' export default async function Home() { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.home.ids), - getAppConsent(cookieStore) - ? getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: appConfig.locale, - }).then(({ data }) => data) - : undefined, - ]) - + const entries = await loadPageEntries(PAGES.home.ids) const entriesById = toIdMap(entries) - const resolvedById = new Map( - entries.map((entry) => [ - entry.sys.id, - optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations), - ]), - ) - - const profile = optimizationData?.profile - const getMergeTagValue = (entry: unknown): string | undefined => - optimization.getMergeTagValue(entry as never, profile) - const resolveEntry = (entry: ContentEntry): ServerTrackingResolvedData => - optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations) - const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates) return ( @@ -55,7 +21,6 @@ export default async function Home() {
-
@@ -95,17 +60,13 @@ export default async function Home() {
{PAGES.home.auto.flatMap((id) => { const entry = entriesById.get(id) - const resolvedData = resolvedById.get(id) - if (!entry || !resolvedData) return [] + if (!entry) return [] return [ , ] })} @@ -119,18 +80,8 @@ export default async function Home() {
{PAGES.home.manual.flatMap((id) => { const entry = entriesById.get(id) - const resolvedData = resolvedById.get(id) - if (!entry || !resolvedData) return [] - return [ - , - ] + if (!entry) return [] + return [] })}
diff --git a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx index 93f17d363..7dc6d8c9d 100644 --- a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx +++ b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx @@ -1,115 +1,57 @@ -import type { ContentEntry, ContentEntrySkeleton } from '@/lib/contentful' -import { - isRecord, - isResolvedContentfulEntry, - isRichTextDocument, -} from '@contentful/optimization-nextjs/api-schemas' -import { - ServerOptimizedEntry, - type ServerTrackingResolvedData, -} from '@contentful/optimization-nextjs/server' -import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' -import { INLINES } from '@contentful/rich-text-types' +import type { ContentEntry } from '@/lib/contentful' +import { OptimizedEntry } from '@/lib/optimization' import type { EntryClickScenario } from 'e2e-web' import type { JSX } from 'react' +import { createRichTextRenderOptions, EntryCardContent } from './EntryCardContent' const HOVER_DURATION_UPDATE_INTERVAL_MS = 1000 -type MergeTagResolver = (entry: unknown) => string | undefined - interface EntryCardProps { baselineEntry: ContentEntry clickScenario?: EntryClickScenario - getMergeTagValue?: MergeTagResolver manualTracking: boolean - resolveEntry?: (entry: ContentEntry) => ServerTrackingResolvedData - resolvedData: ServerTrackingResolvedData -} - -function buildRenderOptions(getMergeTagValue?: MergeTagResolver): Options { - return { - renderNode: { - [INLINES.EMBEDDED_ENTRY]: (node): string => { - const { data } = node - if (!isRecord(data) || !('target' in data)) return '' - return getMergeTagValue?.(data.target) ?? '' - }, - }, - } } export function EntryCard({ baselineEntry, clickScenario, - getMergeTagValue, manualTracking, - resolveEntry, - resolvedData, }: EntryCardProps): JSX.Element { - const resolvedEntry = resolvedData.entry as ContentEntry const autoTrackViews = !manualTracking - const richText = Object.values(resolvedEntry.fields).find(isRichTextDocument) - const nested = Array.isArray(resolvedEntry.fields.nested) - ? resolvedEntry.fields.nested.filter(isResolvedContentfulEntry) - : [] - const renderOptions = buildRenderOptions(getMergeTagValue) - - const content = ( -
-
- {richText ? ( - <>{documentToReactComponents(richText, renderOptions)} - ) : ( -

{typeof resolvedEntry.fields.text === 'string' ? resolvedEntry.fields.text : ''}

- )} -

{`[Entry: ${baselineEntry.sys.id}]`}

-
- {clickScenario === 'descendant' ? ( - - ) : null} - {nested.length > 0 ? ( -
- {nested.map((nestedEntry) => ( - - ))} -
- ) : null} -
- ) return (
- - {autoTrackViews && clickScenario === 'ancestor' ? ( -
- {content} -
- ) : ( - content - )} -
+ {(entry, { getMergeTagValue }) => { + const resolvedEntry = entry as ContentEntry + + return ( + ( + + )} + renderOptions={createRichTextRenderOptions(getMergeTagValue)} + testId={baselineEntry.sys.id} + /> + ) + }} +
) } diff --git a/implementations/nextjs-sdk_ssr/components/EntryCardContent.tsx b/implementations/nextjs-sdk_ssr/components/EntryCardContent.tsx new file mode 100644 index 000000000..1f5ae0a33 --- /dev/null +++ b/implementations/nextjs-sdk_ssr/components/EntryCardContent.tsx @@ -0,0 +1,99 @@ +import type { ContentEntry, ContentEntrySkeleton } from '@/lib/contentful' +import { + isMergeTagEntry, + isRecord, + isResolvedContentfulEntry, + isRichTextDocument, + isUnresolvedEntryLink, + type MergeTagEntry, +} from '@contentful/optimization-nextjs/api-schemas' +import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' +import { INLINES } from '@contentful/rich-text-types' +import type { EntryClickScenario } from 'e2e-web' +import type { JSX, ReactNode } from 'react' + +type MergeTagValueResolver = (entry: MergeTagEntry) => string | undefined + +interface EntryCardContentProps { + className?: string + clickScenario?: EntryClickScenario + clickableAncestor?: boolean + emptyText?: string + entry: ContentEntry + labelEntryId?: string + renderNestedEntry?: (entry: ContentEntry) => ReactNode + renderOptions?: Options + testId?: string + textAriaLabel?: string +} + +export function createRichTextRenderOptions(getMergeTagValue?: MergeTagValueResolver): Options { + return { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: (node): string => { + const { data } = node + if (!isRecord(data) || !('target' in data)) return '[Merge Tag]' + const { target } = data + if (isUnresolvedEntryLink(target) || !isMergeTagEntry(target)) return '[Merge Tag]' + return getMergeTagValue?.(target) ?? '' + }, + }, + } +} + +export function EntryCardContent({ + className, + clickScenario, + clickableAncestor, + emptyText = '', + entry, + labelEntryId = entry.sys.id, + renderNestedEntry, + renderOptions = createRichTextRenderOptions(), + testId, + textAriaLabel = `Entry: ${labelEntryId}`, +}: EntryCardContentProps): JSX.Element { + const id = testId ?? entry.sys.id + const richText = Object.values(entry.fields).find(isRichTextDocument) + const nested = Array.isArray(entry.fields.nested) + ? entry.fields.nested.filter(isResolvedContentfulEntry) + : [] + + const content = ( +
+
+ {richText ? ( + <>{documentToReactComponents(richText, renderOptions)} + ) : ( +

{typeof entry.fields.text === 'string' ? entry.fields.text : emptyText}

+ )} +

{`[Entry: ${labelEntryId}]`}

+
+ {clickScenario === 'descendant' ? ( + + ) : null} + {renderNestedEntry && nested.length > 0 ? ( +
{nested.map(renderNestedEntry)}
+ ) : null} +
+ ) + + return clickableAncestor ? ( +
+ {content} +
+ ) : ( + content + ) +} diff --git a/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx b/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx index c0dd6d80a..5bb49a558 100644 --- a/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx +++ b/implementations/nextjs-sdk_ssr/components/LiveEntryCard.tsx @@ -1,8 +1,9 @@ 'use client' import type { ContentEntry } from '@/lib/contentful' -import { OptimizedEntry } from '@contentful/optimization-nextjs/client' +import { OptimizedEntry } from '@/lib/optimization' import type { JSX } from 'react' +import { createRichTextRenderOptions, EntryCardContent } from './EntryCardContent' interface LiveEntryCardProps { entry: ContentEntry @@ -13,22 +14,19 @@ interface LiveEntryCardProps { export function LiveEntryCard({ entry, liveUpdates, testId }: LiveEntryCardProps): JSX.Element { return ( - {(resolvedEntry) => { + {(resolvedEntry, { getMergeTagValue }) => { const asCf = resolvedEntry as ContentEntry const text = typeof asCf.fields.text === 'string' ? asCf.fields.text : '' const fullLabel = `${text} [Entry: ${resolvedEntry.sys.id}]` return ( -
-
-

{text}

-

{`[Entry: ${resolvedEntry.sys.id}]`}

-
-
+ entry={asCf} + renderOptions={createRichTextRenderOptions(getMergeTagValue)} + testId={testId} + textAriaLabel={fullLabel} + /> ) }}
diff --git a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx index 4161f6fcf..d6c6891c9 100644 --- a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx +++ b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx @@ -5,10 +5,10 @@ import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect, type JSX } from 'react' export function PreviewPanel(): JSX.Element | null { - const { isReady, sdk } = useOptimizationContext() + const { sdk } = useOptimizationContext() useEffect(() => { - if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined) { + if (!appConfig.previewPanelEnabled || sdk === undefined) { return } @@ -25,7 +25,7 @@ export function PreviewPanel(): JSX.Element | null { .catch((error: unknown) => { console.warn('Failed to attach the Contentful Optimization preview panel.', error) }) - }, [isReady, sdk]) + }, [sdk]) return null } diff --git a/implementations/nextjs-sdk_ssr/lib/hooks.ts b/implementations/nextjs-sdk_ssr/lib/hooks.ts index 153c739c8..110bbf65c 100644 --- a/implementations/nextjs-sdk_ssr/lib/hooks.ts +++ b/implementations/nextjs-sdk_ssr/lib/hooks.ts @@ -44,7 +44,7 @@ export function useEventStream( parse: (event: unknown, id: string) => T | undefined, update: (previous: T[], next: T) => T[], ): EventStreamState { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [events, setEvents] = useState([]) const [rawCount, setRawCount] = useState(0) const nextId = useRef(0) @@ -52,7 +52,7 @@ export function useEventStream( const updateRef = useRef(update) useEffect(() => { - if (!isReady || sdk === undefined) return + if (sdk === undefined) return const subscription = sdk.states.eventStream.subscribe((event: unknown) => { const id = `event-${nextId.current}` @@ -69,22 +69,22 @@ export function useEventStream( setRawCount(0) nextId.current = 0 } - }, [isReady, sdk]) + }, [sdk]) return { events, rawCount } } export function useFlagSubscription(flagName: string): unknown { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [value, setValue] = useState(undefined) useEffect(() => { - if (!sdk || !isReady) return + if (sdk === undefined) return const subscription = sdk.states.flag(flagName).subscribe(setValue) return () => { subscription.unsubscribe() } - }, [isReady, sdk, flagName]) + }, [sdk, flagName]) return value } diff --git a/implementations/nextjs-sdk_ssr/lib/optimization.ts b/implementations/nextjs-sdk_ssr/lib/optimization.ts index 600335ec4..5b4268c00 100644 --- a/implementations/nextjs-sdk_ssr/lib/optimization.ts +++ b/implementations/nextjs-sdk_ssr/lib/optimization.ts @@ -1,14 +1,23 @@ -import { createNextjsOptimization } from '@contentful/optimization-nextjs/server' +import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' import { appConfig } from './config' +import { getAppConsent } from './util' -export const optimization = createNextjsOptimization({ - clientId: appConfig.clientId, - environment: appConfig.environment, - locale: appConfig.locale, - logLevel: 'debug', - api: appConfig.api, - app: { - name: 'Contentful Optimization Next.js SDK SSR (Server)', - version: '0.1.0', - }, -}) +export const { NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } = + createNextjsOptimizationComponents({ + clientId: appConfig.clientId, + environment: appConfig.environment, + locale: appConfig.locale, + logLevel: 'debug', + api: appConfig.api, + trackEntryInteraction: { views: true, clicks: true, hovers: true }, + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: ({ cookies }) => + getAppConsent(cookies) ? { events: true, persistence: true } : false, + }, + app: { + name: 'Contentful Optimization Next.js SDK SSR', + version: '0.1.0', + }, + }) diff --git a/implementations/react-web-sdk/README.md b/implementations/react-web-sdk/README.md index faed142da..4e571ec81 100644 --- a/implementations/react-web-sdk/README.md +++ b/implementations/react-web-sdk/README.md @@ -42,7 +42,7 @@ React framework package. | Live updates (global) | `OptimizationRoot liveUpdates` prop | | Live updates (per-component) | `OptimizedEntry liveUpdates` prop | | Live updates (locked) | `` | -| Merge tag rendering | `useMergeTagResolver().getMergeTagValue()` | +| Merge tag rendering | `OptimizedEntry` render context `getMergeTagValue` | | Nested personalization | Nested `` composition | | Consent gating | `sdk.consent()` via `useOptimizationContext()` | | Identify / reset | `sdk.identify()` / `sdk.reset()` via `useOptimizationContext()` | @@ -177,7 +177,7 @@ react-web-sdk/ │ ├── components/ │ │ ├── AnalyticsEventDisplay.tsx # Live event stream panel (persists across routes) │ │ ├── ControlPanel.tsx # Consent, identify, reset, and conversion controls -│ │ └── RichTextRenderer.tsx # Rich text + merge tag rendering +│ │ └── RichTextRenderer.tsx # Rich text + getMergeTagValue merge tags │ ├── config/ │ │ └── locale.ts # Application Contentful locale │ ├── contentful-generated.d.ts # Generated Contentful entry skeleton types @@ -213,9 +213,12 @@ Implementation-specific touchpoints: - `src/main.tsx` mounts `OptimizationRoot`, `ReactRouterAutoPageTracker`, route configuration, and preview-panel attachment. -- `src/sections/ContentEntry.tsx` demonstrates automatic tracking props and manual view tracking. +- `src/sections/ContentEntry.tsx` demonstrates automatic tracking props, manual view tracking, and + passing `OptimizedEntry` render-context `getMergeTagValue` into rich text rendering. - `src/sections/LiveUpdatesExampleEntry.tsx` compares default, locked, and always-live entry resolution. +- `src/components/RichTextRenderer.tsx` renders rich text merge tags with the `getMergeTagValue` + prop from the `OptimizedEntry` render context. - `src/components/ControlPanel.tsx` demonstrates consent, identify, reset, and conversion actions. ## Code orientation @@ -224,14 +227,14 @@ Implementation-specific touchpoints: | ------------------------------------------ | -------------------------------------------------------------- | | `src/main.tsx` | Configures `OptimizationRoot` and `ReactRouterAutoPageTracker` | | `src/App.tsx` | Subscribes to provider state and renders route-level controls | -| `src/sections/ContentEntry.tsx` | Demonstrates `OptimizedEntry` tracking props and manual views | +| `src/sections/ContentEntry.tsx` | Passes `OptimizedEntry` context into tracked rich text entries | | `src/sections/LiveUpdatesExampleEntry.tsx` | Demonstrates locked and live entry resolution | -| `src/components/RichTextRenderer.tsx` | Demonstrates merge tag rendering with `useMergeTagResolver()` | +| `src/components/RichTextRenderer.tsx` | Consumes `getMergeTagValue` for rich text merge tag rendering | | `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, `RichTextRenderer`, -E2E test files, page/section component structure. +**What stays the same:** `contentfulClient.ts`, locale config, type definitions, 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/react-web-sdk/src/App.tsx b/implementations/react-web-sdk/src/App.tsx index 263826998..5537bd668 100644 --- a/implementations/react-web-sdk/src/App.tsx +++ b/implementations/react-web-sdk/src/App.tsx @@ -22,14 +22,14 @@ function toEntryMap(entries: ContentEntry[]): Map { } export default function App(): JSX.Element { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() const { onToggleGlobalLiveUpdates } = useOutletContext() const [selectedOptimizationCount, setSelectedOptimizationCount] = useState(0) const [entries, setEntries] = useState([]) useEffect(() => { - if (!sdk || !isReady) { + if (sdk === undefined) { return } @@ -40,10 +40,10 @@ export default function App(): JSX.Element { return () => { selectedOptSub.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) useEffect(() => { - if (!sdk || !isReady) { + if (sdk === undefined) { return } @@ -55,7 +55,7 @@ export default function App(): JSX.Element { void fetchEntries(PAGES.home.ids).then((nextEntries) => { setEntries(nextEntries) }) - }, [isReady, sdk]) + }, [sdk]) const entriesById = useMemo(() => toEntryMap(entries), [entries]) const liveUpdatesBaselineEntry = entriesById.get(PAGES.home.liveUpdates) @@ -64,7 +64,7 @@ export default function App(): JSX.Element { return

{error.message}

} - if (!sdk || !isReady) { + if (sdk === undefined) { return

Loading SDK...

} diff --git a/implementations/react-web-sdk/src/components/AnalyticsEventDisplay.tsx b/implementations/react-web-sdk/src/components/AnalyticsEventDisplay.tsx index 6b69d59cb..b18760b64 100644 --- a/implementations/react-web-sdk/src/components/AnalyticsEventDisplay.tsx +++ b/implementations/react-web-sdk/src/components/AnalyticsEventDisplay.tsx @@ -151,7 +151,7 @@ function toDuration(event: AnalyticsEvent): number | undefined { } export function AnalyticsEventDisplay(): JSX.Element { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [events, setEvents] = useState([]) const [rawEventsCount, setRawEventsCount] = useState(0) const [, tick] = useReducer((n: number) => n + 1, 0) @@ -165,7 +165,7 @@ export function AnalyticsEventDisplay(): JSX.Element { }, []) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { setEvents([]) setRawEventsCount(0) nextId.current = 0 @@ -187,7 +187,7 @@ export function AnalyticsEventDisplay(): JSX.Element { return () => { subscription.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) return (
diff --git a/implementations/react-web-sdk/src/components/ControlPanel.tsx b/implementations/react-web-sdk/src/components/ControlPanel.tsx index 2ce5a6e1b..7a4451d60 100644 --- a/implementations/react-web-sdk/src/components/ControlPanel.tsx +++ b/implementations/react-web-sdk/src/components/ControlPanel.tsx @@ -193,7 +193,7 @@ interface ControlPanelProps { } export function ControlPanel({ demoCTA: onTrackConversion }: ControlPanelProps): JSX.Element { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const { globalLiveUpdates, previewPanelVisible, setPreviewPanelVisible } = useLiveUpdates() const { onToggleGlobalLiveUpdates } = useOutletContext() @@ -203,7 +203,7 @@ export function ControlPanel({ demoCTA: onTrackConversion }: ControlPanelProps): const [booleanFlag, setBooleanFlag] = useState(undefined) useEffect(() => { - if (!sdk || !isReady) { + if (sdk === undefined) { return } @@ -220,7 +220,7 @@ export function ControlPanel({ demoCTA: onTrackConversion }: ControlPanelProps): selectedOptSub.unsubscribe() flagSub.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) const isIdentified = useMemo(() => Boolean(profile?.traits.identified), [profile]) diff --git a/implementations/react-web-sdk/src/components/RichTextRenderer.tsx b/implementations/react-web-sdk/src/components/RichTextRenderer.tsx index 637b1df34..b1a51bf09 100644 --- a/implementations/react-web-sdk/src/components/RichTextRenderer.tsx +++ b/implementations/react-web-sdk/src/components/RichTextRenderer.tsx @@ -1,4 +1,4 @@ -import { useMergeTagResolver } from '@contentful/optimization-react-web' +import type { OptimizedEntryRenderContext } from '@contentful/optimization-react-web' import { isMergeTagEntry, isRecord, @@ -17,10 +17,11 @@ interface RichTextNode { } interface RichTextRendererProps { + getMergeTagValue: GetMergeTagValue richText: RichTextDocument } -type GetMergeTagValue = ReturnType['getMergeTagValue'] +type GetMergeTagValue = OptimizedEntryRenderContext['getMergeTagValue'] const EMBEDDED_ENTRY_NODE_TYPE = 'embedded-entry-inline' @@ -58,9 +59,10 @@ export function getRichTextContent( .trim() } -export function RichTextRenderer({ richText }: RichTextRendererProps): JSX.Element { - const { getMergeTagValue } = useMergeTagResolver() - +export function RichTextRenderer({ + getMergeTagValue, + richText, +}: RichTextRendererProps): JSX.Element { const renderOptions: Options = { renderNode: { [INLINES.EMBEDDED_ENTRY]: (node): string => { diff --git a/implementations/react-web-sdk/src/sections/ContentEntry.tsx b/implementations/react-web-sdk/src/sections/ContentEntry.tsx index 036410145..2faaec9ad 100644 --- a/implementations/react-web-sdk/src/sections/ContentEntry.tsx +++ b/implementations/react-web-sdk/src/sections/ContentEntry.tsx @@ -69,7 +69,7 @@ export function ContentEntry({ } trackViews={autoTrackViews ? undefined : false} > - {(resolvedEntry) => { + {(resolvedEntry, { getMergeTagValue }) => { const asCf = resolvedEntry as ContentEntryType const richTextField = Object.values(asCf.fields).find(isRichTextDocument) const fullLabel = `Entry: ${asCf.sys.id}` @@ -89,7 +89,7 @@ export function ContentEntry({ >
{richTextField ? ( - + ) : (

{getEntryText(asCf)}

)} diff --git a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx index 26c74c07b..9d24d0e96 100644 --- a/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx +++ b/implementations/web-sdk_react/src/components/AnalyticsEventDisplay.tsx @@ -151,7 +151,7 @@ function toDuration(event: AnalyticsEvent): number | undefined { } export function AnalyticsEventDisplay(): JSX.Element { - const { sdk, isReady } = useOptimization() + const { sdk } = useOptimization() const [events, setEvents] = useState([]) const [rawEventsCount, setRawEventsCount] = useState(0) const [, tick] = useReducer((n: number) => n + 1, 0) @@ -165,7 +165,7 @@ export function AnalyticsEventDisplay(): JSX.Element { }, []) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { setEvents([]) setRawEventsCount(0) nextId.current = 0 @@ -187,7 +187,7 @@ export function AnalyticsEventDisplay(): JSX.Element { return () => { subscription.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) return (
diff --git a/implementations/web-sdk_react/src/components/ControlPanel.tsx b/implementations/web-sdk_react/src/components/ControlPanel.tsx index b65edb92b..b8d35adca 100644 --- a/implementations/web-sdk_react/src/components/ControlPanel.tsx +++ b/implementations/web-sdk_react/src/components/ControlPanel.tsx @@ -140,27 +140,27 @@ function ControlPanelFields({ {globalLiveUpdates ? 'OFF' : 'ON'} + + Preview panel + + + {isPreviewPanelOpen ? 'Open' : 'Closed'} + {ENABLE_PREVIEW_PANEL ? ( - <> - - Preview panel - - - {isPreviewPanelOpen ? 'Open' : 'Closed'} - - - - ) : null} + + ) : ( + + )} { @@ -18,7 +18,7 @@ function RootApp(): React.JSX.Element { const app = - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return app } diff --git a/implementations/web-sdk_react/src/optimization/OptimizationProvider.tsx b/implementations/web-sdk_react/src/optimization/OptimizationProvider.tsx index 435a09e1b..e0b19f829 100644 --- a/implementations/web-sdk_react/src/optimization/OptimizationProvider.tsx +++ b/implementations/web-sdk_react/src/optimization/OptimizationProvider.tsx @@ -3,7 +3,6 @@ import { getOptimization, type OptimizationInstance } from './createOptimization export interface OptimizationContextValue { sdk: OptimizationInstance | undefined - isReady: boolean error: Error | undefined } @@ -15,12 +14,11 @@ export function OptimizationProvider({ children }: PropsWithChildren): JSX.Eleme // Intentionally use a singleton SDK instance to avoid re-initialization during // React StrictMode double invocation in development. const sdk = getOptimization() - return { sdk, isReady: true, error: undefined } + return { sdk, error: undefined } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown Optimization init error' return { sdk: undefined, - isReady: false, error: error instanceof Error ? error : new Error(message), } } diff --git a/implementations/web-sdk_react/src/optimization/hooks/useAnalytics.ts b/implementations/web-sdk_react/src/optimization/hooks/useAnalytics.ts index 6edc15aeb..dac6346bf 100644 --- a/implementations/web-sdk_react/src/optimization/hooks/useAnalytics.ts +++ b/implementations/web-sdk_react/src/optimization/hooks/useAnalytics.ts @@ -10,10 +10,10 @@ export interface UseAnalyticsResult { } export function useAnalytics(): UseAnalyticsResult { - const { sdk, isReady } = useOptimization() + const { sdk } = useOptimization() return useMemo(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return { trackView: (_payload: TrackViewPayload): undefined => undefined, } @@ -22,5 +22,5 @@ export function useAnalytics(): UseAnalyticsResult { return { trackView: async (payload: TrackViewPayload): TrackViewResult => await sdk.trackView(payload), } - }, [isReady, sdk]) + }, [sdk]) } diff --git a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts index b509097e1..5f27e0e8d 100644 --- a/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts +++ b/implementations/web-sdk_react/src/optimization/hooks/useOptimizationResolver.ts @@ -44,14 +44,14 @@ function toStringValue(value: unknown): string { } export function useOptimizationResolver(): UseOptimizationResolverResult { - const { sdk, isReady } = useOptimization() + const { sdk } = useOptimization() // Subscribe to selectedOptimizations so resolveEntry gets a new identity when the // Experience API responds. Without this, ContentEntry's useMemo would lock in the // baseline on first render (signal still empty) and never re-resolve on slow browsers. const { selectedOptimizations } = useOptimizationState(sdk?.states) return useMemo(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { return { resolveEntry: fallbackResolveEntry, getMergeTagValue: (_mergeTagEntry: MergeTagEntry): string => '', @@ -71,5 +71,5 @@ export function useOptimizationResolver(): UseOptimizationResolverResult { getMergeTagValue: (mergeTagEntry: MergeTagEntry): string => toStringValue(sdk.getMergeTagValue(mergeTagEntry)), } - }, [isReady, sdk, selectedOptimizations]) + }, [sdk, selectedOptimizations]) } diff --git a/implementations/web-sdk_react/src/sections/ContentEntry.tsx b/implementations/web-sdk_react/src/sections/ContentEntry.tsx index 94ec153b3..9768f38a2 100644 --- a/implementations/web-sdk_react/src/sections/ContentEntry.tsx +++ b/implementations/web-sdk_react/src/sections/ContentEntry.tsx @@ -194,7 +194,7 @@ export function ContentEntry({ entry, observation, }: ContentEntryProps): JSX.Element { - const { sdk, isReady } = useOptimization() + const { sdk } = useOptimization() const { resolveEntry } = useOptimizationResolver() const containerRef = useRef(null) @@ -207,7 +207,7 @@ export function ContentEntry({ ) useEffect(() => { - if (!isReady || sdk === undefined || observation !== 'manual' || !hasTrackingApi(sdk)) { + if (sdk === undefined || observation !== 'manual' || !hasTrackingApi(sdk)) { return } @@ -230,7 +230,7 @@ export function ContentEntry({ return () => { sdk.tracking.clearElement('views', element) } - }, [experienceId, isReady, observation, resolvedEntry.sys.id, sdk, sticky, variantIndex]) + }, [experienceId, observation, resolvedEntry.sys.id, sdk, sticky, variantIndex]) const richTextField = Object.values(resolvedEntry.fields).find(isRichTextDocument) diff --git a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx index 9f4698a2d..a55e591ed 100644 --- a/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx +++ b/implementations/web-sdk_react/src/sections/LiveUpdatesExampleEntry.tsx @@ -16,7 +16,7 @@ export function LiveUpdatesExampleEntry({ liveUpdates, testIdPrefix, }: LiveUpdatesExampleEntryProps): JSX.Element { - const { sdk, isReady } = useOptimization() + const { sdk } = useOptimization() const { resolveEntry } = useOptimizationResolver() const liveUpdatesContext = useLiveUpdates() const [lockedSelectedOptimizations, setLockedSelectedOptimizations] = useState< @@ -28,7 +28,7 @@ export function LiveUpdatesExampleEntry({ (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false) useEffect(() => { - if (!isReady || sdk === undefined) { + if (sdk === undefined) { setLockedSelectedOptimizations(undefined) return } @@ -51,7 +51,7 @@ export function LiveUpdatesExampleEntry({ return () => { subscription.unsubscribe() } - }, [isReady, sdk, shouldLiveUpdate]) + }, [sdk, shouldLiveUpdate]) const { entry: resolvedEntry } = useMemo( () => resolveEntry(baselineEntry, lockedSelectedOptimizations), diff --git a/lib/e2e-web/README.md b/lib/e2e-web/README.md index 2894176b0..5f2c849f4 100644 --- a/lib/e2e-web/README.md +++ b/lib/e2e-web/README.md @@ -48,8 +48,8 @@ spec groups to run: - `APP_PORT` - port the app listens on. It defaults to `3000`; Angular sets `APP_PORT=4200`. - `E2E_FLAGS` - comma-separated feature flags for spec gating. It defaults to `CSR` when unset. Supported values are `CSR` for client-side behavior, `SSR` for server-rendered variant checks, - `HYDRATION` for server-rendering hydration checks, and `SKIP_NO_JS` to skip JavaScript-disabled - SSR checks. + `HYDRATION` for server-rendering hydration checks, and `SKIP_NO_JS` for implementations that + explicitly opt out of JavaScript-disabled SSR checks. Current implementation env defaults: @@ -59,7 +59,7 @@ Current implementation env defaults: | `react-web-sdk` | `3000` | `CSR` | | `web-sdk_react` | `3000` | `CSR` | | `web-sdk_angular` | `4200` | `CSR,HYDRATION,SSR,SKIP_NO_JS` | -| `nextjs-sdk_ssr` | `3001` | `SSR,SKIP_NO_JS` | +| `nextjs-sdk_ssr` | `3001` | `SSR` | | `nextjs-sdk_hybrid` | `3002` | `CSR,HYDRATION` | The config also starts the shared mock server from `lib/mocks` as a Playwright `webServer`. Both web @@ -86,7 +86,7 @@ IMPLEMENTATION=web-sdk_react pnpm --dir ../../lib/e2e-web test IMPLEMENTATION=web-sdk_angular APP_PORT=4200 E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS pnpm --dir ../../lib/e2e-web test # nextjs-sdk_ssr -IMPLEMENTATION=nextjs-sdk_ssr APP_PORT=3001 E2E_FLAGS=SSR,SKIP_NO_JS pnpm --dir ../../lib/e2e-web test +IMPLEMENTATION=nextjs-sdk_ssr APP_PORT=3001 E2E_FLAGS=SSR pnpm --dir ../../lib/e2e-web test # nextjs-sdk_hybrid IMPLEMENTATION=nextjs-sdk_hybrid APP_PORT=3002 E2E_FLAGS=CSR,HYDRATION pnpm --dir ../../lib/e2e-web test diff --git a/lib/e2e-web/e2e/ssr.spec.ts b/lib/e2e-web/e2e/ssr.spec.ts index f3bac46a8..1a5a85a60 100644 --- a/lib/e2e-web/e2e/ssr.spec.ts +++ b/lib/e2e-web/e2e/ssr.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { CONSENT_COOKIE, runIf, seedAnonymousProfile, seedIdentifiedProfile, skipIf } from './utils' test.describe('Hydration', () => { @@ -65,4 +65,30 @@ test.describe('SSR first-paint state', () => { await expect(page.getByTestId('identified-status')).toHaveText('Yes') }) }) + + test.describe('server-resolved variants', () => { + const BASELINE_ID = '2Z2WLOx07InSewC3LUB3eX' + const EXPERIENCE_ID = '2cSY1TX0nDfYe4fuIrGQ1K' + const VARIANT_ENTRY_ID = '1UFf7qr4mHET3HYuYmcpEj' + + async function expectServerResolvedVariant(page: Page): Promise { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + + const host = page.locator(`[data-ctfl-baseline-id="${BASELINE_ID}"]`).first() + await expect(host).toHaveAttribute('data-ctfl-entry-id', VARIANT_ENTRY_ID) + await expect(host).toHaveAttribute('data-ctfl-optimization-id', EXPERIENCE_ID) + await expect(host).toHaveAttribute('data-ctfl-variant-index', '1') + } + + test('renders the variant for a new visitor before consent', async ({ page }) => { + await expectServerResolvedVariant(page) + }) + + test('renders the variant for a consented visitor', async ({ baseURL, context, page }) => { + await seedAnonymousProfile(context, baseURL) + + await expectServerResolvedVariant(page) + }) + }) }) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 6879699fd..2005586d9 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -37,6 +37,9 @@ For pnpm-managed packages with matching scripts, use `pnpm --filter eventType === name) } - onBlockedByConsent(name: string, args: readonly unknown[]): void { + private onBlockedByConsent(name: string, args: readonly unknown[]): void { coreLogger.warn( `Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(args)}`, ) diff --git a/packages/universal/core-sdk/src/bridge-support/capabilities.ts b/packages/universal/core-sdk/src/bridge-support/capabilities.ts index 6cfc31d89..bb6afccf8 100644 --- a/packages/universal/core-sdk/src/bridge-support/capabilities.ts +++ b/packages/universal/core-sdk/src/bridge-support/capabilities.ts @@ -1,36 +1,18 @@ -import type { - ChangeArray, - OptimizationData, - Profile, - SelectedOptimizationArray, -} from '@contentful/optimization-api-client/api-schemas' -import type { Signal } from '@preact/signals-core' import type { LifecycleInterceptors } from '../CoreBase' import { signals } from '../signals' import { applyOptimizationDataToSignals } from '../state/applyOptimizationDataToSignals' - -export const CORE_BRIDGE_CAPABILITIES_SYMBOL = Symbol.for( - 'ctfl.optimization.internal.bridgeSupport', -) - -export interface PreviewPanelBridge { - readonly changes: Signal - readonly consent: Signal - readonly previewPanelAttached: Signal - readonly previewPanelOpen: Signal - readonly profile: Signal - readonly selectedOptimizations: Signal - readonly stateInterceptors: Pick -} - -export interface CoreBridgeCapabilities { - readonly getPreviewPanelBridge: () => PreviewPanelBridge - readonly hydrateOptimizationData: (data: OptimizationData) => Promise -} - -export interface CoreBridgeHost { - readonly [CORE_BRIDGE_CAPABILITIES_SYMBOL]?: CoreBridgeCapabilities -} +import { + CORE_BRIDGE_CAPABILITIES_SYMBOL, + type CoreBridgeCapabilities, +} from './coreBridgeCapabilities' + +export { + CORE_BRIDGE_CAPABILITIES_SYMBOL, + getCoreBridgeCapabilities, + type CoreBridgeCapabilities, + type CoreBridgeHost, + type PreviewPanelBridge, +} from './coreBridgeCapabilities' export function installCoreBridgeCapabilities( host: object, @@ -58,14 +40,3 @@ export function installCoreBridgeCapabilities( writable: false, }) } - -export function getCoreBridgeCapabilities(sdk: unknown): CoreBridgeCapabilities | undefined { - if (sdk === null || (typeof sdk !== 'object' && typeof sdk !== 'function')) return undefined - if (!hasCoreBridgeCapabilities(sdk)) return undefined - - return sdk[CORE_BRIDGE_CAPABILITIES_SYMBOL] -} - -function hasCoreBridgeCapabilities(host: object): host is CoreBridgeHost { - return CORE_BRIDGE_CAPABILITIES_SYMBOL in host -} diff --git a/packages/universal/core-sdk/src/bridge-support/coreBridgeCapabilities.ts b/packages/universal/core-sdk/src/bridge-support/coreBridgeCapabilities.ts new file mode 100644 index 000000000..42a780919 --- /dev/null +++ b/packages/universal/core-sdk/src/bridge-support/coreBridgeCapabilities.ts @@ -0,0 +1,62 @@ +import type { + ChangeArray, + OptimizationData, + Profile, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import type { Signal } from '@preact/signals-core' +import type { LifecycleInterceptors } from '../CoreBase' + +export const CORE_BRIDGE_CAPABILITIES_SYMBOL = Symbol.for( + 'ctfl.optimization.internal.bridgeSupport', +) + +/** + * Mutable state bridge exposed to preview-panel integrations. + * + * @public + */ +export interface PreviewPanelBridge { + readonly changes: Signal + readonly consent: Signal + readonly previewPanelAttached: Signal + readonly previewPanelOpen: Signal + readonly profile: Signal + readonly selectedOptimizations: Signal + readonly stateInterceptors: Pick +} + +/** + * Internal bridge capabilities exposed by compatible SDK instances. + * + * @public + */ +export interface CoreBridgeCapabilities { + readonly getPreviewPanelBridge: () => PreviewPanelBridge + readonly hydrateOptimizationData: (data: OptimizationData) => Promise +} + +/** + * Object that may expose core bridge capabilities. + * + * @public + */ +export interface CoreBridgeHost { + readonly [CORE_BRIDGE_CAPABILITIES_SYMBOL]?: CoreBridgeCapabilities +} + +/** + * Read bridge capabilities from a compatible SDK instance. + * + * @public + */ +export function getCoreBridgeCapabilities(sdk: unknown): CoreBridgeCapabilities | undefined { + if (sdk === null || (typeof sdk !== 'object' && typeof sdk !== 'function')) return undefined + if (!hasCoreBridgeCapabilities(sdk)) return undefined + + return sdk[CORE_BRIDGE_CAPABILITIES_SYMBOL] +} + +function hasCoreBridgeCapabilities(host: object): host is CoreBridgeHost { + return CORE_BRIDGE_CAPABILITIES_SYMBOL in host +} diff --git a/packages/universal/core-sdk/src/bridge-support/index.ts b/packages/universal/core-sdk/src/bridge-support/index.ts index 6dbb306cf..633387bb5 100644 --- a/packages/universal/core-sdk/src/bridge-support/index.ts +++ b/packages/universal/core-sdk/src/bridge-support/index.ts @@ -9,7 +9,7 @@ import { getCoreBridgeCapabilities, type CoreBridgeCapabilities, type PreviewPanelBridge, -} from './capabilities' +} from './coreBridgeCapabilities' export type { CoreBridgeCapabilities, PreviewPanelBridge } diff --git a/packages/universal/core-sdk/src/consent/Consent.ts b/packages/universal/core-sdk/src/consent/Consent.ts index c5dbfb3c0..73737e536 100644 --- a/packages/universal/core-sdk/src/consent/Consent.ts +++ b/packages/universal/core-sdk/src/consent/Consent.ts @@ -31,8 +31,8 @@ export interface ConsentController { * * @internal * @remarks - * These methods are consumed by consent-gated send paths to decide whether to proceed with an - * operation and how to report blocked calls. + * This predicate is consumed by consent-gated send paths to decide whether to proceed with an + * operation. */ export interface ConsentGuard { /** @@ -45,13 +45,4 @@ export interface ConsentGuard { * The mapping between method names and event type strings can be product‑specific. */ hasConsent: (name: string) => boolean - - /** - * Hook invoked when an operation is blocked due to missing consent. - * - * @param name - The blocked operation/method name. - * @param args - The original call arguments, provided for diagnostics/telemetry. - * @returns Nothing. Implementations typically log and/or emit diagnostics. - */ - onBlockedByConsent: (name: string, args: unknown[]) => void } diff --git a/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts new file mode 100644 index 000000000..1689e670b --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts @@ -0,0 +1,33 @@ +import type CoreStateful from '../CoreStateful' + +type RuntimeMembers = + | 'consent' + | 'destroy' + | 'flush' + | 'getFlag' + | 'getMergeTagValue' + | 'hasConsent' + | 'identify' + | 'locale' + | 'page' + | 'reset' + | 'resolveOptimizedEntry' + | 'screen' + | 'setLocale' + | 'states' + | 'track' + | 'trackClick' + | 'trackFlagView' + | 'trackHover' + | 'trackView' + +/** + * Runtime contract shared by the stateful core and snapshot runtimes. + * + * @remarks + * This surface includes consumer-facing runtime methods and state, excluding SDK infrastructure + * such as API clients, interceptors, and internal resolvers. + * + * @public + */ +export interface OptimizationRuntime extends Pick {} diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts new file mode 100644 index 000000000..730533c6f --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts @@ -0,0 +1,87 @@ +import type { OptimizationData } from '@contentful/optimization-api-client/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { mergeTagEntry } from '../test/fixtures/mergeTagEntry' +import { optimizedEntry } from '../test/fixtures/optimizedEntry' +import { profile } from '../test/fixtures/profile' +import { selectedOptimizations } from '../test/fixtures/selectedOptimizations' +import { createSnapshotRuntime } from './SnapshotRuntime' + +const snapshotData: OptimizationData = { + changes: [ + { + key: 'theme', + meta: { experienceId: 'exp-theme', variantIndex: 1 }, + type: 'Variable', + value: 'dark', + }, + ], + profile, + selectedOptimizations, +} + +describe('SnapshotRuntime', () => { + it('resolves entries, merge tags, and flags from snapshot data', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + const resolved = runtime.resolveOptimizedEntry(optimizedEntry) + const explicit = runtime.resolveOptimizedEntry(optimizedEntry, selectedOptimizations) + + expect(resolved.entry.sys.id).toBe(explicit.entry.sys.id) + expect(resolved.selectedOptimization).toEqual(explicit.selectedOptimization) + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('EU') + expect(runtime.getFlag('theme')).toBe('dark') + }) + + it('exposes settled static state for SSR and first client render', () => { + const runtime = createSnapshotRuntime({ + allowedEventTypes: ['page'], + consent: false, + data: snapshotData, + locale: 'en-US', + persistenceConsent: false, + }) + const observedConsent: Array = [] + + const subscription = runtime.states.consent.subscribe((value) => { + observedConsent.push(value) + }) + + expect(runtime.locale).toBe('en-US') + expect(runtime.states.profile.current).toEqual(profile) + expect(runtime.states.selectedOptimizations.current).toEqual(selectedOptimizations) + expect(runtime.states.canOptimize.current).toBe(true) + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'success' }) + expect(runtime.states.optimizationPossible.current).toBe(true) + expect(runtime.hasConsent('page')).toBe(true) + expect(runtime.hasConsent('track')).toBe(false) + expect(observedConsent).toEqual([false]) + subscription.unsubscribe() + }) + + it('falls back to baseline state without optimization data', () => { + const runtime = createSnapshotRuntime({ allowedEventTypes: [], consent: false }) + const resolved = runtime.resolveOptimizedEntry(optimizedEntry) + + expect(resolved.entry).toBe(optimizedEntry) + expect(resolved.selectedOptimization).toBeUndefined() + expect(runtime.states.profile.current).toBeUndefined() + expect(runtime.states.selectedOptimizations.current).toBeUndefined() + expect(runtime.states.canOptimize.current).toBe(false) + expect(runtime.states.optimizationPossible.current).toBe(false) + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('Nowhere') + }) + + it('treats server-side actions as inert no-ops', async () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + await expect(runtime.identify({ userId: 'user-1' })).resolves.toEqual({ accepted: false }) + await expect(runtime.page()).resolves.toEqual({ accepted: false }) + await expect(runtime.track({ event: 'purchase' })).resolves.toEqual({ accepted: false }) + await expect(runtime.flush()).resolves.toBeUndefined() + expect(runtime.setLocale('de-DE')).toBeUndefined() + expect(() => { + runtime.consent(true) + runtime.reset() + runtime.destroy() + }).not.toThrow() + }) +}) diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts new file mode 100644 index 000000000..709e7b906 --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts @@ -0,0 +1,249 @@ +import type { + ChangeArray, + Json, + MergeTagEntry, + OptimizationData, + Profile, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import { createScopedLogger } from '@contentful/optimization-api-client/logger' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' +import type { CoreStates } from '../CoreStateful' +import type { EventEmissionResult } from '../events/EventEmissionResult' +import { type AllowedEventType, DEFAULT_ALLOWED_EVENT_TYPES } from '../events/EventType' +import FlagsResolver from '../resolvers/FlagsResolver' +import MergeTagValueResolver from '../resolvers/MergeTagValueResolver' +import type { ResolvedData } from '../resolvers/OptimizedEntryResolver' +import OptimizedEntryResolver from '../resolvers/OptimizedEntryResolver' +import { staticObservable } from '../signals' +import type { OptimizationRuntime } from './OptimizationRuntime' + +const logger = createScopedLogger('Optimization:SnapshotRuntime') + +const CONSENT_EVENT_TYPE_MAP: Readonly>> = { + trackView: ['component'], + trackFlagView: ['flag', 'component'], + trackClick: ['component_click'], + trackHover: ['component_hover'], +} + +const OPTIMIZATION_UNLOCKING_EVENT_TYPES: readonly AllowedEventType[] = [ + 'identify', + 'page', + 'screen', + 'track', + 'group', + 'alias', + 'component', +] + +function resolveHasConsent( + name: string, + consent: boolean | undefined, + allowedEventTypes: readonly AllowedEventType[], +): boolean { + if (consent === true) return true + + const { [name]: mappedEventTypes } = CONSENT_EVENT_TYPE_MAP + if (mappedEventTypes !== undefined) { + return mappedEventTypes.some((eventType) => allowedEventTypes.includes(eventType)) + } + + return allowedEventTypes.some((eventType) => eventType === name) +} + +function resolveOptimizationPossible( + consent: boolean | undefined, + allowedEventTypes: readonly AllowedEventType[], +): boolean { + if (consent === true) return true + + return OPTIMIZATION_UNLOCKING_EVENT_TYPES.some((type) => allowedEventTypes.includes(type)) +} + +/** + * Read-only optimization state used to create a snapshot runtime. + * + * @public + */ +export interface OptimizationSnapshot { + /** Optimization data returned by a server-side Experience API request. */ + readonly data?: OptimizationData + /** Tracking consent for the current request or render. */ + readonly consent?: boolean + /** Persistence consent for the current request or render. */ + readonly persistenceConsent?: boolean + /** Locale used to resolve localized flags and entries. */ + readonly locale?: string + /** Event types allowed to affect optimization behavior without full consent. */ + readonly allowedEventTypes?: readonly AllowedEventType[] +} + +const INERT_ACTION_WARNING = + 'Optimization action called on the server (read-only runtime); it is a no-op.' + +const ACCEPTED_NOOP_RESULT: EventEmissionResult = { accepted: false } + +/** + * Read-only runtime backed by an immutable optimization snapshot. + * + * @public + */ +class SnapshotRuntime implements OptimizationRuntime { + private readonly snapshot: OptimizationSnapshot + private readonly changes: ChangeArray | undefined + private readonly currentSelectedOptimizations: SelectedOptimizationArray | undefined + private readonly currentProfile: Profile | undefined + private readonly allowedEventTypes: readonly AllowedEventType[] + + /** Static observable state derived from the snapshot. */ + readonly states: CoreStates + + constructor(snapshot: OptimizationSnapshot = {}) { + this.snapshot = snapshot + this.changes = snapshot.data?.changes + this.currentSelectedOptimizations = snapshot.data?.selectedOptimizations + this.currentProfile = snapshot.data?.profile + this.allowedEventTypes = snapshot.allowedEventTypes ?? DEFAULT_ALLOWED_EVENT_TYPES + + this.states = { + blockedEventStream: staticObservable(undefined), + consent: staticObservable(snapshot.consent), + persistenceConsent: staticObservable(snapshot.persistenceConsent ?? snapshot.consent), + eventStream: staticObservable(undefined), + locale: staticObservable(snapshot.locale), + canOptimize: staticObservable(this.currentSelectedOptimizations !== undefined), + optimizationPossible: staticObservable( + resolveOptimizationPossible(snapshot.consent, this.allowedEventTypes), + ), + experienceRequestState: staticObservable({ status: 'success' }), + selectedOptimizations: staticObservable(this.currentSelectedOptimizations), + previewPanelAttached: staticObservable(false), + previewPanelOpen: staticObservable(false), + profile: staticObservable(this.currentProfile), + flag: (name: string) => staticObservable(this.getFlag(name)), + } + } + + resolveOptimizedEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations?: SelectedOptimizationArray, + ): ResolvedData + resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >(entry: Entry, selectedOptimizations?: SelectedOptimizationArray): ResolvedData + resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations?: SelectedOptimizationArray, + ): ResolvedData { + return OptimizedEntryResolver.resolve( + entry, + selectedOptimizations ?? this.currentSelectedOptimizations, + ) + } + + getMergeTagValue( + embeddedEntryNodeTarget: MergeTagEntry, + profile: Profile | undefined = this.currentProfile, + ): string | undefined { + return MergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) + } + + getFlag(name: string, changes: ChangeArray | undefined = this.changes): Json { + return FlagsResolver.resolve(changes)[name] + } + + get locale(): string | undefined { + return this.snapshot.locale + } + + hasConsent(name: string): boolean { + return resolveHasConsent(name, this.snapshot.consent, this.allowedEventTypes) + } + + async identify(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async page(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async screen(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async track(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async trackView(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async trackClick(): Promise { + this.warnInert() + await Promise.resolve() + } + + async trackHover(): Promise { + this.warnInert() + await Promise.resolve() + } + + async trackFlagView(): Promise { + this.warnInert() + await Promise.resolve() + } + + consent(): void { + this.warnInert() + } + + reset(): void { + this.warnInert() + } + + async flush(): Promise { + this.warnInert() + await Promise.resolve() + } + + setLocale(): string | undefined { + this.warnInert() + return this.snapshot.locale + } + + destroy(): void { + // Snapshot runtime owns no listeners, timers, or singletons. + } + + private inertEventEmission(): EventEmissionResult { + this.warnInert() + return ACCEPTED_NOOP_RESULT + } + + private warnInert(): void { + logger.warn(INERT_ACTION_WARNING) + } +} + +/** + * Create a read-only runtime from server-provided optimization state. + * + * @public + */ +export function createSnapshotRuntime(snapshot?: OptimizationSnapshot): OptimizationRuntime { + return new SnapshotRuntime(snapshot) +} + +export type { SnapshotRuntime } diff --git a/packages/universal/core-sdk/src/runtime/index.ts b/packages/universal/core-sdk/src/runtime/index.ts new file mode 100644 index 000000000..b8f1cd4ee --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/index.ts @@ -0,0 +1,12 @@ +/** + * Framework-neutral runtime contracts shared by stateful and snapshot-backed SDK surfaces. + * + * @packageDocumentation + */ + +export type { OptimizationRuntime } from './OptimizationRuntime' +export { + createSnapshotRuntime, + type OptimizationSnapshot, + type SnapshotRuntime, +} from './SnapshotRuntime' diff --git a/packages/universal/core-sdk/src/signals/Observable.ts b/packages/universal/core-sdk/src/signals/Observable.ts index b1348d3c6..269ca6b40 100644 --- a/packages/universal/core-sdk/src/signals/Observable.ts +++ b/packages/universal/core-sdk/src/signals/Observable.ts @@ -55,6 +55,33 @@ function isNonNullish(value: TValue): value is NonNullable { return value !== undefined && value !== null } +const NOOP_SUBSCRIPTION: Subscription = { unsubscribe: () => undefined } + +/** + * Create an {@link Observable} over a fixed value that never changes. + * + * @typeParam T - Value type emitted by the observable. + * @param value - The constant value exposed as `current` and emitted on subscribe. + * @returns An observable that emits `value` once on subscribe and never again. + * + * @public + */ +export function staticObservable(value: T): Observable { + return { + current: value, + + subscribe(next) { + next(value) + return NOOP_SUBSCRIPTION + }, + + subscribeOnce(next) { + if (isNonNullish(value)) next(value) + return NOOP_SUBSCRIPTION + }, + } +} + function toError(value: unknown): Error { if (value instanceof Error) return value return new Error(`Subscriber threw non-Error value: ${String(value)}`) diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 3d07717c6..d20379da1 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -25,14 +25,15 @@ SDK on the server with the React Web SDK on the client; it is not a new optimiza ## What this package provides -| Runtime | Import path | Responsibility | -| --------------- | ----------------------------------------------------- | ---------------------------------------------------- | -| Client | `@contentful/optimization-nextjs/client` | React SDK providers, hooks, components, and trackers | -| Schemas | `@contentful/optimization-nextjs/api-schemas` | Shared API types, schemas, and structural guards | -| Server | `@contentful/optimization-nextjs/server` | Node SDK creation, request binding, and SSR wrapper | -| ESR | `@contentful/optimization-nextjs/esr` | Edge/request-rendered data and response persistence | -| Request handler | `@contentful/optimization-nextjs/request-handler` | Next middleware/proxy request context forwarding | -| Shared | `@contentful/optimization-nextjs/tracking-attributes` | SSR `data-ctfl-*` tracking attributes | +| Runtime | Import path | Responsibility | +| --------------- | ----------------------------------------------------- | ----------------------------------------------------- | +| Automatic | `@contentful/optimization-nextjs` | Preferred bound component factory for App Router | +| Client | `@contentful/optimization-nextjs/client` | React SDK providers, hooks, components, and trackers | +| Schemas | `@contentful/optimization-nextjs/api-schemas` | Shared API types, schemas, and structural guards | +| Server | `@contentful/optimization-nextjs/server` | Node SDK creation, request binding, and state handoff | +| ESR | `@contentful/optimization-nextjs/esr` | Edge/request-rendered data and response persistence | +| Request handler | `@contentful/optimization-nextjs/request-handler` | Next middleware/proxy request context forwarding | +| Shared | `@contentful/optimization-nextjs/tracking-attributes` | SSR `data-ctfl-*` tracking attributes | ## Install @@ -43,15 +44,91 @@ pnpm add @contentful/optimization-nextjs 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. -## Server setup +## Automatic component setup + +Start App Router integrations from the package root and define app-local bound exports once. Next.js +resolves that root import to the automatic server implementation for Server Components and to the +client exports for Client Components. + +```tsx +import { createNextjsOptimizationComponents } from '@contentful/optimization-nextjs' +import { getAppConsent } from './consent' + +export const { + NextAppAutoPageTracker, + NextPagesAutoPageTracker, + OptimizationProvider, + OptimizationRoot, + OptimizedEntry, +} = createNextjsOptimizationComponents({ + clientId: 'client-id', + environment: 'main', + locale: 'en-US', + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: ({ cookies }) => + getAppConsent(cookies) ? { events: true, persistence: true } : false, + }, +}) +``` + +`server.consent` is required when `server.enabled` is `true`. It can be a consent value or a +resolver that receives the values read from Next.js `cookies()` and `headers()`. + +Use the bound root in an App Router layout: + +```tsx +import { NextAppAutoPageTracker, OptimizationRoot } from '@/lib/optimization' +import { Suspense, type ReactNode } from 'react' + +export default async function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + ) +} +``` + +Use the same app-local `OptimizedEntry` name on either side of the component boundary: ```tsx +import { OptimizedEntry } from '@/lib/optimization' + +export function ServerEntry({ entry }) { + return ( + + {(resolvedEntry, { getMergeTagValue }) => ( + + )} + + ) +} +``` + +A file without `'use client'` gets server-resolved first paint through the Node SDK. The bound root +and provider pass server data as `serverOptimizationState`, and the bound `OptimizedEntry` resolves +through the server SDK. A file with `'use client'` gets browser resolution and live updates through +the React Web SDK with server-only config removed. `OptimizedEntry` render props receive +`(resolvedEntry, { getMergeTagValue })` in both cases. The automatic API accepts config props only; +it does not accept injected SDK instances. + +## Manual server setup + +Use `/server` helpers only when an application needs direct Node SDK request control instead of the +bound App Router setup. + +```tsx +import { OptimizationRoot } from '@contentful/optimization-nextjs/client' import { - ServerOptimizedEntry, createNextjsOptimization, getNextjsServerOptimizationData, } from '@contentful/optimization-nextjs/server' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' +import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' import { cookies, headers } from 'next/headers' const sdk = createNextjsOptimization({ @@ -69,22 +146,20 @@ export default async function Page() { }) const resolvedData = sdk.resolveOptimizedEntry(entry, data?.selectedOptimizations) + const trackingAttributes = getServerTrackingAttributes(entry, resolvedData) return ( - <> - - - {resolvedData.entry.fields.title} - - + +
{resolvedData.entry.fields.title}
+
) } ``` -`NextjsOptimizationState` must render under SDK context. That context can come from -`OptimizationRoot` or `OptimizationProvider`, commonly mounted in a shared App Router layout. +## Manual client setup -## Client setup +Use `/client` imports when a client-only provider must be wired manually. App Router layouts that +use the bound root import do not need this setup. ```tsx import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' @@ -107,8 +182,9 @@ route. Route changes still emit normally. ## Server-to-browser state handoff -Use `serverOptimizationState={data}` on `OptimizationRoot` or `OptimizationProvider` when that -provider or root receives the server Optimization data directly: +Automatic bound roots and providers pass `serverOptimizationState` for App Router Server Components. +In manual server/client setups, use `serverOptimizationState={data}` on `OptimizationRoot` or +`OptimizationProvider` when that provider or root receives the server Optimization data directly: ```tsx ``` -When a shared App Router layout owns the SDK context and a page owns the server data, render -`NextjsOptimizationState` under that context near the server-rendered optimized content: - -```tsx - -``` - Keep `defaults` for configuration or default state such as consent. Pass server-returned profile, -selected optimizations, and changes through `serverOptimizationState` or `NextjsOptimizationState`. +selected optimizations, and changes through `serverOptimizationState`. ## Request context setup @@ -140,9 +209,8 @@ export const proxy = createNextjsOptimizationContextHandler() ``` The returned handler can be exported from `middleware.ts` or `proxy.ts`. It forwards sanitized -request context headers, including the SDK-owned request URL header that -`getNextjsServerOptimizationData()` reads when you pass `headers()`. It does not call `page()`, -resolve consent, or write response cookies. +request context headers, including the SDK-owned request URL header that the bound and manual server +paths read from `headers()`. It does not call `page()`, resolve consent, or write response cookies. ## Edge/request-rendered personalization diff --git a/packages/web/frameworks/nextjs-sdk/package.json b/packages/web/frameworks/nextjs-sdk/package.json index 772b230c6..7c7618c09 100644 --- a/packages/web/frameworks/nextjs-sdk/package.json +++ b/packages/web/frameworks/nextjs-sdk/package.json @@ -8,18 +8,22 @@ "directory": "packages/web/frameworks/nextjs-sdk" }, "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "main": "./dist/client.cjs", + "module": "./dist/client.mjs", + "types": "./dist/client.d.mts", "exports": { ".": { + "react-server": { + "types": "./dist/automatic-server.d.mts", + "default": "./dist/automatic-server.mjs" + }, "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" + "types": "./dist/client.d.mts", + "default": "./dist/client.mjs" }, "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" + "types": "./dist/client.d.cts", + "default": "./dist/client.cjs" } }, "./client": { @@ -91,10 +95,10 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 600, - "index.mjs": 300, "client.cjs": 1200, "client.mjs": 1200, + "automatic-server.cjs": 2500, + "automatic-server.mjs": 2600, "esr.cjs": 4200, "esr.mjs": 4200, "server.cjs": 4500, diff --git a/packages/web/frameworks/nextjs-sdk/rslib.config.ts b/packages/web/frameworks/nextjs-sdk/rslib.config.ts index 8f6cede42..adfec0cf3 100644 --- a/packages/web/frameworks/nextjs-sdk/rslib.config.ts +++ b/packages/web/frameworks/nextjs-sdk/rslib.config.ts @@ -29,8 +29,8 @@ const clientEntries = { const serverEntries = { 'api-schemas': './src/api-schemas.ts', + 'automatic-server': './src/automatic-server.tsx', esr: './src/esr.ts', - index: './src/index.ts', 'request-handler': './src/request-handler.ts', server: './src/server.tsx', 'tracking-attributes': './src/tracking-attributes.ts', diff --git a/packages/web/frameworks/nextjs-sdk/src/automatic-server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/automatic-server.test.tsx new file mode 100644 index 000000000..5ca29fc53 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/automatic-server.test.tsx @@ -0,0 +1,302 @@ +import ContentfulOptimizationRuntime from '@contentful/optimization-node' +import type { MergeTagEntry } from '@contentful/optimization-node/api-schemas' +import { useOptimization } from '@contentful/optimization-react-web' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { + createNextjsOptimizationComponents, + type NextjsOptimizationServerConsentContext, +} from './automatic-server' +import type { CoreStatelessRequest, OptimizationData, ServerTrackingBaselineEntry } from './server' + +interface MockPageRequest { + readonly forRequest: ReturnType + readonly page: ReturnType> +} + +const sdkConfig = { + clientId: 'test-client-id', + environment: 'main', + locale: 'en-US', +} + +const baselineEntry = createEntry('baseline-entry') +const optimizationData: OptimizationData = { + changes: [], + selectedOptimizations: [], + profile: { + id: 'server-profile-id', + stableId: 'server-profile-id', + random: 0.5, + audiences: [], + traits: { + continent: 'EU', + }, + location: {}, + session: { + id: 'server-session-id', + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + }, +} + +let currentCookies = createCookieReader() +let currentHeaders = new Headers() + +rs.mock('next/headers', () => ({ + cookies: () => currentCookies, + headers: () => currentHeaders, +})) + +void afterEach(() => { + currentCookies = createCookieReader() + currentHeaders = new Headers() + rs.restoreAllMocks() +}) + +function createCookieReader( + values: Record = {}, +): NextjsOptimizationServerConsentContext['cookies'] { + return { + get: (name: string) => { + const value = values[name] + return value === undefined ? undefined : { value } + }, + } +} + +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 createMergeTagEntry(id: string, selector: string): MergeTagEntry { + const entry = createEntry(id) + const mergeTagEntry: MergeTagEntry = { + ...entry, + fields: { + nt_mergetag_id: selector, + nt_name: selector, + }, + sys: { + ...entry.sys, + contentType: { + sys: { + id: 'nt_mergetag', + linkType: 'ContentType', + type: 'Link', + }, + }, + }, + } + return mergeTagEntry +} + +function mockPageRequest( + page = rs.fn( + async () => await Promise.resolve({ accepted: true, data: optimizationData }), + ), +): MockPageRequest { + const requestOptimization = new ContentfulOptimizationRuntime(sdkConfig).forRequest({ + consent: true, + }) + const forRequest = rs + .spyOn(ContentfulOptimizationRuntime.prototype, 'forRequest') + .mockReturnValue(requestOptimization) + rs.spyOn(requestOptimization, 'page').mockImplementation(page) + + return { forRequest, page } +} + +describe('automatic Next.js server components', () => { + it('loads server data into the bound client provider', async () => { + currentCookies = createCookieReader({ consent: 'granted' }) + currentHeaders = new Headers({ 'x-test': 'header-value' }) + const { forRequest, page } = mockPageRequest() + const resolveConsent = rs.fn(({ cookies, headers }: NextjsOptimizationServerConsentContext) => { + expect(cookies.get('consent')?.value).toBe('granted') + expect(headers.get('x-test')).toBe('header-value') + return { events: true, persistence: true } + }) + + const { OptimizationRoot } = createNextjsOptimizationComponents({ + ...sdkConfig, + defaults: { consent: false, persistenceConsent: false }, + server: { + enabled: true, + consent: resolveConsent, + }, + }) + + const element = await OptimizationRoot({ children: 'Server content' }) + + expect(resolveConsent).toHaveBeenCalledTimes(1) + expect(forRequest).toHaveBeenCalledWith( + expect.objectContaining({ + consent: { events: true, persistence: true }, + locale: sdkConfig.locale, + }), + ) + expect(page).toHaveBeenCalledTimes(1) + expect(element).toMatchObject({ + props: { + children: { + props: { + children: 'Server content', + }, + }, + clientId: sdkConfig.clientId, + defaults: { consent: true, persistenceConsent: true }, + environment: sdkConfig.environment, + serverOptimizationState: optimizationData, + }, + }) + }) + + it('loads server data with false consent so the Node SDK can apply its allowlist', async () => { + const { forRequest, page } = mockPageRequest() + const { OptimizationRoot } = createNextjsOptimizationComponents({ + ...sdkConfig, + defaults: { consent: true, persistenceConsent: true }, + server: { + enabled: true, + consent: false, + }, + }) + + const element = await OptimizationRoot({ children: 'Server content' }) + + expect(forRequest).toHaveBeenCalledWith( + expect.objectContaining({ + consent: false, + locale: sdkConfig.locale, + }), + ) + expect(page).toHaveBeenCalledTimes(1) + expect(element).toMatchObject({ + props: { + children: { + props: { + children: 'Server content', + }, + }, + defaults: { consent: false, persistenceConsent: false }, + serverOptimizationState: optimizationData, + }, + }) + }) + + it('renders Node-derived server state during server render', async () => { + const { page } = mockPageRequest() + const { OptimizationRoot } = createNextjsOptimizationComponents({ + ...sdkConfig, + server: { + enabled: true, + consent: true, + }, + }) + + function ProfileProbe(): ReactElement { + const sdk = useOptimization() + + return {sdk.states.profile.current?.id} + } + + const element = await OptimizationRoot({ children: }) + + expect(page).toHaveBeenCalledTimes(1) + expect(renderToString(element)).toContain('server-profile-id') + }) + + it('renders baseline entry content when server rendering is disabled', async () => { + const { forRequest, page } = mockPageRequest() + const { OptimizedEntry } = createNextjsOptimizationComponents({ + ...sdkConfig, + server: { + enabled: false, + }, + }) + + const element = await OptimizedEntry({ + baselineEntry, + children: (entry) => entry.sys.id, + 'data-testid': 'entry', + trackViews: true, + }) + + expect(forRequest).not.toHaveBeenCalled() + expect(page).not.toHaveBeenCalled() + expect(element.props).toMatchObject({ + 'data-ctfl-baseline-id': 'baseline-entry', + 'data-ctfl-entry-id': 'baseline-entry', + 'data-ctfl-track-views': true, + 'data-testid': 'entry', + children: 'baseline-entry', + }) + }) + + it('passes request-profile merge-tag helpers to server render props', async () => { + const { page } = mockPageRequest() + const mergeTagEntry = createMergeTagEntry('merge-tag', 'traits.continent') + const { OptimizedEntry } = createNextjsOptimizationComponents({ + ...sdkConfig, + server: { + enabled: true, + consent: true, + }, + }) + + const element = await OptimizedEntry({ + baselineEntry, + children: (_entry, { getMergeTagValue }) => getMergeTagValue(mergeTagEntry) ?? 'missing', + }) + + expect(page).toHaveBeenCalledTimes(1) + expect(element.props).toMatchObject({ children: 'EU' }) + }) +}) diff --git a/packages/web/frameworks/nextjs-sdk/src/automatic-server.tsx b/packages/web/frameworks/nextjs-sdk/src/automatic-server.tsx new file mode 100644 index 000000000..0c392301f --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/automatic-server.tsx @@ -0,0 +1,263 @@ +import { + LiveUpdatesProvider as ReactWebLiveUpdatesProvider, + OptimizationProvider as ReactWebOptimizationProvider, + type OptimizedEntryRenderContext, + type OptimizationRootProps as ReactWebOptimizationRootProps, + type OptimizedEntryProps as ReactWebOptimizedEntryProps, +} from '@contentful/optimization-react-web' +import { + NextAppAutoPageTracker, + type NextAppAutoPageContext, + type NextAppAutoPageTrackerProps, +} from '@contentful/optimization-react-web/router/next-app' +import { + NextPagesAutoPageTracker, + type NextPagesAutoPageContext, + type NextPagesAutoPageTrackerProps, +} from '@contentful/optimization-react-web/router/next-pages' +import { cookies as readNextjsCookies, headers as readNextjsHeaders } from 'next/headers' +import { cache, createElement, type ReactElement, type ReactNode } from 'react' +import type { + BoundNextjsOptimizationRootProps, + NextjsBoundProviderConfig, +} from './bound-component-types' +import { + createNextjsOptimization, + getNextjsServerOptimizationData, + type CoreStatelessRequestConsent, + type NextjsCookieReader, + type OptimizationData, + type OptimizationNodeConfig, +} from './server' +import { renderOptimizedEntryOnServer } from './server-entry-renderer' +import type { ServerTrackingResolvedData } from './tracking-attributes' + +export type { OptimizedEntryRenderContext } from '@contentful/optimization-react-web' +export type { BoundNextjsOptimizationRootProps } from './bound-component-types' +export { + NextAppAutoPageTracker, + NextPagesAutoPageTracker, + type NextAppAutoPageContext, + type NextAppAutoPageTrackerProps, + type NextPagesAutoPageContext, + type NextPagesAutoPageTrackerProps, +} + +export interface NextjsOptimizationServerConsentContext { + readonly cookies: NextjsCookieReader + readonly headers: Headers +} + +export type NextjsOptimizationServerConsentResolver = ( + context: NextjsOptimizationServerConsentContext, +) => CoreStatelessRequestConsent | Promise + +export type NextjsOptimizationServerOptions = + | { + readonly enabled: true + readonly consent: CoreStatelessRequestConsent | NextjsOptimizationServerConsentResolver + } + | { + readonly enabled?: false + readonly consent?: never + } + +export type NextjsOptimizationComponentsConfig = Omit< + ReactWebOptimizationRootProps, + 'children' | 'sdk' | 'serverOptimizationState' +> & { + readonly server?: NextjsOptimizationServerOptions +} +export type NextjsServerOptimizedEntryProps = Omit< + ReactWebOptimizedEntryProps, + 'liveUpdates' | 'loadingFallback' +> +type IgnoredReactWebOptimizedEntryProps = Pick< + ReactWebOptimizedEntryProps, + 'liveUpdates' | 'loadingFallback' +> + +export interface NextjsOptimizationComponents { + readonly OptimizationRoot: (props: BoundNextjsOptimizationRootProps) => Promise + readonly OptimizationProvider: ( + props: BoundNextjsOptimizationRootProps, + ) => Promise + readonly OptimizedEntry: (props: NextjsServerOptimizedEntryProps) => Promise + readonly NextAppAutoPageTracker: typeof NextAppAutoPageTracker + readonly NextPagesAutoPageTracker: typeof NextPagesAutoPageTracker +} + +interface NextjsAutomaticServerOptimizationData { + readonly consent: CoreStatelessRequestConsent | undefined + readonly data: OptimizationData | undefined +} + +interface LooseServerOptions { + readonly enabled?: boolean + readonly consent?: unknown +} + +export function createNextjsOptimizationComponents( + config: NextjsOptimizationComponentsConfig, +): NextjsOptimizationComponents { + validateServerOptions(config.server) + + const sdk = createNextjsOptimization(toServerOptimizationConfig(config)) + const loadServerData = cache(async (): Promise => { + if (config.server?.enabled !== true) return { consent: undefined, data: undefined } + + const [cookieStore, headerStore] = await Promise.all([readNextjsCookies(), readNextjsHeaders()]) + const consent = await resolveServerConsent(config.server.consent, { + cookies: cookieStore, + headers: headerStore, + }) + + const { data } = await getNextjsServerOptimizationData(sdk, { + consent, + cookies: cookieStore, + headers: headerStore, + locale: config.locale, + }) + + return { consent, data } + }) + + async function OptimizationRoot({ + children, + }: BoundNextjsOptimizationRootProps): Promise { + const serverData = await loadServerData() + return createElement( + ReactWebOptimizationProvider, + toClientProviderConfig(config, serverData), + createElement( + ReactWebLiveUpdatesProvider, + { globalLiveUpdates: config.liveUpdates }, + children, + ), + ) + } + + async function OptimizationProvider({ + children, + }: BoundNextjsOptimizationRootProps): Promise { + const serverData = await loadServerData() + return createElement( + ReactWebOptimizationProvider, + toClientProviderConfig(config, serverData), + children, + ) + } + + async function OptimizedEntry(props: NextjsServerOptimizedEntryProps): Promise { + const { + baselineEntry, + children, + liveUpdates: _liveUpdates, + loadingFallback: _loadingFallback, + testId, + 'data-testid': dataTestId, + ...serverEntryProps + } = props as NextjsServerOptimizedEntryProps & Partial + const { data } = await loadServerData() + const resolvedData = sdk.resolveOptimizedEntry(baselineEntry, data?.selectedOptimizations) + const renderContext: OptimizedEntryRenderContext = { + getMergeTagValue: (embeddedEntryNodeTarget, profile) => + sdk.getMergeTagValue(embeddedEntryNodeTarget, profile ?? data?.profile), + } + const testAttributes = + dataTestId === undefined && testId === undefined + ? {} + : { + 'data-testid': dataTestId ?? testId, + } + + return renderOptimizedEntryOnServer({ + ...serverEntryProps, + ...testAttributes, + baselineEntry, + children: resolveOptimizedEntryChildren(children, resolvedData.entry, renderContext), + resolvedData, + }) + } + + return { + NextAppAutoPageTracker, + NextPagesAutoPageTracker, + OptimizationProvider, + OptimizationRoot, + OptimizedEntry, + } +} + +function validateServerOptions(options: LooseServerOptions | undefined): void { + if (options?.enabled === true && options.consent === undefined) { + throw new Error( + 'createNextjsOptimizationComponents() requires server.consent when server.enabled is true.', + ) + } +} + +function toServerOptimizationConfig( + config: NextjsOptimizationComponentsConfig, +): OptimizationNodeConfig { + const { + cookie: _cookie, + defaults: _defaults, + liveUpdates: _liveUpdates, + onStatesReady: _onStatesReady, + server: _server, + trackEntryInteraction: _trackEntryInteraction, + ...serverConfig + } = config + + return serverConfig as OptimizationNodeConfig +} + +function toClientProviderConfig( + config: NextjsOptimizationComponentsConfig, + serverData: NextjsAutomaticServerOptimizationData, +): NextjsBoundProviderConfig { + const { liveUpdates: _liveUpdates, server: _server, ...providerConfig } = config + const clientProviderConfig: NextjsBoundProviderConfig = { + ...providerConfig, + defaults: resolveClientDefaults(providerConfig.defaults, serverData.consent), + serverOptimizationState: serverData.data, + } + return clientProviderConfig +} + +function resolveClientDefaults( + defaults: ReactWebOptimizationRootProps['defaults'], + consent: CoreStatelessRequestConsent | undefined, +): ReactWebOptimizationRootProps['defaults'] { + if (consent === undefined) return defaults + + if (typeof consent === 'boolean') { + return { + ...defaults, + consent, + persistenceConsent: consent, + } + } + + return { + ...defaults, + consent: consent.events ?? defaults?.consent, + persistenceConsent: consent.persistence ?? defaults?.persistenceConsent, + } +} + +function resolveServerConsent( + consent: CoreStatelessRequestConsent | NextjsOptimizationServerConsentResolver, + context: NextjsOptimizationServerConsentContext, +): CoreStatelessRequestConsent | Promise { + return typeof consent === 'function' ? consent(context) : consent +} + +function resolveOptimizedEntryChildren( + children: ReactWebOptimizedEntryProps['children'], + entry: ServerTrackingResolvedData['entry'], + context: OptimizedEntryRenderContext, +): ReactNode { + return typeof children === 'function' ? children(entry, context) : children +} diff --git a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts new file mode 100644 index 000000000..3b0b35901 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts @@ -0,0 +1,8 @@ +import type { OptimizationRootProps } from '@contentful/optimization-react-web' +import type { ReactNode } from 'react' + +export type NextjsBoundProviderConfig = Omit + +export interface BoundNextjsOptimizationRootProps { + readonly children?: ReactNode +} diff --git a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx index 7905b9653..2434782bf 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx @@ -1,22 +1,4 @@ -import ContentfulOptimization from '@contentful/optimization-web' -import type { OptimizationData } from '@contentful/optimization-web/api-schemas' -import { act } from 'react' -import { createRoot } from 'react-dom/client' -import { renderToString } from 'react-dom/server' import * as client from './client' -import { NextjsOptimizationState, OptimizationContext } from './client' - -type RemovedHydratorPrefix = 'Nextjs' -type RemovedHydratorSuffixPrefix = 'ServerOptimization' -type RemovedHydratorSuffixSuffix = 'Hydrator' -type RemovedHydratorSuffix = `${RemovedHydratorSuffixPrefix}${RemovedHydratorSuffixSuffix}` -type RemovedHydratorExportName = `${RemovedHydratorPrefix}${RemovedHydratorSuffix}` -type RemovedHydratorExportIsAbsent = RemovedHydratorExportName extends keyof typeof client - ? false - : true - -const removedHydratorExportIsAbsent: RemovedHydratorExportIsAbsent = true -const removedHydratorExportName = ['Nextjs', 'ServerOptimization', 'Hydrator'].join('') const testConfig = { clientId: 'test-client-id', @@ -27,113 +9,24 @@ const testConfig = { }, } -function cleanupOptimizationSingleton(): void { - window.contentfulOptimization?.destroy() - document.body.innerHTML = '' -} - -void afterEach(() => { - cleanupOptimizationSingleton() -}) - -const optimizationData: OptimizationData = { - changes: [], - selectedOptimizations: [], - profile: { - id: 'server-profile-id', - stableId: 'server-profile-id', - random: 0.5, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'server-session-id', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'http://localhost/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, - }, -} - -async function renderClientStateMarker( - sdk: ContentfulOptimization, - data: OptimizationData | undefined, -): Promise<{ unmount: () => void }> { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - await act(async () => { - root.render( - - - , - ) - await Promise.resolve() - await Promise.resolve() - }) - - return { - unmount() { - act(() => { - root.unmount() - }) - container.remove() - }, - } -} - -describe('Next.js client optimization state marker', () => { - it('exports NextjsOptimizationState without the removed server hydrator API', () => { - expect(removedHydratorExportIsAbsent).toBe(true) - expect(client.NextjsOptimizationState).toBeTypeOf('function') - expect(removedHydratorExportName in client).toBe(false) - }) - - it('renders no UI', () => { - const sdk = new ContentfulOptimization(testConfig) - - const markup = renderToString( - - - , - ) - - expect(markup).toBe('') - sdk.destroy() - }) - - it('hands server optimization state to the nearest optimization provider runtime', async () => { - const sdk = new ContentfulOptimization(testConfig) - - const rendered = await renderClientStateMarker(sdk, optimizationData) - - expect(sdk.states.profile.current).toEqual(optimizationData.profile) - expect(sdk.states.selectedOptimizations.current).toEqual(optimizationData.selectedOptimizations) - expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) - - rendered.unmount() - sdk.destroy() - }) - - it('does not change optimization state when data is undefined', async () => { - const sdk = new ContentfulOptimization(testConfig) - - const rendered = await renderClientStateMarker(sdk, undefined) - - expect(sdk.states.profile.current).toBeUndefined() - expect(sdk.states.selectedOptimizations.current).toBeUndefined() - - rendered.unmount() - sdk.destroy() +describe('Next.js client components', () => { + it('creates bound client components from config props', () => { + const { OptimizationRoot, OptimizedEntry } = client.createNextjsOptimizationComponents({ + ...testConfig, + liveUpdates: true, + server: { enabled: false }, + }) + + const element = OptimizationRoot({ children: 'Bound content' }) + + expect(OptimizedEntry).toBe(client.OptimizedEntry) + expect(element.props).toMatchObject({ + api: testConfig.api, + children: 'Bound content', + clientId: testConfig.clientId, + environment: testConfig.environment, + liveUpdates: true, + }) + expect(element.props).not.toHaveProperty('server') }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index 1d8c714fb..1a8a41637 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -1,32 +1,124 @@ 'use client' -import { useOptimization } from '@contentful/optimization-react-web' -import type { OptimizationData } from '@contentful/optimization-web/api-schemas' -import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' -import { useLayoutEffect } from 'react' - -export * from '@contentful/optimization-react-web' -export { +import { + OptimizationProvider as ReactWebOptimizationProvider, + OptimizationRoot as ReactWebOptimizationRoot, + OptimizedEntry as ReactWebOptimizedEntry, + type OptimizationRootProps, + type OptimizedEntryProps, +} from '@contentful/optimization-react-web' +import { NextAppAutoPageTracker, type NextAppAutoPageContext, type NextAppAutoPageTrackerProps, } from '@contentful/optimization-react-web/router/next-app' -export { +import { NextPagesAutoPageTracker, type NextPagesAutoPageContext, type NextPagesAutoPageTrackerProps, } from '@contentful/optimization-react-web/router/next-pages' +import { createElement, type ReactElement } from 'react' +import type { + BoundNextjsOptimizationRootProps, + NextjsBoundProviderConfig, +} from './bound-component-types' + +export * from '@contentful/optimization-react-web' +export type { BoundNextjsOptimizationRootProps } from './bound-component-types' +export { + NextAppAutoPageTracker, + NextPagesAutoPageTracker, + type NextAppAutoPageContext, + type NextAppAutoPageTrackerProps, + type NextPagesAutoPageContext, + type NextPagesAutoPageTrackerProps, +} + +export interface NextjsOptimizationServerConsentContext { + readonly cookies: NextjsCookieReader + readonly headers: Headers +} + +export interface NextjsCookieValue { + readonly value: string +} -export interface NextjsOptimizationStateProps { - readonly data: OptimizationData | undefined +export interface NextjsCookieReader { + get: (name: string) => NextjsCookieValue | undefined } -export function NextjsOptimizationState({ data }: NextjsOptimizationStateProps): null { - const sdk = useOptimization() +export type NextjsOptimizationServerConsent = + | boolean + | { + readonly events?: boolean + readonly persistence?: boolean + } + +export type NextjsOptimizationServerConsentResolver = ( + context: NextjsOptimizationServerConsentContext, +) => NextjsOptimizationServerConsent | Promise - useLayoutEffect(() => { - void hydrateOptimizationData(sdk, data) - }, [data, sdk]) +export type NextjsOptimizationServerOptions = + | { + readonly enabled: true + readonly consent: NextjsOptimizationServerConsent | NextjsOptimizationServerConsentResolver + } + | { + readonly enabled?: false + readonly consent?: never + } + +export type NextjsOptimizationComponentsConfig = Omit< + OptimizationRootProps, + 'children' | 'sdk' | 'serverOptimizationState' +> & { + readonly server?: NextjsOptimizationServerOptions +} + +export interface NextjsOptimizationComponents { + readonly OptimizationRoot: (props: BoundNextjsOptimizationRootProps) => ReactElement + readonly OptimizationProvider: (props: BoundNextjsOptimizationRootProps) => ReactElement | null + readonly OptimizedEntry: (props: OptimizedEntryProps) => ReactElement | null + readonly NextAppAutoPageTracker: typeof NextAppAutoPageTracker + readonly NextPagesAutoPageTracker: typeof NextPagesAutoPageTracker +} + +export function createNextjsOptimizationComponents( + config: NextjsOptimizationComponentsConfig, +): NextjsOptimizationComponents { + const rootConfig = toClientRootConfig(config) + const providerConfig = toClientProviderConfig(config) + + function OptimizationRoot({ children }: BoundNextjsOptimizationRootProps): ReactElement { + return createElement(ReactWebOptimizationRoot, rootConfig, children) + } + + function OptimizationProvider({ + children, + }: BoundNextjsOptimizationRootProps): ReactElement | null { + return createElement(ReactWebOptimizationProvider, providerConfig, children) + } + + return { + NextAppAutoPageTracker, + NextPagesAutoPageTracker, + OptimizationProvider, + OptimizationRoot, + OptimizedEntry: ReactWebOptimizedEntry, + } +} + +function toClientRootConfig( + config: NextjsOptimizationComponentsConfig, +): Omit { + const { server: _server, ...clientConfig } = config + return clientConfig +} - return null +function toClientProviderConfig( + config: NextjsOptimizationComponentsConfig, +): NextjsBoundProviderConfig { + const { liveUpdates: _liveUpdates, server: _server, ...providerConfig } = config + const clientProviderConfig: NextjsBoundProviderConfig = providerConfig + return clientProviderConfig } diff --git a/packages/web/frameworks/nextjs-sdk/src/index.ts b/packages/web/frameworks/nextjs-sdk/src/index.ts deleted file mode 100644 index 8c22c34b5..000000000 --- a/packages/web/frameworks/nextjs-sdk/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Contentful Optimization Next.js SDK adapter. - * - * @remarks - * Import concrete runtime helpers from dedicated subpaths: - * - * - `@contentful/optimization-nextjs/client` - * - `@contentful/optimization-nextjs/server` - * - `@contentful/optimization-nextjs/esr` - * - `@contentful/optimization-nextjs/request-handler` - * - `@contentful/optimization-nextjs/tracking-attributes` - * - * @packageDocumentation - */ - -export {} 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 a9c5c098b..eb00af451 100644 --- a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts @@ -7,10 +7,6 @@ export function acceptNextjsClientSdk(runtime: WebContentfulOptimization): Optim return runtime } -export function acceptConcreteWebRuntime(sdk: OptimizationSdk): WebContentfulOptimization { - return sdk -} - export function acceptNextjsServerSdk( runtime: NodeContentfulOptimization, ): NextjsServerOptimization { diff --git a/packages/web/frameworks/nextjs-sdk/src/server-entry-renderer.tsx b/packages/web/frameworks/nextjs-sdk/src/server-entry-renderer.tsx new file mode 100644 index 000000000..2b8f9d815 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/server-entry-renderer.tsx @@ -0,0 +1,55 @@ +import { createElement, type JSX, type ReactElement, type ReactNode } from 'react' +import { + getServerTrackingAttributes, + type ServerTrackingAttributeOptions, + type ServerTrackingAttributes, + type ServerTrackingBaselineEntry, + type ServerTrackingResolvedData, +} from './tracking-attributes' + +type ServerEntryRendererOwnProps = + ServerTrackingAttributeOptions & { + readonly as?: TElement + readonly baselineEntry: ServerTrackingBaselineEntry + readonly children?: ReactNode + readonly resolvedData: ServerTrackingResolvedData + } + +type DataCtflAttributeName = `data-ctfl-${string}` + +type ServerEntryRendererProps = + ServerEntryRendererOwnProps & + Omit< + JSX.IntrinsicElements[TElement], + keyof ServerEntryRendererOwnProps | DataCtflAttributeName + > + +export function renderOptimizedEntryOnServer({ + as, + baselineEntry, + children, + clickable, + hoverDurationUpdateIntervalMs, + resolvedData, + trackClicks, + trackHovers, + trackViews, + viewDurationUpdateIntervalMs, + ...htmlProps +}: ServerEntryRendererProps): ReactElement { + const Element = as ?? 'div' + const trackingAttributes: ServerTrackingAttributes = getServerTrackingAttributes( + baselineEntry, + resolvedData, + { + clickable, + hoverDurationUpdateIntervalMs, + trackClicks, + trackHovers, + trackViews, + viewDurationUpdateIntervalMs, + }, + ) + + return createElement(Element, { ...htmlProps, ...trackingAttributes }, children) +} diff --git a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx index 6f90b8ee5..3e35d62e0 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx @@ -1,6 +1,5 @@ import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - ServerOptimizedEntry, bindNextjsOptimizationRequest, createNextjsOptimization, createNextjsPageContext, @@ -11,7 +10,6 @@ import { type CoreStatelessRequest, type OptimizationData, } from './server' -import type { ServerTrackingBaselineEntry, ServerTrackingResolvedData } from './tracking-attributes' const sdkConfig = { clientId: 'test-client-id', @@ -23,16 +21,6 @@ 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: [], @@ -61,45 +49,6 @@ 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 () => @@ -413,26 +362,4 @@ 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', - }) - }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index db48a9e3d..7afb14aee 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -12,15 +12,7 @@ import type { UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' import { createPageContextFromUrl } from '@contentful/optimization-node/core-sdk' -import { createElement, type JSX, type ReactElement, type ReactNode } from 'react' import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' -import { - getServerTrackingAttributes, - type ServerTrackingAttributeOptions, - type ServerTrackingAttributes, - type ServerTrackingBaselineEntry, - type ServerTrackingResolvedData, -} from './tracking-attributes' export const DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE = ANONYMOUS_ID_COOKIE export type { OptimizationNodeConfig } from '@contentful/optimization-node' @@ -144,23 +136,6 @@ export type NextjsPageContextInput = | NonNullable | CreateNextjsPageContextOptions -type ServerOptimizedEntryOwnProps = - ServerTrackingAttributeOptions & { - readonly as?: TElement - readonly baselineEntry: ServerTrackingBaselineEntry - readonly children?: ReactNode - readonly resolvedData: ServerTrackingResolvedData - } - -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) } @@ -340,36 +315,6 @@ export function persistNextjsAnonymousId( } } -export function ServerOptimizedEntry({ - as, - baselineEntry, - children, - clickable, - hoverDurationUpdateIntervalMs, - resolvedData, - trackClicks, - trackHovers, - trackViews, - viewDurationUpdateIntervalMs, - ...htmlProps -}: ServerOptimizedEntryProps): ReactElement { - const Element = as ?? 'div' - const trackingAttributes: ServerTrackingAttributes = getServerTrackingAttributes( - baselineEntry, - resolvedData, - { - clickable, - hoverDurationUpdateIntervalMs, - trackClicks, - trackHovers, - trackViews, - viewDurationUpdateIntervalMs, - }, - ) - - return createElement(Element, { ...htmlProps, ...trackingAttributes }, children) -} - 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 6dac7505e..0dd2a942b 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -263,18 +263,26 @@ 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 `useMergeTagResolver()` when a component needs to resolve embedded merge tag entries: +For optimized entry content, prefer the `OptimizedEntry` render context and pass `getMergeTagValue` +into the child renderer: ```tsx -import { useMergeTagResolver } from '@contentful/optimization-react-web' - -function MergeTagText({ mergeTagEntry }) { - const { getMergeTagValue } = useMergeTagResolver() +import { OptimizedEntry } from '@contentful/optimization-react-web' - return {getMergeTagValue(mergeTagEntry) ?? ''} +function HeroEntry({ baselineEntry }) { + return ( + + {(resolvedEntry, { getMergeTagValue }) => ( + + )} + + ) } ``` +Use `useMergeTagResolver()` for components that resolve merge tags outside an `OptimizedEntry` +render prop. + If a merge tag references localized profile fields such as `location.city` or `location.country`, its resolved value follows the localized profile values returned by the Experience API. @@ -323,7 +331,9 @@ import { OptimizedEntry } from '@contentful/optimization-react-web' function HeroEntry({ baselineEntry }) { return ( - {(resolvedEntry) => } + {(resolvedEntry, { getMergeTagValue }) => ( + + )} ) } diff --git a/packages/web/frameworks/react-web-sdk/dev/app/sections/HookEntrySection.tsx b/packages/web/frameworks/react-web-sdk/dev/app/sections/HookEntrySection.tsx index 1b7344738..858a47e1e 100644 --- a/packages/web/frameworks/react-web-sdk/dev/app/sections/HookEntrySection.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/app/sections/HookEntrySection.tsx @@ -8,11 +8,12 @@ interface HookEntrySectionProps { } function HookEntryCard({ baselineEntry }: { baselineEntry: Entry }): ReactElement { - const { entry, isLoading, isReady, canOptimize, selectedOptimization } = useOptimizedEntry({ - baselineEntry, - }) + const { entry, isLoading, isPresentationReady, canOptimize, selectedOptimization } = + useOptimizedEntry({ + baselineEntry, + }) - if (isLoading || !isReady) { + if (isLoading || !isPresentationReady) { return (

useOptimizedEntry (hook only)

diff --git a/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx b/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx index 59ace5d90..a1f3a2a53 100644 --- a/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx @@ -3,23 +3,21 @@ import { LiveUpdatesProvider, OptimizationProvider, type OptimizationSdk } from import { useOptimizationContext } from '../../../src/hooks/useOptimization' function DecoupledConsumer({ label }: { label: string }): ReactElement { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() return (

- {label}:{' '} - {error ? `Error — ${error.message}` : isReady && sdk ? 'SDK ready' : 'Initializing...'} + {label}: {error ? `Error — ${error.message}` : sdk ? 'SDK ready' : 'Initializing...'}

) } function ContextConsumer(): ReactElement { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() return (

useOptimizationContext()

-

{`isReady: ${String(isReady)}`}

{`sdk: ${sdk ? 'present' : 'undefined'}`}

{`error: ${error ? error.message : 'none'}`}

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 0a221b06b..7eb29da7a 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -1,11 +1,11 @@ -import type ContentfulOptimization from '@contentful/optimization-web' import { createContext } from 'react' -export type OptimizationSdk = ContentfulOptimization +import type { WebOptimizationRuntime } from '../runtime/webRuntime' + +export type OptimizationSdk = WebOptimizationRuntime export interface OptimizationContextValue { readonly sdk: OptimizationSdk | undefined - readonly isReady: boolean readonly error: Error | undefined } diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts index b981178c8..fe6410cda 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts @@ -5,12 +5,8 @@ export function acceptReactWebSdk(runtime: ContentfulOptimization): Optimization return runtime } -export function acceptConcreteWebSdk(sdk: OptimizationSdk): ContentfulOptimization { - return sdk -} - describe('React Web OptimizationSdk type contract', () => { - it('aliases the concrete Web SDK runtime', () => { + it('accepts the concrete Web SDK runtime', () => { expect(true).toBe(true) }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx index f2d777d5f..687f24170 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx @@ -79,7 +79,7 @@ describe('useMergeTagResolver', () => { } renderToString( - + , ) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts index d2792e18f..c998982ae 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts @@ -29,19 +29,16 @@ export function useOptimizationContext(): OptimizationContextValue { * @public */ export function useOptimization(): OptimizationSdk { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() - if (!sdk || !isReady) { + if (!sdk) { if (error) { throw new Error(`ContentfulOptimization SDK failed to initialize: ${error.message}`, { cause: error, }) } - throw new Error( - 'ContentfulOptimization SDK is still initializing. ' + - 'This should not happen when using the loading gate in OptimizationProvider.', - ) + throw new Error('ContentfulOptimization SDK is unavailable.') } return sdk diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts index 50e335988..3fe4ef367 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts @@ -38,7 +38,7 @@ function useObservableState(observable: ObservableLike): T { const getSnapshot = useCallback(() => snapshotRef.current, []) - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + return useSyncExternalStore(subscribe, getSnapshot, () => observable.current) } /** diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 2cfea12d8..fff1e95ab 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -141,12 +141,14 @@ describe('@contentful/optimization-react-web core providers', () => { withoutLocale.unmount() }) - it('does not create an owned optimization instance during server render', () => { - let renderedChild = false + it('renders config-owned children from a snapshot during server render', () => { + let experienceRequestStatus: string | undefined = undefined - function Probe(): null { - renderedChild = true - return null + function Probe(): ReactElement { + const sdk = useOptimization() + experienceRequestStatus = sdk.states.experienceRequestState.current.status + + return {experienceRequestStatus} } const markup = renderToString( @@ -159,8 +161,8 @@ describe('@contentful/optimization-react-web core providers', () => { , ) - expect(markup).toBe('') - expect(renderedChild).toBe(false) + expect(markup).toContain('success') + expect(experienceRequestStatus).toBe('success') expect(window.contentfulOptimization).toBeUndefined() }) @@ -222,9 +224,7 @@ describe('@contentful/optimization-react-web core providers', () => { } renderToString( - + , ) @@ -232,7 +232,6 @@ describe('@contentful/optimization-react-web core providers', () => { expect(capturedContext).toEqual( expect.objectContaining({ sdk: undefined, - isReady: false, error: initializationError, }), ) @@ -246,7 +245,7 @@ describe('@contentful/optimization-react-web core providers', () => { const capturedError = captureRenderError( , @@ -316,7 +315,7 @@ describe('@contentful/optimization-react-web core providers', () => { }) renderToString( - + , ) @@ -425,13 +424,13 @@ describe('@contentful/optimization-react-web core providers', () => { }) const rendered = renderClient( - + , ) rendered.rerender( - + , ) @@ -546,7 +545,7 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - expect(results).toEqual([true, false, true]) + expect(results).toEqual([true, false, true, false, true, true]) }) it('destroys the optimization singleton on provider unmount', () => { diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index 5cfee6a7f..e1e216e8f 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -33,6 +33,7 @@ export { OptimizedEntry } from './optimized-entry/OptimizedEntry' export type { OptimizedEntryLoadingFallback, OptimizedEntryProps, + OptimizedEntryRenderContext, } from './optimized-entry/OptimizedEntry' export { useOptimizedEntry } from './optimized-entry/useOptimizedEntry' export type { 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 b86bfb675..377547656 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,7 +1,11 @@ -import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' +import type { + MergeTagEntry, + SelectedOptimizationArray, +} from '@contentful/optimization-web/api-schemas' import { act, createElement } from 'react' import { OptimizedEntry, type OptimizedEntryProps } from './OptimizedEntry' import { + createOptimizationSdk, createRuntime, getRequiredElement, getWrapper, @@ -25,6 +29,28 @@ describe('OptimizedEntry', () => { const baselineChild = makeEntry('child-baseline') const variantChild = makeEntry('child-variant') + function makeMergeTagEntry(id: string): MergeTagEntry { + const entry = makeEntry(id) + const mergeTagEntry: MergeTagEntry = { + ...entry, + fields: { + nt_mergetag_id: 'traits.continent', + nt_name: id, + }, + sys: { + ...entry.sys, + contentType: { + sys: { + id: 'nt_mergetag', + linkType: 'ContentType', + type: 'Link', + }, + }, + }, + } + return mergeTagEntry + } + const variantOneState: SelectedOptimizationArray = [ { experienceId: 'exp-hero', @@ -72,6 +98,28 @@ describe('OptimizedEntry', () => { await view.unmount() }) + it('passes merge-tag helpers to render props', async () => { + const optimization = createOptimizationSdk() + const getMergeTagValueCalls: unknown[] = [] + optimization.getMergeTagValue = function (embeddedEntryNodeTarget): string { + getMergeTagValueCalls.push([this === optimization, embeddedEntryNodeTarget]) + return 'EU' + } + const mergeTagEntry = makeMergeTagEntry('merge-tag') + + const view = await renderComponent( + + {(_resolved, { getMergeTagValue }) => getMergeTagValue(mergeTagEntry)} + , + optimization, + ) + + expect(view.container.textContent).toContain('EU') + expect(getMergeTagValueCalls).toContainEqual([true, mergeTagEntry]) + + await view.unmount() + }) + it('locks to first non-undefined optimization state when live updates are disabled', async () => { const { optimization, emit } = createRuntime((entry, selectedOptimizations) => { const selected = selectedOptimizations?.[0] @@ -604,7 +652,7 @@ describe('OptimizedEntry', () => { await spanView.unmount() }) - it('renders invisible loading target during SSR for non-optimized entries', () => { + it('renders visible resolved content during SSR when the runtime is ready', () => { const { optimization } = createRuntime((entry) => ({ entry })) const markup = renderToStringWithoutWindow(() => @@ -616,9 +664,8 @@ describe('OptimizedEntry', () => { ), ) - expect(markup).toContain('data-ctfl-loading-layout-target="true"') - expect(markup).toContain('visibility:hidden') expect(markup).toContain('baseline') + expect(markup).not.toContain('visibility:hidden') }) it('renders non-optimized content after sdk initialization', async () => { diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.testUtils.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.testUtils.tsx index 227ad4dff..2a23f0d78 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.testUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.testUtils.tsx @@ -1,5 +1,6 @@ import type { TestEntry } from '../test/sdkTestUtils' export { + createOptimizationSdk, createRuntime, defaultLiveUpdatesContext, createTestEntry as makeEntry, 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 29a049b92..9660743fa 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 @@ -4,6 +4,7 @@ import { } from '@contentful/optimization-web/presentation' import type { Entry } from 'contentful' import { createContext, useContext, useEffect, useMemo, useRef, type JSX } from 'react' +import { useOptimization } from '../hooks/useOptimization' import { createScopedLogger } from '../logger' import { resolveChildren, @@ -11,12 +12,14 @@ import { resolveLoadingLayoutTargetStyle, type LoadingFallback, type OptimizedEntryChildren, + type OptimizedEntryRenderContext, type RenderProp, type WrapperElement, } from './optimizedEntryUtils' import { useOptimizedEntrySnapshot } from './useOptimizedEntry' export type OptimizedEntryLoadingFallback = LoadingFallback +export type { OptimizedEntryRenderContext } export type OptimizedEntryWrapperElement = WrapperElement export type OptimizedEntryRenderProp = RenderProp @@ -130,6 +133,14 @@ export function OptimizedEntry({ trackViews, viewDurationUpdateIntervalMs, }: OptimizedEntryProps): JSX.Element | null { + const sdk = useOptimization() + const renderContext = useMemo( + () => ({ + getMergeTagValue: (embeddedEntryNodeTarget, profile) => + sdk.getMergeTagValue(embeddedEntryNodeTarget, profile), + }), + [sdk], + ) const { sys: { id: baselineEntryId }, } = baselineEntry @@ -165,7 +176,7 @@ export function OptimizedEntry({ targetDisplay: loadingTargetDisplay, } = loadingPresentation const loadingContent = shouldRenderBaselineWhileLoading - ? resolveChildren(children, baselineEntry) + ? resolveChildren(children, baselineEntry, renderContext) : resolvedLoadingFallback const dataTestId = dataTestIdProp ?? testId const Wrapper = as @@ -194,7 +205,7 @@ export function OptimizedEntry({ return ( - {resolveChildren(children, entry)} + {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 1771548aa..b8267b16e 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,10 +1,14 @@ import type { OptimizedEntryLoadingTargetDisplay } 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 WrapperElement = 'div' | 'span' -export type RenderProp = (resolvedEntry: Entry) => ReactNode +export interface OptimizedEntryRenderContext { + readonly getMergeTagValue: OptimizationSdk['getMergeTagValue'] +} +export type RenderProp = (resolvedEntry: Entry, context: OptimizedEntryRenderContext) => ReactNode export type OptimizedEntryChildren = ReactNode | RenderProp export type LoadingLayoutTargetStyle = Pick @@ -21,12 +25,16 @@ export function isRenderProp(children: OptimizedEntryChildren): children is Rend return typeof children === 'function' } -export function resolveChildren(children: OptimizedEntryChildren, entry: Entry): ReactNode { +export function resolveChildren( + children: OptimizedEntryChildren, + entry: Entry, + context: OptimizedEntryRenderContext, +): ReactNode { if (!isRenderProp(children)) { return children } - return children(entry) + return children(entry, context) } export function resolveLoadingLayoutTargetStyle( 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 2e009fdd2..a3dae16cb 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 @@ -55,7 +55,7 @@ describe('useOptimizedEntry', () => { entry: baselineEntry, selectedOptimization: undefined, isLoading: true, - isReady: true, + isPresentationReady: true, canOptimize: false, selectedOptimizations: undefined, }) @@ -183,7 +183,7 @@ describe('useOptimizedEntry', () => { expect(rendered.getResult()).toMatchObject({ entry: baselineEntry, isLoading: false, - isReady: true, + isPresentationReady: true, canOptimize: false, selectedOptimization: undefined, selectedOptimizations: undefined, 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 988354e7d..f26b902b7 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 @@ -10,17 +10,26 @@ import { useLiveUpdates } from '../hooks/useLiveUpdates' import { useOptimizationContext } from '../hooks/useOptimization' export interface UseOptimizedEntryParams { + /** Baseline Contentful entry fetched by the application. */ baselineEntry: Entry + /** Per-entry live-update override. */ liveUpdates?: boolean } export interface UseOptimizedEntryResult { + /** Whether SDK state says optimized content can be selected. */ canOptimize: boolean + /** Entry that should be rendered for the current hook state. */ entry: Entry + /** Whether the optimized entry is still waiting for optimization state. */ isLoading: boolean - isReady: 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'] + /** Full resolved entry data returned by the SDK resolver. */ resolvedData: ResolvedData + /** Selected optimization array used for this hook state. */ selectedOptimizations: SelectedOptimizationArray | undefined } @@ -35,6 +44,11 @@ export interface UseOptimizedEntrySnapshotParams extends UseOptimizedEntryParams viewDurationUpdateIntervalMs?: number } +/** + * Return the low-level optimized-entry presentation snapshot for a baseline entry. + * + * @public + */ export function useOptimizedEntrySnapshot({ baselineEntry, clickable, @@ -47,9 +61,10 @@ export function useOptimizedEntrySnapshot({ trackViews, viewDurationUpdateIntervalMs, }: UseOptimizedEntrySnapshotParams): OptimizedEntrySnapshot { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const liveUpdatesContext = useLiveUpdates() - const [isPresentationReady, setIsPresentationReady] = useState(false) + const isSdkReady = sdk !== undefined + const [isPresentationReady, setIsPresentationReady] = useState(isSdkReady) const controllerOptions = useMemo( () => ({ @@ -60,7 +75,7 @@ export function useOptimizedEntrySnapshot({ hasCustomLoadingFallback, isPreviewPanelOpen: liveUpdatesContext.previewPanelVisible, sdk, - isSdkStateReady: isReady, + isSdkStateReady: isSdkReady, targetDisplay, clickable, hoverDurationUpdateIntervalMs, @@ -75,7 +90,7 @@ export function useOptimizedEntrySnapshot({ clickable, hasCustomLoadingFallback, hoverDurationUpdateIntervalMs, - isReady, + isSdkReady, liveUpdates, liveUpdatesContext.globalLiveUpdates, liveUpdatesContext.previewPanelVisible, @@ -91,8 +106,8 @@ export function useOptimizedEntrySnapshot({ const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()) useEffect(() => { - setIsPresentationReady(isReady) - }, [isReady]) + setIsPresentationReady(isSdkReady) + }, [isSdkReady]) useEffect(() => { controller.setSnapshotListener(setSnapshot) @@ -113,6 +128,11 @@ export function useOptimizedEntrySnapshot({ return snapshot } +/** + * Resolve a baseline entry and expose optimized-entry loading and metadata state. + * + * @public + */ export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult { const snapshot = useOptimizedEntrySnapshot(params) @@ -120,7 +140,7 @@ export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimized canOptimize: snapshot.canOptimize, entry: snapshot.entry, isLoading: snapshot.isLoading, - isReady: snapshot.isReady, + isPresentationReady: snapshot.isPresentationReady, selectedOptimization: snapshot.selectedOptimization, resolvedData: snapshot.resolvedData, selectedOptimizations: snapshot.selectedOptimizations, diff --git a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx index b1d35f9cb..420d6875b 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx @@ -11,11 +11,11 @@ export function LiveUpdatesProvider({ children, globalLiveUpdates = false, }: LiveUpdatesProviderProps): ReactElement { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [previewPanelVisible, setPreviewPanelVisible] = useState(false) useEffect(() => { - if (!sdk || !isReady) { + if (!sdk) { return } @@ -26,7 +26,7 @@ export function LiveUpdatesProvider({ return () => { sub.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) return ( { rendered.unmount() }) - it('applies serverOptimizationState to owned SDK instances before onStatesReady and child render', async () => { + it('renders serverOptimizationState from a snapshot before owned SDK setup finishes', async () => { const serverOptimizationState = createServerOptimizationState('owned-server-profile') const setupOrder: string[] = [] let profileFromOnStatesReady: OptimizationData['profile'] | undefined = undefined - let profileFromChild: OptimizationData['profile'] | undefined = undefined + const childProfiles: Array = [] function Probe(): null { setupOrder.push('child') - profileFromChild = useOptimization().states.profile.current + childProfiles.push(useOptimization().states.profile.current) return null } @@ -250,9 +252,12 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) - expect(profileFromChild).toEqual(serverOptimizationState.profile) + expect(childProfiles).toEqual([ + serverOptimizationState.profile, + serverOptimizationState.profile, + ]) rendered.unmount() }) @@ -277,16 +282,16 @@ describe('OptimizationProvider onStatesReady', () => { sdk.destroy() }) - it('applies serverOptimizationState to injected SDK instances before onStatesReady and child render', async () => { + it('renders serverOptimizationState from a snapshot before injected SDK setup finishes', async () => { const serverOptimizationState = createServerOptimizationState('injected-ready-profile') const sdk = new ContentfulOptimization(testConfig) const setupOrder: string[] = [] let profileFromOnStatesReady: OptimizationData['profile'] | undefined = undefined - let profileFromChild: OptimizationData['profile'] | undefined = undefined + const childProfiles: Array = [] function Probe(): null { setupOrder.push('child') - profileFromChild = useOptimization().states.profile.current + childProfiles.push(useOptimization().states.profile.current) return null } @@ -303,9 +308,12 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) - expect(profileFromChild).toEqual(serverOptimizationState.profile) + expect(childProfiles).toEqual([ + serverOptimizationState.profile, + serverOptimizationState.profile, + ]) rendered.unmount() sdk.destroy() }) @@ -334,12 +342,14 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not construct owned sdk instances during server render', () => { - let childRendered = false + it('renders config-only children from a snapshot during server render', () => { + let experienceRequestStatus: string | undefined = undefined - function Probe(): null { - childRendered = true - return null + function Probe(): ReactElement { + const sdk = useOptimization() + experienceRequestStatus = sdk.states.experienceRequestState.current.status + + return {experienceRequestStatus} } const markup = renderToString( @@ -352,8 +362,74 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + expect(markup).toContain('success') + expect(experienceRequestStatus).toBe('success') + expect(window.contentfulOptimization).toBeUndefined() + }) + + it('renders serverOptimizationState during server render', () => { + const serverOptimizationState = createServerOptimizationState('server-profile') + let profileFromChild: OptimizationData['profile'] | undefined = undefined + let consentFromChild: boolean | undefined = undefined + let pageConsentFromChild = false + let trackConsentFromChild = true + + function Probe(): ReactElement { + const sdk = useOptimization() + profileFromChild = sdk.states.profile.current + consentFromChild = sdk.states.consent.current + pageConsentFromChild = sdk.hasConsent('page') + trackConsentFromChild = sdk.hasConsent('track') + + return {sdk.states.profile.current?.id} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('server-profile') + expect(profileFromChild).toEqual(serverOptimizationState.profile) + expect(consentFromChild).toBe(false) + expect(pageConsentFromChild).toBe(true) + expect(trackConsentFromChild).toBe(false) + expect(window.contentfulOptimization).toBeUndefined() + }) + + it('resolves server-selected entries from the snapshot during server render', () => { + const serverOptimizationState = { + ...createServerOptimizationState('server-profile'), + selectedOptimizations, + } + let resolvedEntryId: string | undefined = undefined + + function Probe(): ReactElement { + resolvedEntryId = useOptimization().resolveOptimizedEntry(optimizedEntry).entry.sys.id + + return {resolvedEntryId} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('4k6ZyFQnR2POY5IJLLlJRb') + expect(resolvedEntryId).toBe('4k6ZyFQnR2POY5IJLLlJRb') expect(window.contentfulOptimization).toBeUndefined() }) @@ -407,13 +483,15 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not render injected sdk children during server render when state setup must run first', () => { + it('renders injected sdk children during server render before client-only state setup', () => { const sdk = createOptimizationSdk() const onStatesReady = rs.fn() let childRendered = false + let capturedOptimization: ReturnType | undefined = undefined function Probe(): null { childRendered = true + capturedOptimization = useOptimization() return null } @@ -424,7 +502,8 @@ describe('OptimizationProvider onStatesReady', () => { ) expect(markup).toBe('') - expect(childRendered).toBe(false) + expect(childRendered).toBe(true) + expect(requireOptimizationSdk(capturedOptimization)).toBe(sdk) expect(onStatesReady).not.toHaveBeenCalled() }) @@ -454,7 +533,6 @@ describe('OptimizationProvider onStatesReady', () => { expect(capturedContext).toEqual({ sdk: undefined, - isReady: false, error, }) expect(destroySpy).toHaveBeenCalledTimes(1) 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 eaa002daa..900d7f8ff 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -1,6 +1,7 @@ import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' +import { DEFAULT_WEB_ALLOWED_EVENT_TYPES } from '@contentful/optimization-web/constants' import { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, @@ -9,9 +10,17 @@ import { type OnStatesReady as SharedOnStatesReady, type TrackEntryInteractionOptions as SharedTrackEntryInteractionOptions, } from '@contentful/optimization-web/presentation' -import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from 'react' 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. @@ -26,8 +35,8 @@ type ProviderSdkBinding = OptimizationRootSdkBinding interface ProviderState { readonly error: Error | undefined - readonly isReady: boolean - readonly sdk: OptimizationSdk | undefined + readonly isLive: boolean + readonly runtime: WebOptimizationRuntime | undefined } interface ServerOptimizationStateProps { @@ -51,7 +60,7 @@ export type OptimizationProviderConfigProps = PropsWithChildren< */ readonly trackEntryInteraction?: TrackEntryInteractionOptions /** - * Called once the SDK state surface is initialized and before provider children mount. + * Called once the live SDK state surface is initialized. * Return a cleanup function to unsubscribe app-level state observers on teardown. */ readonly onStatesReady?: OnStatesReady @@ -62,7 +71,8 @@ export type OptimizationProviderConfigProps = PropsWithChildren< export type OptimizationProviderSdkProps = PropsWithChildren< ServerOptimizationStateProps & { /** - * Called with the injected SDK state surface before provider children mount. + * Called with the injected SDK state surface before provider children mount unless a server + * snapshot is provided for the initial render. * Return a cleanup function to unsubscribe app-level state observers on teardown. */ readonly onStatesReady?: OnStatesReady @@ -167,15 +177,34 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): ) } -export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { +function injectedSdkBacksInitialRender(props: OptimizationProviderProps): boolean { + return props.sdk !== undefined && props.serverOptimizationState === undefined +} + +function createInitialRuntime(props: OptimizationProviderProps): WebOptimizationRuntime { + if (props.sdk !== undefined) { + return injectedSdkBacksInitialRender(props) + ? props.sdk + : createWebSnapshotRuntime({ data: props.serverOptimizationState }) + } + + return createWebSnapshotRuntime({ + allowedEventTypes: props.allowedEventTypes ?? DEFAULT_WEB_ALLOWED_EVENT_TYPES, + consent: props.defaults?.consent, + data: props.serverOptimizationState, + locale: props.locale, + persistenceConsent: props.defaults?.persistenceConsent, + }) +} + +export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { const { children } = props const initialPropsRef = useRef(props) const liveLocale = props.sdk === undefined ? props.locale : undefined - const canRenderInjectedSdk = canUseInjectedSdkDuringInitialRender(props) const [state, setState] = useState(() => ({ error: undefined, - isReady: canRenderInjectedSdk, - sdk: canRenderInjectedSdk ? props.sdk : undefined, + isLive: injectedSdkBacksInitialRender(props), + runtime: createInitialRuntime(props), })) useLayoutEffect(() => { @@ -203,12 +232,12 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } sdkBinding = initializedBinding - setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) + setState({ error: undefined, isLive: true, runtime: initializedBinding.sdk }) } function setInitializationError(error: unknown): void { if (!setupState.disposed) { - setState({ error: toError(error), isReady: false, sdk: undefined }) + setState({ error: toError(error), isLive: false, runtime: undefined }) } } @@ -237,22 +266,27 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle }, []) useLayoutEffect(() => { - if (state.sdk === undefined || props.sdk !== undefined || liveLocale === undefined) { + if (!state.isLive || state.runtime === undefined || props.sdk !== undefined) { + return + } + + if (liveLocale === undefined) { return } try { - state.sdk.setLocale(liveLocale) + state.runtime.setLocale(liveLocale) } catch (error: unknown) { - setState({ error: toError(error), isReady: true, sdk: state.sdk }) + setState({ error: toError(error), isLive: true, runtime: state.runtime }) } - }, [liveLocale, props.sdk, state.sdk]) - - const shouldRenderChildren = state.isReady || state.error !== undefined + }, [liveLocale, props.sdk, state.isLive, state.runtime]) - if (!shouldRenderChildren) { - return null - } + const contextValue = useMemo( + () => ({ sdk: state.runtime, error: state.error }), + [state.runtime, state.error], + ) - return {children} + return ( + {children} + ) } diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx index 9e33d30d4..9d1268a2a 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx @@ -47,7 +47,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx index e0b25558f..6bf86c84e 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx @@ -34,7 +34,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx index de8cc5bda..ad3004864 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx @@ -36,7 +36,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx index d09731ed6..173864a8c 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx @@ -65,7 +65,7 @@ function buildTestRouter( ): ReturnType { function RootLayout(): ReactElement { return ( - + {tracker} diff --git a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts new file mode 100644 index 000000000..e7a3b2a6a --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts @@ -0,0 +1,28 @@ +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/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 5c50853c3..ecae52986 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -461,7 +461,7 @@ export async function renderWithOptimizationProviders( await act(async () => { await Promise.resolve() root.render( - + {node} , ) @@ -485,7 +485,7 @@ export function renderWithOptimizationProvidersToString( liveUpdatesContext = defaultLiveUpdatesContext(), ): string { return renderToString( - + {node} , ) diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index b4bac47b7..0f8e846a1 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -52,6 +52,16 @@ "default": "./dist/bridge-support.cjs" } }, + "./runtime": { + "import": { + "types": "./dist/runtime.d.mts", + "default": "./dist/runtime.mjs" + }, + "require": { + "types": "./dist/runtime.d.cts", + "default": "./dist/runtime.cjs" + } + }, "./presentation": { "import": { "types": "./dist/presentation.d.mts", @@ -125,6 +135,8 @@ "index.mjs": 12500, "bridge-support.cjs": 1200, "bridge-support.mjs": 1200, + "runtime.cjs": 800, + "runtime.mjs": 200, "contentful-optimization-web-components.umd.js": 39000, "presentation.cjs": 3200, "presentation.mjs": 3200, diff --git a/packages/web/web-sdk/rslib.config.ts b/packages/web/web-sdk/rslib.config.ts index 88147bd85..ae83364b5 100644 --- a/packages/web/web-sdk/rslib.config.ts +++ b/packages/web/web-sdk/rslib.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ logger: './src/logger.ts', constants: './src/constants.ts', 'bridge-support': './src/bridge-support.ts', + runtime: './src/runtime.ts', presentation: './src/presentation/index.ts', 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', @@ -96,6 +97,7 @@ export default defineConfig({ logger: './src/logger.ts', constants: './src/constants.ts', 'bridge-support': './src/bridge-support.ts', + runtime: './src/runtime.ts', presentation: './src/presentation/index.ts', 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index a00650d02..a2aa2afa1 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -25,6 +25,7 @@ import { ANONYMOUS_ID_COOKIE_LEGACY } from '@contentful/optimization-core/consta import { getPageProperties, getUserAgent } from './builders/EventBuilder' import { ANONYMOUS_ID_COOKIE, + DEFAULT_WEB_ALLOWED_EVENT_TYPES, OPTIMIZATION_WEB_SDK_NAME, OPTIMIZATION_WEB_SDK_VERSION, } from './constants' @@ -209,7 +210,7 @@ function mergeConfig({ logLevel: LocalStore.debug ? 'debug' : logLevel, } - mergedConfig.allowedEventTypes ??= allowedEventTypes ?? ['identify', 'page'] + mergedConfig.allowedEventTypes ??= allowedEventTypes ?? [...DEFAULT_WEB_ALLOWED_EVENT_TYPES] return mergedConfig } diff --git a/packages/web/web-sdk/src/constants.ts b/packages/web/web-sdk/src/constants.ts index d5acff1ce..c8ed4a9d4 100644 --- a/packages/web/web-sdk/src/constants.ts +++ b/packages/web/web-sdk/src/constants.ts @@ -1,8 +1,17 @@ +import type { AllowedEventType } from '@contentful/optimization-core' + // eslint-disable-next-line @typescript-eslint/naming-convention -- Replaced at build-time declare const __OPTIMIZATION_VERSION__: string | undefined // eslint-disable-next-line @typescript-eslint/naming-convention -- Replaced at build-time declare const __OPTIMIZATION_PACKAGE_NAME__: string | undefined +/** + * Event types the Web SDK admits before event consent is granted. + * + * @public + */ +export const DEFAULT_WEB_ALLOWED_EVENT_TYPES: readonly AllowedEventType[] = ['identify', 'page'] + /** * Version of the Optimization Web SDK, replaced at build time. * diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index c50541522..8cd516724 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -19,6 +19,7 @@ export type OptimizationWebRuntime = ContentfulOptimization export * from './builders/EventBuilder' export { CAN_ADD_LISTENERS, + DEFAULT_WEB_ALLOWED_EVENT_TYPES, ENTRY_SELECTOR, HAS_MUTATION_OBSERVER, OPTIMIZATION_WEB_SDK_NAME, diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index ba3a6d1fa..f14bc8a8f 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -8,63 +8,120 @@ import { const BASELINE_REVEAL_TIMEOUT_MS = 5000 +/** + * Display mode used for the temporary loading layout target. + * + * @public + */ export type OptimizedEntryLoadingTargetDisplay = 'block' | 'inline' +/** + * Layout-neutral display value used by optimized-entry host elements. + * + * @public + */ export const OPTIMIZED_ENTRY_HOST_DISPLAY = 'contents' interface ExperienceRequestStateLike { readonly status: string } +/** + * Minimal SDK surface needed by optimized-entry presentation controllers. + * + * @public + */ export interface OptimizedEntrySdk { + /** SDK state observables used to resolve and track optimized entry content. */ readonly states: { readonly canOptimize: Observable readonly experienceRequestState: Observable readonly optimizationPossible: Observable readonly selectedOptimizations: Observable } + /** Resolve a Contentful entry against the currently selected optimizations. */ resolveOptimizedEntry: ( entry: Entry, selectedOptimizations?: SelectedOptimizationArray, ) => ResolvedData } +/** + * Current presentation state for one optimized entry. + * + * @public + */ export interface OptimizedEntrySnapshot { + /** Whether SDK state says optimized content can be selected. */ readonly canOptimize: boolean + /** Entry that should be rendered for the current snapshot. */ readonly entry: Entry + /** Host attributes needed for automatic entry interaction tracking. */ readonly hostAttributes: OptimizedEntryTrackingAttributes + /** Whether the optimized entry is still waiting for optimization state. */ readonly isLoading: boolean - readonly isReady: boolean + /** Whether the client presentation layer is ready to reveal rendered content. */ + readonly isPresentationReady: boolean + /** Loading and fallback rendering decisions for wrappers around the entry. */ readonly loadingPresentation: { readonly showLoadingFallback: boolean readonly hideLoadingLayoutTarget: boolean readonly shouldRenderBaselineWhileLoading: boolean readonly targetDisplay: OptimizedEntryLoadingTargetDisplay } + /** Full resolved entry data returned by the SDK resolver. */ readonly resolvedData: ResolvedData + /** Selected optimization that resolved the current entry, when one applied. */ readonly selectedOptimization: ResolvedData['selectedOptimization'] + /** Selected optimization array used for this snapshot. */ readonly selectedOptimizations: SelectedOptimizationArray | undefined } +/** + * Inputs used to configure an {@link OptimizedEntryController}. + * + * @public + */ export interface OptimizedEntryControllerOptions { + /** Whether the client presentation layer is ready to reveal rendered content. */ readonly isPresentationReady?: boolean + /** Baseline Contentful entry fetched by the application. */ readonly baselineEntry: Entry + /** Per-entry live-update override. */ readonly entryLiveUpdatesEnabled?: boolean + /** Root-level live-update setting inherited by entries without an override. */ readonly rootLiveUpdatesEnabled?: boolean + /** Whether the wrapper has its own loading fallback UI. */ readonly hasCustomLoadingFallback?: boolean + /** Delay before baseline content is revealed while optimization remains unresolved. */ readonly baselineRevealTimeoutMs?: number + /** Whether the preview panel is open and should force live updates. */ readonly isPreviewPanelOpen?: boolean + /** SDK instance used for optimized entry resolution. */ readonly sdk?: OptimizedEntrySdk + /** Whether SDK state observables are ready to read. */ readonly isSdkStateReady?: boolean + /** Display mode for the temporary loading layout target. */ readonly targetDisplay?: OptimizedEntryLoadingTargetDisplay + /** Whether the wrapper should be marked as a click target. */ readonly clickable?: boolean + /** Hover duration update interval in milliseconds. */ readonly hoverDurationUpdateIntervalMs?: number + /** Per-entry click tracking override. */ readonly trackClicks?: boolean + /** Per-entry hover tracking override. */ readonly trackHovers?: boolean + /** Per-entry view tracking override. */ readonly trackViews?: boolean + /** View duration update interval in milliseconds. */ readonly viewDurationUpdateIntervalMs?: number } +/** + * Receives optimized-entry snapshot updates. + * + * @public + */ export type OptimizedEntrySnapshotListener = (snapshot: OptimizedEntrySnapshot) => void interface NormalizedOptimizedEntryControllerOptions { @@ -86,8 +143,15 @@ interface NormalizedOptimizedEntryControllerOptions { readonly viewDurationUpdateIntervalMs?: number } +/** + * Duplicate-baseline guard state for nested optimized entries. + * + * @public + */ export interface OptimizedEntryNestingState { + /** Baseline IDs for the current optimized entry and all optimized-entry ancestors. */ readonly currentAndAncestorBaselineIds: ReadonlySet + /** Whether the current baseline ID already exists in an optimized-entry ancestor. */ readonly hasDuplicateBaselineAncestor: boolean } @@ -114,10 +178,20 @@ function normalizeOptions( } } +/** + * Return whether a Contentful entry contains optimization references. + * + * @public + */ export function hasOptimizationReferences(entry: Entry): boolean { return Array.isArray(entry.fields.nt_experiences) && entry.fields.nt_experiences.length > 0 } +/** + * Resolve duplicate-baseline guard state for a nested optimized entry. + * + * @public + */ export function resolveOptimizedEntryNestingState( baselineEntryId: string, ancestorBaselineIds: ReadonlySet | null | undefined, @@ -132,6 +206,11 @@ export function resolveOptimizedEntryNestingState( } } +/** + * Resolve whether an optimized entry should react to later SDK state updates. + * + * @public + */ export function resolveShouldLiveUpdate(params: { readonly entryLiveUpdatesEnabled: boolean | undefined readonly rootLiveUpdatesEnabled: boolean @@ -185,7 +264,7 @@ function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySn left.canOptimize === right.canOptimize && left.entry === right.entry && left.isLoading === right.isLoading && - left.isReady === right.isReady && + left.isPresentationReady === right.isPresentationReady && left.selectedOptimization === right.selectedOptimization && left.selectedOptimizations === right.selectedOptimizations && areLoadingPresentationsEqual(left.loadingPresentation, right.loadingPresentation) && @@ -193,6 +272,12 @@ function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySn ) } +/** + * Coordinates optimized-entry resolution, loading presentation, live updates, and tracking + * attributes without depending on a specific UI framework. + * + * @public + */ export class OptimizedEntryController { private canOptimize = false private connected = false @@ -208,13 +293,16 @@ export class OptimizedEntryController { constructor(options: OptimizedEntryControllerOptions) { this.options = normalizeOptions(options) + this.primeStateFromSdk() this.snapshot = this.createSnapshot() } + /** Register or clear the callback that receives snapshot updates. */ setSnapshotListener(listener: OptimizedEntrySnapshotListener | undefined): void { this.listener = listener } + /** Subscribe to SDK state and start loading timeout management. */ connect(): void { if (this.connected) { return @@ -225,12 +313,14 @@ export class OptimizedEntryController { this.updateSnapshot() } + /** Unsubscribe from SDK state and stop loading timeout management. */ disconnect(): void { this.connected = false this.clearSubscriptions() this.clearLoadingRevealTimer() } + /** Apply new controller options and recompute the current snapshot. */ updateOptions(options: OptimizedEntryControllerOptions): void { const { options: previousOptions } = this const previousShouldLiveUpdate = this.shouldLiveUpdate() @@ -262,6 +352,7 @@ export class OptimizedEntryController { this.updateSnapshot() } + /** Return the latest optimized-entry snapshot. */ getSnapshot(): OptimizedEntrySnapshot { return this.snapshot } @@ -281,9 +372,7 @@ export class OptimizedEntryController { this.subscriptions = [] } - private resubscribe(): void { - this.clearSubscriptions() - + private primeStateFromSdk(): void { const { options } = this const { sdk, isSdkStateReady } = options if (!sdk || !isSdkStateReady) { @@ -302,6 +391,22 @@ export class OptimizedEntryController { this.canOptimize = currentCanOptimize this.hasExperienceRequestSettled = isExperienceRequestSettled(currentExperienceRequestState) this.optimizationPossible = currentOptimizationPossible + } + + private resubscribe(): void { + this.clearSubscriptions() + + const { options } = this + const { sdk, isSdkStateReady } = options + if (!sdk || !isSdkStateReady) { + return + } + + this.primeStateFromSdk() + + const { states } = sdk + const { canOptimize, experienceRequestState, optimizationPossible, selectedOptimizations } = + states this.subscriptions = [ selectedOptimizations.subscribe((nextSelectedOptimizations) => { @@ -379,7 +484,7 @@ export class OptimizedEntryController { this.options, ), isLoading, - isReady: this.options.isPresentationReady, + isPresentationReady: this.options.isPresentationReady, loadingPresentation: { showLoadingFallback, hideLoadingLayoutTarget, diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts index 11730d99d..1ece32a87 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryTrackingAttributes.ts @@ -1,17 +1,38 @@ import type { ResolvedData } from '@contentful/optimization-core' import type { Entry, EntrySkeletonType } from 'contentful' +/** + * Value type supported by optimized-entry host tracking attributes. + * + * @public + */ export type OptimizedEntryHostAttributeValue = string | boolean | number | undefined +/** + * Options that control optimized-entry interaction tracking attributes. + * + * @public + */ export interface OptimizedEntryTrackingAttributeOptions { + /** Whether the host element should be treated as a click target. */ readonly clickable?: boolean + /** Hover duration update interval in milliseconds. */ readonly hoverDurationUpdateIntervalMs?: number + /** Per-entry click tracking override. */ readonly trackClicks?: boolean + /** Per-entry hover tracking override. */ readonly trackHovers?: boolean + /** Per-entry view tracking override. */ readonly trackViews?: boolean + /** View duration update interval in milliseconds. */ readonly viewDurationUpdateIntervalMs?: number } +/** + * Data attributes applied to optimized-entry host elements for automatic tracking. + * + * @public + */ export type OptimizedEntryTrackingAttributes = Record interface SelectedOptimizationWithDuplicationScope { @@ -34,6 +55,11 @@ function resolveDuplicationScope( return candidate.trim() ? candidate : undefined } +/** + * Build host tracking attributes for an optimized-entry presentation snapshot. + * + * @public + */ export function resolveOptimizedEntryTrackingAttributes( baselineEntry: Entry, resolvedData: ResolvedData, diff --git a/packages/web/web-sdk/src/presentation/index.ts b/packages/web/web-sdk/src/presentation/index.ts index 6d4de592c..ff0aa763a 100644 --- a/packages/web/web-sdk/src/presentation/index.ts +++ b/packages/web/web-sdk/src/presentation/index.ts @@ -1,3 +1,9 @@ +/** + * Low-level presentation primitives shared by optimized-entry wrappers. + * + * @packageDocumentation + */ + export { createOptimizationRootSdkBinding, disposeOptimizationRootSdkBinding, diff --git a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts index 6fd207c50..6f4a8284a 100644 --- a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts +++ b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts @@ -10,43 +10,91 @@ import type { OptimizedEntrySdk } from './OptimizedEntryController' type Cleanup = () => void type OnStatesReadyResult = Cleanup | ReturnType +/** + * Entry interaction tracking options accepted by presentation roots. + * + * @public + */ export type TrackEntryInteractionOptions = AutoTrackEntryInteractionOptions + +/** + * Web SDK configuration accepted when a presentation root owns the SDK instance. + * + * @public + */ export type OptimizationRootSdkConfig = Omit +/** + * SDK surface required by presentation roots. + * + * @public + */ export interface OptimizationRootSdk extends OptimizedEntrySdk, Partial> { + /** SDK states required by optimized entries and preview-panel-aware roots. */ readonly states: OptimizedEntrySdk['states'] & { readonly previewPanelOpen: Observable } + /** Release SDK-owned resources. */ destroy: () => void + /** Set the active locale and return the resulting locale. */ setLocale: (locale: string) => string | undefined } +/** + * Callback invoked after a presentation root has SDK states available. + * + * @public + */ export type OnStatesReady = ( states: TSdk['states'], ) => OnStatesReadyResult +/** + * SDK binding owned by or injected into a presentation root. + * + * @public + */ export interface OptimizationRootSdkBinding< TSdk extends OptimizationRootSdk = OptimizationRootSdk, > { + /** Optional cleanup returned by `onStatesReady`. */ readonly cleanup?: Cleanup + /** Whether the binding created and must destroy the SDK instance. */ readonly ownsInstance: boolean + /** Bound SDK instance. */ readonly sdk: TSdk } +/** + * Options for binding an existing SDK instance to a presentation root. + * + * @public + */ export interface CreateInjectedOptimizationRootSdkBindingOptions { + /** Callback invoked once SDK states are available. */ readonly onStatesReady?: OnStatesReady + /** Existing SDK instance to bind. */ readonly sdk: TSdk } +/** + * Options for creating and binding a presentation-root-owned SDK instance. + * + * @public + */ export interface CreateOwnedOptimizationRootSdkBindingOptions< TSdk extends OptimizationRootSdk = OptimizationRootSdk, > { + /** Web SDK configuration, excluding automatic entry tracking options. */ readonly config: OptimizationRootSdkConfig + /** Factory used to create the owned SDK instance. */ readonly createSdk: (config: OptimizationWebConfig) => TSdk + /** Callback invoked once SDK states are available. */ readonly onStatesReady?: OnStatesReady + /** Automatic entry interaction tracking options for the owned SDK. */ readonly trackEntryInteraction?: TrackEntryInteractionOptions } @@ -74,12 +122,22 @@ function createOwnedSdkBinding( } } +/** + * Resolve automatic entry interaction tracking options for a presentation root. + * + * @public + */ export function resolveTrackEntryInteractionOptions( trackEntryInteraction: TrackEntryInteractionOptions | undefined, ): Required { return resolveAutoTrackEntryInteractionOptions(trackEntryInteraction) } +/** + * Create an injected or owned SDK binding for a presentation root. + * + * @public + */ export function createOptimizationRootSdkBinding( options: | CreateInjectedOptimizationRootSdkBindingOptions @@ -101,6 +159,11 @@ export function createOptimizationRootSdkBinding