diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md index e0d7c52b3..6e3f62f79 100644 --- a/documentation/concepts/README.md +++ b/documentation/concepts/README.md @@ -8,6 +8,7 @@ children: - ./interaction-tracking-in-web-sdks.md - ./interaction-tracking-in-node-and-stateless-environments.md - ./profile-synchronization-between-client-and-server.md + - ./server-side-rendering-and-hydration.md - ./react-native-sdk-interaction-tracking-mechanics.md - ./ios-sdk-runtime-and-interaction-mechanics.md - ./android-sdk-runtime-and-interaction-mechanics.md @@ -45,6 +46,10 @@ they are not the first stop for installation or setup commands. - [Profile synchronization between client and server](./profile-synchronization-between-client-and-server.md) - explains how profile identity, profile data, cookies, browser storage, and Experience API responses work together when Node and Web SDK runtimes share a visitor journey. +- [Server-side rendering and hydration](./server-side-rendering-and-hydration.md) - explains how the + React layer renders personalized content on the server through an isomorphic runtime seam, where + the server-versus-client boundary falls, and the determinism contract that keeps server HTML and + the first client render identical. - [React Native SDK interaction tracking mechanics](./react-native-sdk-interaction-tracking-mechanics.md) - explains how the React Native SDK observes, gates, and emits tracking events, covering event types, the viewport state machine, default visibility and timing, consent gating, scroll context, diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index be3cb69ee..730b3053b 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -88,8 +88,9 @@ 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. +Next.js uses the Node server and React Web client surfaces. The isomorphic `OptimizedEntry` resolves +entries on the server for first paint and hydrates on the client; `ServerOnlyOptimizedEntry` is +available for pure zero-JavaScript Server Component rendering. ## 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..92a996033 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: server helpers and `ServerOnlyOptimizedEntry` 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. | ## Constraints that decide delivery @@ -75,8 +75,9 @@ Apply these constraints before choosing server-only, hybrid, or manual tracking: 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 + server-returned Optimization data through `serverOptimizationState` (call + `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client + Component to hydrate page-specific data under an existing provider). 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 @@ -312,8 +313,9 @@ 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 +Next.js, the isomorphic `OptimizedEntry` renders the Web SDK tracking attributes as part of +resolving the entry on the server; `ServerOnlyOptimizedEntry` does the same from an explicit +`ResolvedData` for pure zero-JavaScript server rendering. 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 @@ -393,9 +395,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 Next.js, pass the server `OptimizationData` through `serverOptimizationState` (call + `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client + Component to hydrate page-specific data under an existing provider). 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 +409,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 without `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. 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..ea824e224 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -185,12 +185,13 @@ current request locale. Server Components pass `headers()` to `getNextjsServerOp 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 +Locale handoff is separate from server optimization state handoff. Pass the server data with +`serverOptimizationState` on `OptimizationRoot`, resolving it in the layout so the provider renders +personalized state on the server and hydrates the same data on the client. Keep `defaults` for configuration or default state such as consent policy, not for server-returned profile, selected -optimizations, or changes. +optimizations, or changes. (To hydrate page-specific data under an existing provider that was not +seeded with server data, call `hydrateOptimizationData` from +`@contentful/optimization-web/bridge-support` inside a Client Component.) ## Node and stateless SDKs @@ -230,9 +231,10 @@ 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 Next.js client `useOptimizedEntry()`, and the isomorphic `OptimizedEntry` that + resolves on the server and hydrates on the client. +- Next.js server `resolveOptimizedEntry()` for Server Component resolution; + `ServerOnlyOptimizedEntry` for pure zero-JavaScript server rendering. - iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI `OptimizedEntry(entry:)`. - Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 3099e2b7b..2c917d671 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -105,13 +105,17 @@ profile-changing events: 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. + `serverOptimizationState` on the provider or root, which renders it on the server and hydrates it + on the client. To hydrate page-specific data under an existing provider that was not seeded with + server data, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` + inside a Client Component. For consent gates, see [Consent management in the Optimization SDK Suite](./consent-management-in-the-optimization-sdk-suite.md). For state shape and observable state mechanics, see -[Core state management](./core-state-management.md). +[Core state management](./core-state-management.md). For how the React provider renders this data on +the server and hydrates it without a mismatch, see +[Server-side rendering and hydration](./server-side-rendering-and-hydration.md). ## Runtime responsibilities @@ -140,11 +144,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 React Web and Next.js provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. To hydrate page-specific data under an existing provider, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client Component. | +| **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: @@ -164,10 +168,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. +`OptimizationData` to `serverOptimizationState`; the provider renders that state on the server and +hydrates the same data on the client (call `hydrateOptimizationData` from +`@contentful/optimization-web/bridge-support` inside a Client Component to hydrate page-specific +data under an existing provider). 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 +500,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 React Web and Next.js provider handoff, use `serverOptimizationState`. To hydrate page-specific data under an existing provider, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client Component. | +| 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 +528,11 @@ 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`, or use React Web or Next.js `serverOptimizationState` for + provider handoff (call `hydrateOptimizationData` from + `@contentful/optimization-web/bridge-support` inside a Client Component for page-level hydration), + 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/concepts/server-side-rendering-and-hydration.md b/documentation/concepts/server-side-rendering-and-hydration.md new file mode 100644 index 000000000..986606063 --- /dev/null +++ b/documentation/concepts/server-side-rendering-and-hydration.md @@ -0,0 +1,183 @@ +--- +title: Server-side rendering and hydration +--- + +# Server-side rendering and hydration + +Use this document to understand how the React layer renders personalized content on the server and +hands it off to the browser without a blank first paint or a hydration mismatch. It explains the +isomorphic runtime seam that lets the same hooks and components run in both environments, where the +server-versus-client boundary actually falls, and the determinism contract that keeps server HTML +and the first client render identical. + +For installation and setup, start with the [Optimization SDK guides](../guides/README.md). For how +profile data and identifiers move between runtimes, see +[Profile synchronization between client and server](./profile-synchronization-between-client-and-server.md). +For how a baseline entry resolves to a variant, see +[Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md). + +
+ Table of Contents + + +- [Runtime applicability](#runtime-applicability) +- [The problem SSR poses](#the-problem-ssr-poses) +- [Capability tiers](#capability-tiers) +- [The isomorphic runtime seam](#the-isomorphic-runtime-seam) + - [The provider chooses the backing](#the-provider-chooses-the-backing) + - [Reading state on the server](#reading-state-on-the-server) + - [Actions and tracking on the server](#actions-and-tracking-on-the-server) +- [The render lifecycle](#the-render-lifecycle) +- [The React Server Component boundary](#the-react-server-component-boundary) +- [The hydration determinism contract](#the-hydration-determinism-contract) +- [Framework handoff](#framework-handoff) + + +
+ +## Runtime applicability + +| Runtime | SSR role | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **React Web SDK** (`@contentful/optimization-react-web`) | Owns the isomorphic provider, hooks, and `OptimizedEntry`. Renders on the server and hydrates in the browser. | +| **Core SDK** (`@contentful/optimization-core`) | Provides the `OptimizationRuntime` contract and `createSnapshotRuntime`, the read-only runtime used for server rendering. Exposed on the `./runtime` subpath. | +| **Node SDK** (`@contentful/optimization-node`) | Produces the request-scoped `OptimizationData` snapshot the server render is seeded with, using `forRequest()`. | +| **Next.js SDK** (`@contentful/optimization-nextjs`) | Binds the request context, resolves the snapshot, and forwards it to the provider. | + +## The problem SSR poses + +The browser SDK (`ContentfulOptimization`) is stateful and browser-bound: its constructor reads +`localStorage` and `document.cookie`, attaches listeners, and registers a `window` singleton. It +cannot be constructed during server rendering, and React does not run effects (`useEffect`, +`useLayoutEffect`) on the server at all — only the render function and `useState`/`useMemo` +initializers run. + +A provider that constructs the SDK in an effect and gates its children on that SDK therefore renders +nothing on the server: the effect never runs, so the children never mount, and `renderToString` +emits an empty tree. With JavaScript disabled that is a permanently blank page; with JavaScript +enabled it is a blank first paint until hydration. + +The fix is not to make the browser SDK run on the server. It is to recognize that rendering +personalized content does not require the _stateful browser_ SDK — only the personalization data and +the pure logic that resolves it. + +## Capability tiers + +The SDK surface splits into three tiers by what each capability needs, not by environment: + +| Tier | Members | Needs a browser? | Server behavior | +| --------------- | ------------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------- | +| **Resolve** | `resolveOptimizedEntry`, `getMergeTagValue`, `getFlag` | No — pure functions | Identical to the client | +| **Read state** | `states.{consent, profile, selectedOptimizations, canOptimize, …}` | No | Static values from the request snapshot | +| **Act / track** | `identify`, `page`, `track`, `tracking.*`, `trackCurrentPage` | Yes | Inert no-ops (there is no user interaction to record on the server) | + +The resolve tier already lives on `CoreBase`, the shared base class of both the stateful and +stateless runtimes, so variant resolution is available server-side with no browser globals. Reading +state server-side needs only a static view of the request's evaluated data. Only interaction +tracking genuinely needs the browser — and it is effect-only by nature (it observes DOM elements), +so it never executes during a server render. + +## The isomorphic runtime seam + +`OptimizationRuntime` is the single interface the hooks and components bind to. It is derived from +the stateful runtime, so the live browser SDK satisfies it by construction, and a server +implementation is forced to match the same signatures. Two runtimes implement it: + +- **The live SDK** (`ContentfulOptimization`) on the client after hydration. +- **A snapshot runtime** (`createSnapshotRuntime`, on `@contentful/optimization-core/runtime`) for + the server render and the initial client render. Its resolve methods delegate to the shared static + resolvers, its `states` are static observables over a serialized `OptimizationData` snapshot, and + its actions are inert no-ops that warn in development. + +Consumers never see two objects or branch on environment. They call `useOptimization()` and receive +one runtime whose behavior is correct wherever it runs. + +### The provider chooses the backing + +`OptimizationProvider` is the only place that decides which runtime backs the context: + +- On the server render and the first client render, it seeds the context with a snapshot runtime + built from the configured consent, locale, and any `serverOptimizationState`. `isReady` is `true`, + so children render immediately. +- In the mount effect (client only), it constructs the live `ContentfulOptimization`, hydrates it + with the same snapshot, and swaps the context to point at it. + +From that point the context is fully interactive: a later consent grant or identify call updates the +live signals and re-renders dependent components. + +### Reading state on the server + +Read hooks (`useConsentState`, `useProfileState`, `useSelectedOptimizationsState`, and the rest) +subscribe through `useSyncExternalStore`. On the server and during the first hydration render, React +calls the hook's `getServerSnapshot`, which reads the runtime observable's current value directly. +Because the snapshot runtime's observables are static, that value is stable and matches the value +the client is seeded with. + +### Actions and tracking on the server + +Event actions (`identify`, `page`, `track`) and browser-only tracking (`tracking.enableElement`, +`trackCurrentPage`) are safe to call on the snapshot runtime; they are inert no-ops. In practice the +server never reaches the browser-only ones, because they run inside effects, which do not execute +during server rendering. Keeping them present and safe means application code calls them the same +way everywhere without an environment check. + +## The render lifecycle + +```text +SERVER BROWSER +------ ------- +1. Resolve request OptimizationData +2. Render with a snapshot runtime ──► 3. Paint personalized HTML immediately + • render() + useState run (also what a JS-disabled browser or + • effects DO NOT run crawler sees) + • getServerSnapshot supplies state + • OptimizedEntry resolves variants ──► 4. Download JS + 5. Hydrate: first render reuses the SAME + snapshot, so markup matches + 6. Effect constructs the live SDK, + hydrates it, swaps the context + 7. Interactive: state changes re-render +``` + +## The React Server Component boundary + +In the Next.js App Router, "isomorphic" applies to Client Components (`'use client'`) that are +server-rendered for first paint and then hydrate — this covers `OptimizedEntry` and every read hook. +React hooks cannot run inside a true asynchronous Server Component; that is a React rule, not an SDK +limitation. + +Code that runs only in a Server Component — the fetch/decide step that produces the snapshot, or a +server-only entry render — uses the same-named imperative method, +`runtime.resolveOptimizedEntry(entry, selectedOptimizations)`, rather than a parallel vocabulary. +The developer story is therefore: resolve the snapshot in a Server Component or server helper, then +write the personalized UI as Client Components that server-render for first paint and hydrate for +interactivity. + +## The hydration determinism contract + +For hydration to succeed, the server render and the first client render must produce identical +markup. The rules that guarantee it: + +- **Seed the first client render synchronously from the snapshot.** Never resolve initial + personalization state inside an effect — that guarantees a flash and a hydration mismatch. +- **The serialized snapshot must be the exact value the client provider is seeded with.** Variant + resolution is a pure function of the baseline entry and `selectedOptimizations`, so identical + inputs produce identical output on both sides. +- **Reconcile later, not during hydration.** If the browser later discovers newer state (for example + a locally cached profile), it applies after hydration through a normal re-render — the + React-blessed path — rather than by rendering different initial markup. + +## Framework handoff + +The snapshot is a small, serializable `OptimizationData` shape (`profile`, `selectedOptimizations`, +`changes`). Each framework binds the same three seams — resolve on the server, transport the +snapshot, hydrate on the client: + +- **Next.js** resolves the snapshot with the server helpers and passes it to the provider through + `serverOptimizationState`. +- **Angular** carries the snapshot through `TransferState` and initializes read-only signals on the + server, then the live Web SDK in the browser. + +Because the resolve/read tiers are shared and the transport is one serializable shape, adding a new +framework is a thin adapter over the same contract rather than a reimplementation of personalization +logic. 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..56bdbb437 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 @@ -562,25 +562,30 @@ export default async function Home() { } ``` -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. +When a route renders entry HTML without client takeover, render the isomorphic `OptimizedEntry`. It +resolves the variant on the server for first paint and emits the same `data-ctfl-*` metadata the +browser interaction tracker reads after browser startup. **Follow this pattern:** ```tsx -import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server' +import { OptimizedEntry } from '@contentful/optimization-nextjs/client' -function ServerRenderedEntry({ baselineEntry, resolvedData }: ServerRenderedEntryProps) { +function ServerRenderedEntry({ baselineEntry }: ServerRenderedEntryProps) { return ( - // Render data-ctfl-* attributes for browser entry-interaction tracking. - -

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

-
+ + {(entry) =>

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

} +
) } ``` +> [!NOTE] +> +> `OptimizedEntry` is the default for entry rendering. Use `ServerOnlyOptimizedEntry` only for a +> pure Server Component with zero client JavaScript for the entry and no live updates, resolving +> `resolvedData` yourself on the server. + ### Browser root and server optimization state **Integration category:** Required for first integration @@ -774,7 +779,8 @@ delivery. them; use `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. +3. `OptimizedEntry` already renders server-side and emits tracking metadata; use + `ServerOnlyOptimizedEntry` only for a pure zero-JavaScript Server Component entry. 4. Use `sdk.tracking.enableElement(...)` from `useOptimization()` only for app-owned manual observation cases. 5. Verify consent gates. Page events can be allowed before full consent, but entry views, clicks, 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..56b449303 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 @@ -50,17 +50,17 @@ preference, or regional rule, use the policy-dependent consent section before re 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. + `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`. Entry + resolution runs entirely on the server, so the initial HTML shows the selected variant without + any browser SDK context. The client provider section adds the browser SDK for page events, + tracking, and consent controls, and hands this same server data to the browser through + `serverOptimizationState`. **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' @@ -92,7 +92,6 @@ preference, or regional rule, use the policy-dependent consent section before re return (
-

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

) @@ -133,24 +132,24 @@ preference, or regional rule, use the policy-dependent consent section before re Use this table as the setup inventory for the full SSR 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 `OptimizationRoot` props for browser integrations | -| 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 | -| 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` | -| 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 | -| Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide | +| 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 `OptimizationRoot` props for browser integrations | +| 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 | +| 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` | +| 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 | `OptimizedEntry`, `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 | +| 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 @@ -275,15 +274,23 @@ consent controls, identify, and reset run in the browser through the Next.js cli 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. +3. Resolve the request optimization data in the layout and pass it to `OptimizationRoot` (or + `OptimizationProvider`) through `serverOptimizationState={optimizationData}`. The provider + renders the same personalized state on the server and hydrates the live browser SDK with it on + the client, so the layout is the single handoff point. Deduplicate the resolution with the page + components using a request-scoped cache; see + [Caching and request deduplication](#caching-and-request-deduplication). 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. +> [!NOTE] +> +> `serverOptimizationState` on the provider is the recommended handoff. To seed a configuration-only +> provider and hydrate page-specific data later under existing SDK context, call +> `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client +> Component. + **Adapt this to your use case:** ```tsx @@ -303,19 +310,24 @@ consent controls, identify, and reset run in the browser through the Next.js cli ``` -For policy-dependent consent, derive the initial tracker behavior from the same source that the -server used: +For policy-dependent consent, resolve the request optimization data and derive the initial tracker +behavior from the same source that the server used. Passing `serverOptimizationState` makes the +provider render identified and personalized state at first paint, even with JavaScript disabled: **Adapt this to your use case:** ```tsx const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted' +// Deduplicated per request; see "Caching and request deduplication". +const optimizationData = appConsent ? await getServerOptimizationData() : undefined @@ -406,37 +418,47 @@ 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. +Render entries with the isomorphic `OptimizedEntry` from `@contentful/optimization-nextjs/client`. +It resolves the variant on the server for first paint, emits the `data-ctfl-*` tracking attributes +the browser observes, and hydrates for interaction tracking and live updates — one component for +both environments. Because the provider is seeded with `serverOptimizationState`, `OptimizedEntry` +resolves the same variant on the server and the client without a hydration mismatch. -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 +1. Render `OptimizedEntry` with the baseline entry; it reads the current `selectedOptimizations` + from provider context. +2. 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 +3. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only when an entry needs per-element tracking behavior. **Adapt this to your use case:** ```tsx - -

{resolvedData.entry.fields.title}

-
+import { OptimizedEntry } from '@contentful/optimization-nextjs/client' + +function ArticleEntry({ baselineEntry }: { baselineEntry: Entry }) { + return ( + + {(entry) =>

{String(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: +> [!NOTE] +> +> `OptimizedEntry` is the default for entry rendering. Use `ServerOnlyOptimizedEntry` when you need +> a pure Server Component with zero client JavaScript for the entry and no live updates, resolving +> `resolvedData` yourself on the server. + +Use the lower-level `getServerTrackingAttributes()` helper when a design-system component must own +the wrapper markup and forward the `data-ctfl-*` attributes to the DOM element the browser SDK +observes: **Adapt this to your use case:** @@ -584,8 +606,8 @@ 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 `cookies()`, `headers()`, `getNextjsServerOptimizationData()`, and the provider's +`serverOptimizationState` handoff 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 +643,17 @@ export async function GET(request: Request) { **Integration category:** Advanced or production-only -The SSR pattern keeps `ServerOptimizedEntry` 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. +The SSR pattern keeps primary 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()`, or preview-panel changes do not rewrite that +server-rendered markup in place unless the entry opts into live updates. -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. +`OptimizedEntry` defaults to this server-first behavior: it renders the server-resolved variant and +holds it after hydration. Opt a specific entry into browser reactivity with `liveUpdates`, or add +browser-owned islands with `useOptimizedEntry()` or `LiveUpdatesProvider`, and treat those as +browser-owned after hydration. This is useful for secondary widgets, preview/editor tools, or +content blocks where live-update 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 @@ -670,7 +693,7 @@ Before releasing a Next.js SSR integration, verify these checks: | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | 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 | +| Entry view, click, or hover events do not appear | Missing `data-ctfl-*` attributes, opted-out `trackEntryInteraction`, or denied browser consent | Render `OptimizedEntry`, 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 | @@ -680,5 +703,5 @@ Before releasing a Next.js SSR integration, verify these checks: - [`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. + Use it to compare proxy request context forwarding, server entry resolution, the + `serverOptimizationState` handoff, App Router layout tracking, and browser controls. diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md index 8dde2705a..4c1e166bd 100644 --- a/implementations/nextjs-sdk_hybrid/README.md +++ b/implementations/nextjs-sdk_hybrid/README.md @@ -40,14 +40,16 @@ and Optimization state, then the browser SDK resolves entries and owns reactive handoff. It demonstrates: - Server request context forwarding through proxy -- Server-to-browser state handoff through `NextjsOptimizationState` -- Browser-side entry resolution with `OptimizedEntry` after browser startup +- Server-to-browser state handoff through `serverOptimizationState` on `OptimizationRoot` +- Isomorphic entry rendering with `OptimizedEntry`, which resolves on the server and re-resolves in + the browser after startup - 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 - 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. +browser through the provider's `serverOptimizationState` prop, and lets the browser SDK own entry +re-resolution and reactive updates after startup. ## Architecture @@ -62,17 +64,18 @@ First request getOptimizationData() calls getNextjsServerOptimizationData() with cookies() and headers() - app/page.tsx and app/page-two/page.tsx - fetch CDA entries server-side - render NextjsOptimizationState with server Optimization data - - app/layout.tsx + app/layout.tsx (Server Component) owns one OptimizationRoot for browser takeover and route tracking + passes serverOptimizationState={await getOptimizationData()} + provider renders personalized state on the server and hydrates the live SDK + + app/page.tsx and app/page-two/page.tsx + fetch CDA entries server-side and render them through OptimizedEntry Browser runtime - NextjsOptimizationState hydrates Optimization data into the nearest runtime + provider swaps the snapshot runtime for the live browser SDK NextAppAutoPageTracker emits route page events - OptimizedEntry resolves entries from current selectedOptimizations + OptimizedEntry re-resolves entries from current selectedOptimizations LiveUpdatesProvider controls reactive re-resolution ``` @@ -155,7 +158,7 @@ The E2E suite reuses the shared `lib/e2e-web` browser scenarios for CSR and hydr 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. +Next.js adapter request-context, `ServerOnlyOptimizedEntry`, and initial page-event helper behavior. 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..61f35d738 100644 --- a/implementations/nextjs-sdk_hybrid/app/layout.tsx +++ b/implementations/nextjs-sdk_hybrid/app/layout.tsx @@ -2,6 +2,7 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { getOptimizationData } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' @@ -29,6 +30,9 @@ export default async function RootLayout({ const cookieStore = await cookies() const appConsent = getAppConsent(cookieStore) const htmlLang = getHtmlLang(appConfig.locale) + // Seed the provider with server-resolved state (deduplicated per request with + // the pages) so first paint is personalized before the browser takes over. + const serverOptimizationState = await getOptimizationData() return ( @@ -45,6 +49,7 @@ export default async function RootLayout({ version: '0.1.0', }} defaults={{ consent: appConsent, persistenceConsent: appConsent }} + serverOptimizationState={serverOptimizationState} > diff --git a/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx b/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx index 5f780bfd2..1cf456d77 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() { -
diff --git a/implementations/nextjs-sdk_hybrid/app/page.tsx b/implementations/nextjs-sdk_hybrid/app/page.tsx index 1e0e294c1..c53ec176b 100644 --- a/implementations/nextjs-sdk_hybrid/app/page.tsx +++ b/implementations/nextjs-sdk_hybrid/app/page.tsx @@ -1,18 +1,16 @@ import { ControlPanel } from '@/components/ControlPanel' 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 { 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(), - ]) + // The layout seeds the provider with serverOptimizationState, and EntryCard's + // OptimizedEntry resolves variants from context, so the page only needs the + // baseline entries. + const entries = await loadPageEntries(PAGES.home.ids) const entriesById = toIdMap(entries) const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates) @@ -27,7 +25,6 @@ export default async function Home() {
-
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..4592ea6f0 100644 --- a/implementations/nextjs-sdk_hybrid/lib/hooks.ts +++ b/implementations/nextjs-sdk_hybrid/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) 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_hybrid/lib/optimization.ts b/implementations/nextjs-sdk_hybrid/lib/optimization.ts index 48015e8da..3c95ac314 100644 --- a/implementations/nextjs-sdk_hybrid/lib/optimization.ts +++ b/implementations/nextjs-sdk_hybrid/lib/optimization.ts @@ -24,10 +24,15 @@ export const getOptimizationData = cache(async () => { const cookieStore = await cookies() const headerStore = await headers() - if (!getAppConsent(cookieStore)) return undefined + // Resolve on the server regardless of consent so first-paint SSR matches the + // browser SDK, which resolves the initial `page()` before consent by default + // (`allowedEventTypes` includes `page`). Consent gates event emission and + // profile persistence — not experience resolution — so pass the real consent + // state rather than short-circuiting to baseline. + const consented = getAppConsent(cookieStore) const { data } = await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, + consent: { events: consented, persistence: consented }, cookies: cookieStore, headers: headerStore, locale: appConfig.locale, diff --git a/implementations/nextjs-sdk_ssr/.env.example b/implementations/nextjs-sdk_ssr/.env.example index 07e791524..09257f777 100644 --- a/implementations/nextjs-sdk_ssr/.env.example +++ b/implementations/nextjs-sdk_ssr/.env.example @@ -1,7 +1,8 @@ DOTENV_CONFIG_QUIET=true # SKIP_NO_JS skips the JavaScript-disabled variant resolution suite (SSR, JavaScript disabled). -E2E_FLAGS=SSR,SKIP_NO_JS +# The provider renders personalized state server-side, so the JS-disabled suite runs by default. +E2E_FLAGS=SSR APP_PORT=3001 PUBLIC_NINETAILED_CLIENT_ID="mock-client-id" diff --git a/implementations/nextjs-sdk_ssr/README.md b/implementations/nextjs-sdk_ssr/README.md index caebe6f96..22251be1d 100644 --- a/implementations/nextjs-sdk_ssr/README.md +++ b/implementations/nextjs-sdk_ssr/README.md @@ -39,17 +39,19 @@ 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` +- Server-rendered personalized first paint with `getNextjsServerOptimizationData()`, deduplicated + per request with a React `cache()` helper (`getServerOptimizationData` in `lib/optimization.ts`) +- Seeding `OptimizationRoot` with `serverOptimizationState` in the layout so the provider renders + identified and personalized state on the server, correct even with JavaScript disabled +- Server-rendered entries through the isomorphic `OptimizedEntry` - 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` - `initialPageEvent="skip"` when the server already resolved the same initial page -In this SSR pattern, content rendered through `ServerOptimizedEntry` 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. +In this SSR pattern, `OptimizedEntry` renders the server-resolved variant and holds it after +hydration. 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 opts specific entries into `liveUpdates` to demonstrate browser-side re-resolution. ## Architecture @@ -59,24 +61,27 @@ Request createNextjsOptimizationContextHandler() forwards sanitized request URL context for Server Components - 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 + lib/optimization.ts + getServerOptimizationData() — React cache() + calls getNextjsServerOptimizationData() with cookies() and headers() + one Experience page() call per request, shared by layout and pages -Browser startup - app/layout.tsx + app/layout.tsx (Server Component) mounts OptimizationRoot from @contentful/optimization-nextjs/client + passes serverOptimizationState={await getServerOptimizationData()} + provider renders personalized state on the server and hydrates the live SDK passes initialPageEvent="skip" only after consented server page resolution - browser tracking - NextAppAutoPageTracker handles route page events - default interaction observers read data-ctfl-* attributes + app/page.tsx + fetches CDA entries + reads the same getServerOptimizationData() (deduplicated) + renders entries through the isomorphic OptimizedEntry - client live updates island - LiveEntryCard uses OptimizedEntry for browser-side re-resolution examples +Browser startup + provider swaps the snapshot runtime for the live browser SDK + NextAppAutoPageTracker handles route page events + default interaction observers read data-ctfl-* attributes + entries opted into liveUpdates re-resolve in the browser ``` ## CDA locale handling @@ -140,10 +145,11 @@ 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 +The SSR E2E run uses `E2E_FLAGS=SSR` from `.env.example`. It runs the shared navigation, tracking, +and live updates specs against SSR-rendered markup, plus the SSR first-paint and JavaScript-disabled +variant-resolution blocks — the provider renders personalized state on the server, so those pass +without JavaScript. 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..f6f442fb7 100644 --- a/implementations/nextjs-sdk_ssr/app/layout.tsx +++ b/implementations/nextjs-sdk_ssr/app/layout.tsx @@ -2,6 +2,7 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { getServerOptimizationData } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' @@ -20,6 +21,11 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac const cookieStore = await cookies() const appConsent = getAppConsent(cookieStore) + // Resolve the request-scoped optimization state on the server so the + // isomorphic provider renders identified/personalized first-paint state even + // with JavaScript disabled. Deduplicated per request with the page components. + const serverOptimizationState = await getServerOptimizationData() + return ( @@ -35,6 +41,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac version: '0.1.0', }} defaults={{ consent: appConsent, persistenceConsent: appConsent }} + serverOptimizationState={serverOptimizationState} > diff --git a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx index 8de467ce3..936aed7d6 100644 --- a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx @@ -1,40 +1,19 @@ 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, - ]) + // EntryCard's OptimizedEntry resolves variants from provider context, so the + // page only needs the baseline entries. + 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 +26,6 @@ export default async function PageTwo() { -
@@ -55,12 +33,8 @@ export default async function PageTwo() {

Auto-observed

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

Auto tracked entry is unavailable.

)} @@ -72,12 +46,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..bff15acbf 100644 --- a/implementations/nextjs-sdk_ssr/app/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page.tsx @@ -1,47 +1,17 @@ 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, - ]) + // Entries are fetched server-side; the isomorphic OptimizedEntry (inside + // EntryCard) resolves the variant from provider context on both server and + // client, so the page no longer pre-resolves or threads resolved data. + 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 +25,6 @@ export default async function Home() {
-
@@ -95,17 +64,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 +84,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..c84784ac0 100644 --- a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx +++ b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx @@ -1,13 +1,12 @@ +'use client' + 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 { 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 { EntryClickScenario } from 'e2e-web' @@ -20,41 +19,38 @@ 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 { +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) ?? '' + return getMergeTagValue(data.target) ?? '' }, }, } } -export function EntryCard({ +function EntryContent({ baselineEntry, clickScenario, - getMergeTagValue, - manualTracking, - resolveEntry, - resolvedData, -}: EntryCardProps): JSX.Element { - const resolvedEntry = resolvedData.entry as ContentEntry - const autoTrackViews = !manualTracking + resolvedEntry, +}: { + baselineEntry: ContentEntry + clickScenario?: EntryClickScenario + resolvedEntry: ContentEntry +}): JSX.Element { + const { getMergeTagValue } = useMergeTagResolver() 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 renderOptions = buildRenderOptions((entry) => getMergeTagValue(entry as never)) - const content = ( + return (
( ))}
) : null}
) +} + +export function EntryCard({ + baselineEntry, + clickScenario, + manualTracking, +}: EntryCardProps): JSX.Element { + const autoTrackViews = !manualTracking return (
- - {autoTrackViews && clickScenario === 'ancestor' ? ( -
- {content} -
- ) : ( - content - )} -
+ {(resolvedEntry) => + autoTrackViews && clickScenario === 'ancestor' ? ( +
+ +
+ ) : ( + + ) + } +
) } 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..4592ea6f0 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) 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..4b26bcbfc 100644 --- a/implementations/nextjs-sdk_ssr/lib/optimization.ts +++ b/implementations/nextjs-sdk_ssr/lib/optimization.ts @@ -1,5 +1,12 @@ -import { createNextjsOptimization } from '@contentful/optimization-nextjs/server' +import type { OptimizationData } from '@contentful/optimization-nextjs/server' +import { + createNextjsOptimization, + getNextjsServerOptimizationData, +} from '@contentful/optimization-nextjs/server' +import { cookies, headers } from 'next/headers' +import { cache } from 'react' import { appConfig } from './config' +import { getAppConsent } from './util' export const optimization = createNextjsOptimization({ clientId: appConfig.clientId, @@ -12,3 +19,31 @@ export const optimization = createNextjsOptimization({ version: '0.1.0', }, }) + +/** + * Resolve the request-scoped Optimization data once per request. + * + * The root layout and the page both need this data — the layout to seed the + * isomorphic provider, the page to resolve entry variants. `cache` deduplicates + * the call across the request so exactly one Experience `page()` event is + * emitted, regardless of how many server components ask for it. + */ +export const getServerOptimizationData = cache(async (): Promise => { + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) + + // Resolve on the server regardless of consent so first-paint SSR matches the + // browser SDK, which resolves the initial `page()` before consent by default + // (`allowedEventTypes` includes `page`). Consent gates event emission and + // profile persistence — not experience resolution — so pass the real consent + // state rather than short-circuiting to baseline. + const consented = getAppConsent(cookieStore) + + const { data } = await getNextjsServerOptimizationData(optimization, { + consent: { events: consented, persistence: consented }, + cookies: cookieStore, + headers: headerStore, + locale: appConfig.locale, + }) + + return data +}) diff --git a/lib/e2e-web/e2e/ssr.spec.ts b/lib/e2e-web/e2e/ssr.spec.ts index f3bac46a8..9ea065985 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,35 @@ test.describe('SSR first-paint state', () => { await expect(page.getByTestId('identified-status')).toHaveText('Yes') }) }) + + // Regression guard: the server must resolve the optimized variant into the + // first-paint HTML (with JavaScript disabled), matching what the browser SDK + // resolves after hydration. A baseline-only server render — baseline id equal + // to entry id, variant index 0, no optimization id — is the mismatch bug where + // the server short-circuited resolution while the client still personalized. + 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/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index 2d1bb7630..b91c6e23b 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-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" + } + }, "./api-client": { "import": { "types": "./dist/api-client.d.mts", @@ -92,9 +102,11 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 18300, - "index.mjs": 17800, + "index.mjs": 18800, "bridge-support.cjs": 1200, - "bridge-support.mjs": 2100, + "bridge-support.mjs": 2300, + "runtime.cjs": 8000, + "runtime.mjs": 8000, "preview-support.cjs": 5300, "preview-support.mjs": 5500 } diff --git a/packages/universal/core-sdk/rslib.config.ts b/packages/universal/core-sdk/rslib.config.ts index b4410b9f8..bf5fca923 100644 --- a/packages/universal/core-sdk/rslib.config.ts +++ b/packages/universal/core-sdk/rslib.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ logger: './src/logger.ts', constants: './src/constants.ts', 'bridge-support': './src/bridge-support/index.ts', + runtime: './src/runtime/index.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', 'preview-support': './src/preview-support/index.ts', diff --git a/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts new file mode 100644 index 000000000..5bf19b51f --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts @@ -0,0 +1,58 @@ +import type CoreStateful from '../CoreStateful' + +/** + * Members of the stateful core surface consumed by framework layers (React, + * Angular, etc.) that must also be satisfiable by a request-scoped, read-only + * server runtime. + * + * @internal + */ +type OptimizationRuntimeMembers = + // Pure, environment-agnostic resolvers. + | 'resolveOptimizedEntry' + | 'getMergeTagValue' + | 'getFlag' + // Read surface. + | 'states' + | 'locale' + | 'hasConsent' + // Event actions (inert server-side). + | 'identify' + | 'page' + | 'screen' + | 'track' + | 'trackView' + | 'trackClick' + | 'trackHover' + | 'trackFlagView' + // State + lifecycle actions (inert server-side). + | 'consent' + | 'reset' + | 'flush' + | 'setLocale' + | 'destroy' + +/** + * Runtime contract shared by the client-side stateful core and any server-side + * snapshot runtime. + * + * @remarks + * The interface is derived from {@link CoreStateful} with {@link https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys | Pick}, + * so the browser SDK satisfies it by construction and a server implementation + * (see `createSnapshotRuntime`) is forced to match the exact same signatures. + * This is the single seam a framework provider binds to: it renders children + * from either backing without branching on environment. + * + * The three capability tiers behave differently by environment: + * + * - **Resolve** (`resolveOptimizedEntry`, `getMergeTagValue`, `getFlag`) — pure + * functions; identical on server and client. + * - **Read** (`states`) — live signals on the client; static, request-scoped + * observables on the server. + * - **Actions** (`identify`, `page`, `screen`, `track`, `consent`, `reset`, + * `flush`, `setLocale`) — real side effects on the client; inert no-ops on the + * server, where there is no user interaction to track. + * + * @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..7158b5d4b --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts @@ -0,0 +1,174 @@ +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 = { + profile, + selectedOptimizations, + changes: [ + { + type: 'Variable', + key: 'theme', + value: 'dark', + meta: { experienceId: 'exp-theme', variantIndex: 1 }, + }, + ], +} + +describe('SnapshotRuntime', () => { + describe('resolve surface', () => { + it('resolves the optimized variant using the snapshot selectedOptimizations', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + const fromSnapshot = runtime.resolveOptimizedEntry(optimizedEntry) + const fromExplicit = runtime.resolveOptimizedEntry(optimizedEntry, selectedOptimizations) + + expect(fromSnapshot.selectedOptimization).toBeDefined() + expect(fromSnapshot.entry.sys.id).toBe(fromExplicit.entry.sys.id) + expect(fromSnapshot.selectedOptimization).toEqual(fromExplicit.selectedOptimization) + }) + + it('returns the baseline entry when the snapshot has no optimizations', () => { + const runtime = createSnapshotRuntime() + + const result = runtime.resolveOptimizedEntry(optimizedEntry) + + expect(result.entry).toBe(optimizedEntry) + expect(result.selectedOptimization).toBeUndefined() + }) + + it('resolves merge tags against the snapshot profile', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('EU') + }) + + it('falls back to the merge-tag fallback without a profile', () => { + const runtime = createSnapshotRuntime() + + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('Nowhere') + }) + + it('resolves flags from the snapshot changes', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.getFlag('theme')).toBe('dark') + expect(runtime.getFlag('missing')).toBeUndefined() + }) + }) + + describe('read surface', () => { + it('exposes static observables seeded from the snapshot', () => { + const runtime = createSnapshotRuntime({ + data: snapshotData, + consent: true, + locale: 'de-DE', + }) + + expect(runtime.states.consent.current).toBe(true) + expect(runtime.states.locale.current).toBe('de-DE') + 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.locale).toBe('de-DE') + expect(runtime.hasConsent('track')).toBe(true) + }) + + it('reports canOptimize false and a settled request state without server data', () => { + const runtime = createSnapshotRuntime() + + expect(runtime.states.canOptimize.current).toBe(false) + // A snapshot is always settled — no experience request is in flight for the + // render it backs — so consumers present content instead of a loading state. + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'success' }) + expect(runtime.states.profile.current).toBeUndefined() + expect(runtime.hasConsent('track')).toBe(false) + }) + + it('reports optimizationPossible false when consent is absent and nothing is allow-listed', () => { + const runtime = createSnapshotRuntime({ consent: false, allowedEventTypes: [] }) + + expect(runtime.states.optimizationPossible.current).toBe(false) + }) + + it('reports optimizationPossible true when an unlocking event type is allow-listed pre-consent', () => { + const runtime = createSnapshotRuntime({ consent: false, allowedEventTypes: ['page'] }) + + expect(runtime.states.optimizationPossible.current).toBe(true) + }) + + it('resolves hasConsent through the allow-list when consent is not granted', () => { + const runtime = createSnapshotRuntime({ + consent: false, + allowedEventTypes: ['page', 'component'], + }) + + // `page` is allow-listed directly; `trackView` maps to the `component` + // selector, which is allow-listed; `trackClick` maps to `component_click`, + // which is not. + expect(runtime.hasConsent('page')).toBe(true) + expect(runtime.hasConsent('trackView')).toBe(true) + expect(runtime.hasConsent('trackClick')).toBe(false) + }) + + it('grants every hasConsent check when consent is true regardless of allow-list', () => { + const runtime = createSnapshotRuntime({ consent: true, allowedEventTypes: [] }) + + expect(runtime.hasConsent('trackClick')).toBe(true) + expect(runtime.states.optimizationPossible.current).toBe(true) + }) + + it('emits each state value once on subscribe', () => { + const runtime = createSnapshotRuntime({ data: snapshotData, consent: true }) + const received: Array = [] + + const subscription = runtime.states.consent.subscribe((value) => { + received.push(value) + }) + + expect(received).toEqual([true]) + subscription.unsubscribe() + }) + + it('defaults preview panel state to closed', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.states.previewPanelAttached.current).toBe(false) + expect(runtime.states.previewPanelOpen.current).toBe(false) + }) + }) + + describe('inert actions', () => { + it('treats event actions as accepted:false 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.trackView({ componentId: 'component-1', viewId: 'view-1', viewDurationMs: 100 }), + ).resolves.toEqual({ accepted: false }) + }) + + it('treats state and lifecycle actions as no-ops that do not throw', async () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(() => { + runtime.consent(true) + }).not.toThrow() + expect(() => { + runtime.reset() + }).not.toThrow() + expect(() => { + runtime.destroy() + }).not.toThrow() + expect(runtime.setLocale('en-US')).toBeUndefined() + await expect(runtime.flush()).resolves.toBeUndefined() + }) + }) +}) 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..f96dd37d5 --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts @@ -0,0 +1,309 @@ +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 AllowedEventType, + DEFAULT_ALLOWED_EVENT_TYPES, + type EventEmissionResult, +} from '../events' +import type { ResolvedData } from '../resolvers' +import { FlagsResolver, MergeTagValueResolver, OptimizedEntryResolver } from '../resolvers' +import { staticObservable } from '../signals' +import type { OptimizationRuntime } from './OptimizationRuntime' + +const logger = createScopedLogger('Optimization:SnapshotRuntime') + +// Mirrors the live SDK's allow-list-aware consent gating (see +// CoreStatefulEventEmitter.hasConsent and CoreStateful's optimizationPossibleSignal) +// so the snapshot resolves the same values before and after hydration. +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)) +} + +/** + * Request-scoped, read-only state used to seed a {@link SnapshotRuntime}. + * + * @remarks + * Mirrors the serializable server-to-client handoff shape. `data` is the + * resolved {@link OptimizationData} the server computed for the request; + * `consent` and `locale` carry the request's policy so read hooks observe the + * same values the live client SDK will report after hydration. + * + * @public + */ +export interface OptimizationSnapshot { + /** Optimization data resolved server-side for the current request. */ + readonly data?: OptimizationData + /** Event consent for the request, if known. */ + readonly consent?: boolean + /** Profile-continuity persistence consent for the request, if known. */ + readonly persistenceConsent?: boolean + /** Active locale for the request, if known. */ + readonly locale?: string + /** + * Event types admitted before consent is granted. + * + * @remarks + * Carries the same value the live SDK is configured with, so the snapshot + * resolves `hasConsent()` and `optimizationPossible` identically before and + * after hydration. Defaults to {@link DEFAULT_ALLOWED_EVENT_TYPES} (fail + * closed) when the host does not supply it. + */ + readonly allowedEventTypes?: readonly AllowedEventType[] +} + +/** + * Reason logged when an action method is invoked on the read-only runtime. + * + * @internal + */ +const INERT_ACTION_WARNING = + 'Optimization action called on the server (read-only runtime); it is a no-op. ' + + 'Actions such as identify/page/track only run in the browser after hydration.' + +const ACCEPTED_NOOP_RESULT: EventEmissionResult = { accepted: false } + +/** + * A read-only {@link OptimizationRuntime} backed by a request-scoped snapshot. + * + * @remarks + * Used during server-side rendering and the initial client render, before the + * live stateful SDK exists. It satisfies the exact same interface the browser + * SDK does, so a framework provider can render children from either backing + * without branching on environment: + * + * - **Resolve** methods delegate to the shared static resolvers — identical + * behavior to the browser SDK, with no browser globals and no API client. + * - **`states`** are static observables over the snapshot: `current` returns the + * snapshot value and `subscribe` emits it once and never changes, so + * `useSyncExternalStore` server snapshots stay stable. + * - **Actions** are inert no-ops that warn in development. There is no request, + * queue, or user interaction to service on the server. + * + * Create instances with {@link createSnapshotRuntime}. + * + * @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[] + + 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 + + const canOptimize = this.currentSelectedOptimizations !== undefined + // Match the live SDK's allow-list-aware gating so the value does not flip + // when the browser SDK hydrates and subscribes. + const optimizationPossible = resolveOptimizationPossible( + snapshot.consent, + this.allowedEventTypes, + ) + + this.states = { + blockedEventStream: staticObservable(undefined), + consent: staticObservable(snapshot.consent), + persistenceConsent: staticObservable(snapshot.persistenceConsent ?? snapshot.consent), + eventStream: staticObservable(undefined), + locale: staticObservable(snapshot.locale), + canOptimize: staticObservable(canOptimize), + optimizationPossible: staticObservable(optimizationPossible), + // A snapshot is a settled, request-scoped result: no experience request is + // in flight for the render it backs (server render and the client's first + // render). Report `success` so consumers such as OptimizedEntry present + // resolved-or-baseline content instead of a loading state — and so the + // server render matches the client's first render before hydration. + 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 { + // No-op: the 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 {@link OptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves entries/merge tags/flags from the snapshot, + * exposes static state observables, and treats actions as no-ops. + * + * @example + * ```ts + * const runtime = createSnapshotRuntime({ data: serverOptimizationData, consent: true }) + * const { entry } = runtime.resolveOptimizedEntry(baselineEntry) + * ``` + * + * @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..35397c4f9 --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/index.ts @@ -0,0 +1,6 @@ +export type { OptimizationRuntime } from './OptimizationRuntime' +export { + createSnapshotRuntime, + type OptimizationSnapshot, + type SnapshotRuntime, +} from './SnapshotRuntime' diff --git a/packages/universal/core-sdk/src/signals/Observable.test.ts b/packages/universal/core-sdk/src/signals/Observable.test.ts index 1db3d123a..e78c288fa 100644 --- a/packages/universal/core-sdk/src/signals/Observable.test.ts +++ b/packages/universal/core-sdk/src/signals/Observable.test.ts @@ -1,5 +1,39 @@ import { signal } from '@preact/signals-core' -import { toObservable } from './Observable' +import { staticObservable, toObservable } from './Observable' + +describe('staticObservable', () => { + it('exposes the constant value as current', () => { + expect(staticObservable('value').current).toBe('value') + expect(staticObservable(undefined).current).toBeUndefined() + }) + + it('emits the value once on subscribe and never again', () => { + const received: number[] = [] + const subscription = staticObservable(42).subscribe((value) => { + received.push(value) + }) + + expect(received).toEqual([42]) + + // No mechanism can change a static value, so the callback never fires again. + subscription.unsubscribe() + expect(received).toEqual([42]) + }) + + it('emits non-nullish values through subscribeOnce and skips nullish ones', () => { + const receivedValue: number[] = [] + staticObservable(7).subscribeOnce((value) => { + receivedValue.push(value) + }) + expect(receivedValue).toEqual([7]) + + const receivedNullish: unknown[] = [] + staticObservable(undefined).subscribeOnce((value) => { + receivedNullish.push(value) + }) + expect(receivedNullish).toEqual([]) + }) +}) describe('Observable helpers', () => { it('exposes current as a deep-cloned snapshot of the signal value', () => { diff --git a/packages/universal/core-sdk/src/signals/Observable.ts b/packages/universal/core-sdk/src/signals/Observable.ts index b1348d3c6..f628cc039 100644 --- a/packages/universal/core-sdk/src/signals/Observable.ts +++ b/packages/universal/core-sdk/src/signals/Observable.ts @@ -55,6 +55,41 @@ 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. + * + * @remarks + * Use this for read-only, request-scoped state surfaces such as server-side + * rendering, where a snapshot is known up front and cannot update. `subscribe` + * invokes `next(value)` synchronously and returns a no-op {@link Subscription}; + * `subscribeOnce` invokes `next(value)` synchronously only when `value` is + * non-nullish. Unlike {@link toObservable}, no cloning is performed — the caller + * owns snapshot isolation for the constant value. + * + * @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..9facf46de 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -45,21 +45,24 @@ already installed by your app instead of installing its own copy. ## Server setup +Resolve the request Optimization data in a Server Component. Wrap it in React `cache()` so the +layout and pages share one call per request. Entry resolution is pure and runs on the server. + ```tsx import { - ServerOptimizedEntry, createNextjsOptimization, getNextjsServerOptimizationData, } from '@contentful/optimization-nextjs/server' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { cookies, headers } from 'next/headers' +import { cache } from 'react' -const sdk = createNextjsOptimization({ +export const sdk = createNextjsOptimization({ clientId: 'client-id', environment: 'main', }) -export default async function Page() { +// One Experience `page()` call per request, shared across server components. +export const getServerOptimizationData = cache(async () => { const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) const { data } = await getNextjsServerOptimizationData(sdk, { consent: { events: true, persistence: true }, @@ -67,32 +70,32 @@ export default async function Page() { headers: headerStore, locale: 'en-US', }) - - const resolvedData = sdk.resolveOptimizedEntry(entry, data?.selectedOptimizations) - - return ( - <> - - - {resolvedData.entry.fields.title} - - - ) -} + return data +}) ``` -`NextjsOptimizationState` must render under SDK context. That context can come from -`OptimizationRoot` or `OptimizationProvider`, commonly mounted in a shared App Router layout. - ## Client setup +Mount `OptimizationRoot` in the App Router layout and pass the server data through +`serverOptimizationState`. The provider renders personalized state on the server (so the first paint +is correct even without JavaScript) and hydrates the live browser SDK with the same data on the +client. + ```tsx import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' +import { getServerOptimizationData } from '@/lib/optimization' import { Suspense, type ReactNode } from 'react' -export function Providers({ children }: { children: ReactNode }) { +export default async function RootLayout({ children }: { children: ReactNode }) { + const serverOptimizationState = await getServerOptimizationData() + return ( - + @@ -105,31 +108,26 @@ export function Providers({ children }: { children: ReactNode }) { Use `initialPageEvent="skip"` only when the server already called `page()` for the same initial 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: - -```tsx - - {children} - -``` +## Rendering optimized entries -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: +Render entries with the isomorphic `OptimizedEntry`. It resolves the variant on the server for first +paint, emits the tracking attributes the browser observes, and hydrates for interaction tracking and +live updates — one component in both environments. ```tsx - +import { OptimizedEntry } from '@contentful/optimization-nextjs/client' +; + {(resolvedEntry) =>

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

} +
``` -Keep `defaults` for configuration or default state such as consent. Pass server-returned profile, -selected optimizations, and changes through `serverOptimizationState` or `NextjsOptimizationState`. +> [!NOTE] +> +> `OptimizedEntry` is the default for entry rendering. To seed a configuration-only provider and +> hydrate page-specific data later, call `hydrateOptimizationData` from +> `@contentful/optimization-web/bridge-support` inside a Client Component. Use +> `ServerOnlyOptimizedEntry` from `@contentful/optimization-nextjs/server` when you need a pure +> zero-JavaScript Server Component entry with no live updates, resolving `resolvedData` yourself. ## Request context setup diff --git a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx index 7905b9653..750ac6c65 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx @@ -1,10 +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' @@ -15,125 +9,28 @@ type RemovedHydratorExportIsAbsent = RemovedHydratorExportName extends keyof typ ? false : true +type RemovedStateMarkerExportName = 'NextjsOptimizationState' +type RemovedStateMarkerExportIsAbsent = RemovedStateMarkerExportName extends keyof typeof client + ? false + : true + const removedHydratorExportIsAbsent: RemovedHydratorExportIsAbsent = true +const removedStateMarkerExportIsAbsent: RemovedStateMarkerExportIsAbsent = true const removedHydratorExportName = ['Nextjs', 'ServerOptimization', 'Hydrator'].join('') +const removedStateMarkerExportName = ['Nextjs', 'Optimization', 'State'].join('') -const testConfig = { - clientId: 'test-client-id', - environment: 'main', - api: { - insightsBaseUrl: 'http://localhost:8000/insights/', - experienceBaseUrl: 'http://localhost:8000/experience/', - }, -} - -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', () => { +describe('Next.js client subpath', () => { + it('no longer exports the removed server hydrator or state marker APIs', () => { expect(removedHydratorExportIsAbsent).toBe(true) - expect(client.NextjsOptimizationState).toBeTypeOf('function') + expect(removedStateMarkerExportIsAbsent).toBe(true) expect(removedHydratorExportName in client).toBe(false) + expect(removedStateMarkerExportName 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() + it('re-exports the live client surface for provider handoff', () => { + expect(client.OptimizationRoot).toBeTypeOf('function') + expect(client.OptimizationProvider).toBeTypeOf('function') + expect(client.NextAppAutoPageTracker).toBeTypeOf('function') + expect(client.NextPagesAutoPageTracker).toBeTypeOf('function') }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index 1d8c714fb..f9e52588b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -1,10 +1,5 @@ '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 { NextAppAutoPageTracker, @@ -16,17 +11,3 @@ export { type NextPagesAutoPageContext, type NextPagesAutoPageTrackerProps, } from '@contentful/optimization-react-web/router/next-pages' - -export interface NextjsOptimizationStateProps { - readonly data: OptimizationData | undefined -} - -export function NextjsOptimizationState({ data }: NextjsOptimizationStateProps): null { - const sdk = useOptimization() - - useLayoutEffect(() => { - void hydrateOptimizationData(sdk, data) - }, [data, sdk]) - - return null -} 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.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx index 6f90b8ee5..75c865d51 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx @@ -1,6 +1,6 @@ import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - ServerOptimizedEntry, + ServerOnlyOptimizedEntry, bindNextjsOptimizationRequest, createNextjsOptimization, createNextjsPageContext, @@ -415,7 +415,7 @@ describe('Next.js server helpers', () => { }) it('renders a server wrapper with tracking attributes and caller props', () => { - const element = ServerOptimizedEntry({ + const element = ServerOnlyOptimizedEntry({ as: 'article', baselineEntry, children: 'Rendered content', diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index db48a9e3d..38e5f7f17 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -144,7 +144,7 @@ export type NextjsPageContextInput = | NonNullable | CreateNextjsPageContextOptions -type ServerOptimizedEntryOwnProps = +type ServerOnlyOptimizedEntryOwnProps = ServerTrackingAttributeOptions & { readonly as?: TElement readonly baselineEntry: ServerTrackingBaselineEntry @@ -154,11 +154,11 @@ type ServerOptimizedEntryOwnProps type DataCtflAttributeName = `data-ctfl-${string}` -export type ServerOptimizedEntryProps = - ServerOptimizedEntryOwnProps & +export type ServerOnlyOptimizedEntryProps = + ServerOnlyOptimizedEntryOwnProps & Omit< JSX.IntrinsicElements[TElement], - keyof ServerOptimizedEntryOwnProps | DataCtflAttributeName + keyof ServerOnlyOptimizedEntryOwnProps | DataCtflAttributeName > export function createNextjsOptimization(config: OptimizationNodeConfig): ContentfulOptimization { @@ -340,7 +340,24 @@ export function persistNextjsAnonymousId( } } -export function ServerOptimizedEntry({ +/** + * Renders a server-resolved entry in a Server Component with tracking + * attributes and no client JavaScript for the entry itself. + * + * @remarks + * Use this component when you need a pure Server Component that ships zero client + * JavaScript for the entry and no live updates, resolving `resolvedData` + * yourself on the server. For the common case — first-paint server rendering + * plus client-side interactivity and live updates from a single component with + * automatic variant resolution — prefer the isomorphic `OptimizedEntry` from + * `@contentful/optimization-react-web`. + * + * When a design-system component must own the wrapper markup, use the + * lower-level {@link getServerTrackingAttributes} helper directly instead. + * + * @public + */ +export function ServerOnlyOptimizedEntry({ as, baselineEntry, children, @@ -352,7 +369,7 @@ export function ServerOptimizedEntry): ReactElement { +}: ServerOnlyOptimizedEntryProps): ReactElement { const Element = as ?? 'div' const trackingAttributes: ServerTrackingAttributes = getServerTrackingAttributes( baselineEntry, diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 6dac7505e..9a12df745 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -86,6 +86,12 @@ Experience API responses and events need to use the same language:
``` +The provider renders on the server as well as the client. During server-side rendering it renders +children from a read-only snapshot (seeded by `serverOptimizationState` and consent/locale +defaults), then constructs and hydrates the live browser SDK after mount. Hooks and `OptimizedEntry` +work in both environments; event and tracking calls are no-ops during server rendering. See +[Server-side rendering and hydration](../../../../documentation/concepts/server-side-rendering-and-hydration.md). + ## When to use this package Use `@contentful/optimization-react-web` for React browser applications that need provider-based SDK @@ -99,23 +105,23 @@ or custom framework adapters. such as `liveUpdates`, `onStatesReady`, and `serverOptimizationState`. The Web SDK `autoTrackEntryInteraction` option is exposed as the React `trackEntryInteraction` prop. -| Prop | Required? | Default | Description | -| ------------------------- | --------- | --------------------------------------------- | -------------------------------------------------------------------------- | -| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | -| `environment` | No | `'main'` | Contentful environment identifier | -| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | -| `app` | No | `undefined` | Application metadata attached to outgoing event context | -| `locale` | No | `undefined` | SDK Experience API and default event locale | -| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | -| `serverOptimizationState` | No | `undefined` | Server-returned Optimization state to apply before provider children mount | -| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | -| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | -| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | -| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | -| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | -| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | -| `logLevel` | No | `'error'` | Minimum log level for the default console sink | -| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | +| Prop | Required? | Default | Description | +| ------------------------- | --------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | +| `environment` | No | `'main'` | Contentful environment identifier | +| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | +| `app` | No | `undefined` | Application metadata attached to outgoing event context | +| `locale` | No | `undefined` | SDK Experience API and default event locale | +| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | +| `serverOptimizationState` | No | `undefined` | Server-resolved Optimization state; renders personalized state during SSR and hydrates the client SDK | +| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | +| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | +| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | +| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | +| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | +| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | +| `logLevel` | No | `'error'` | Minimum log level for the default console sink | +| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | Use `OptimizationProvider` directly when an application or framework adapter needs direct provider control, including integrations that supply an SDK instance: 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..53ee4743e 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,28 @@ -import type ContentfulOptimization from '@contentful/optimization-web' import { createContext } from 'react' -export type OptimizationSdk = ContentfulOptimization +import type { WebOptimizationRuntime } from '../runtime/webRuntime' + +/** + * The runtime object exposed to React consumers. + * + * @remarks + * On the client after hydration this is the live `ContentfulOptimization` + * instance; during server rendering and the initial client render it is a + * read-only snapshot runtime with the same shape. Both satisfy + * {@link WebOptimizationRuntime}, so consumers never branch on environment. + * + * @public + */ +export type OptimizationSdk = WebOptimizationRuntime export interface OptimizationContextValue { + /** + * The isomorphic Optimization runtime. Always present once a provider is + * mounted: a snapshot-backed runtime on the server and initial client render, + * the live SDK after hydration. Consumers use the same object in every + * environment. + */ 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..bee1a82fb 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,12 @@ 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('is satisfied by the concrete Web SDK runtime', () => { + // The live ContentfulOptimization instance is assignable to OptimizationSdk + // (verified by acceptReactWebSdk type-checking). OptimizationSdk is the + // broader isomorphic runtime surface, so the reverse is intentionally not + // guaranteed — a snapshot runtime also satisfies it on the server. 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..275b19c66 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts @@ -24,24 +24,29 @@ export function useOptimizationContext(): OptimizationContextValue { } /** - * Returns the initialized Contentful Optimization SDK instance. + * Returns the Contentful Optimization runtime. + * + * @remarks + * The returned object is isomorphic and always present once a provider is + * mounted: a read-only snapshot runtime during server rendering and the initial + * client render, and the live SDK after hydration. It is safe to call in any + * environment — reads and entry resolution work everywhere; event and + * browser-only tracking calls are no-ops on the server. Throws only when used + * outside an {@link OptimizationProvider}. * * @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 getMissingProviderError() } 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..13984ffd4 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,13 @@ function useObservableState(observable: ObservableLike): T { const getSnapshot = useCallback(() => snapshotRef.current, []) - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + // On the server (and the initial hydration render) React uses the third + // argument. Read directly from the observable's current value so the server + // snapshot matches the value the client is seeded with, avoiding hydration + // mismatches. The observable is a static, request-scoped snapshot server-side. + const getServerSnapshot = useCallback(() => observable.current, [observable]) + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } /** 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..bce951a2c 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -141,7 +141,7 @@ describe('@contentful/optimization-react-web core providers', () => { withoutLocale.unmount() }) - it('does not create an owned optimization instance during server render', () => { + it('renders children on the server without creating an owned optimization instance', () => { let renderedChild = false function Probe(): null { @@ -149,7 +149,7 @@ describe('@contentful/optimization-react-web core providers', () => { return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(renderedChild).toBe(false) + // The isomorphic provider renders children on the server (backed by a + // snapshot runtime) but never constructs the live browser SDK there. + expect(renderedChild).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -222,9 +223,7 @@ describe('@contentful/optimization-react-web core providers', () => { } renderToString( - + , ) @@ -232,7 +231,6 @@ describe('@contentful/optimization-react-web core providers', () => { expect(capturedContext).toEqual( expect.objectContaining({ sdk: undefined, - isReady: false, error: initializationError, }), ) @@ -246,7 +244,7 @@ describe('@contentful/optimization-react-web core providers', () => { const capturedError = captureRenderError( , @@ -316,7 +314,7 @@ describe('@contentful/optimization-react-web core providers', () => { }) renderToString( - + , ) @@ -425,13 +423,13 @@ describe('@contentful/optimization-react-web core providers', () => { }) const rendered = renderClient( - + , ) rendered.rerender( - + , ) @@ -507,12 +505,14 @@ describe('@contentful/optimization-react-web core providers', () => { }) it('supports live updates fallback semantics for dependent components', () => { - const results: boolean[] = [] + // The isomorphic provider renders children once from the snapshot runtime and + // again after swapping to the live SDK, so capture the resolved value per + // labeled component rather than asserting a fixed render count. + const resolved: Record = {} - function Probe({ liveUpdates }: { liveUpdates?: boolean }): null { + function Probe({ label, liveUpdates }: { label: string; liveUpdates?: boolean }): null { const context = useLiveUpdates() - const isLive = liveUpdates ?? context.globalLiveUpdates - results.push(isLive) + resolved[label] = liveUpdates ?? context.globalLiveUpdates return null } @@ -524,8 +524,8 @@ describe('@contentful/optimization-react-web core providers', () => { api={testConfig.api} liveUpdates={true} > - - + +
) } @@ -538,7 +538,7 @@ describe('@contentful/optimization-react-web core providers', () => { api={testConfig.api} liveUpdates={false} > - + ) } @@ -546,7 +546,11 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - expect(results).toEqual([true, false, true]) + expect(resolved).toEqual({ + 'global-live': true, + 'override-off': false, + 'override-on': true, + }) }) it('destroys the optimization singleton on provider unmount', () => { 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..99df73cdc 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 @@ -604,7 +604,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 +616,10 @@ describe('OptimizedEntry', () => { ), ) - expect(markup).toContain('data-ctfl-loading-layout-target="true"') - expect(markup).toContain('visibility:hidden') + // The provider context is ready, so the entry presents its resolved content + // during SSR rather than the hidden loading target. 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/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index 988354e7d..3da9f6678 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 @@ -47,9 +47,16 @@ export function useOptimizedEntrySnapshot({ trackViews, viewDurationUpdateIntervalMs, }: UseOptimizedEntrySnapshotParams): OptimizedEntrySnapshot { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const liveUpdatesContext = useLiveUpdates() - const [isPresentationReady, setIsPresentationReady] = useState(false) + // The isomorphic runtime is present from the initial render (snapshot on the + // server, live SDK after hydration), so SDK-state readiness tracks its + // presence. + const isSdkStateReady = sdk !== undefined + // Seed from readiness so the server render (and the first client render) + // presents resolved content instead of the loading state; effects, which do + // not run on the server, would otherwise leave this false during SSR. + const [isPresentationReady, setIsPresentationReady] = useState(isSdkStateReady) const controllerOptions = useMemo( () => ({ @@ -60,7 +67,7 @@ export function useOptimizedEntrySnapshot({ hasCustomLoadingFallback, isPreviewPanelOpen: liveUpdatesContext.previewPanelVisible, sdk, - isSdkStateReady: isReady, + isSdkStateReady, targetDisplay, clickable, hoverDurationUpdateIntervalMs, @@ -75,7 +82,7 @@ export function useOptimizedEntrySnapshot({ clickable, hasCustomLoadingFallback, hoverDurationUpdateIntervalMs, - isReady, + isSdkStateReady, liveUpdates, liveUpdatesContext.globalLiveUpdates, liveUpdatesContext.previewPanelVisible, @@ -91,8 +98,8 @@ export function useOptimizedEntrySnapshot({ const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()) useEffect(() => { - setIsPresentationReady(isReady) - }, [isReady]) + setIsPresentationReady(isSdkStateReady) + }, [isSdkStateReady]) useEffect(() => { controller.setSnapshotListener(setSnapshot) 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('applies serverOptimizationState to owned SDK instances for onStatesReady and child render', async () => { const serverOptimizationState = createServerOptimizationState('owned-server-profile') const setupOrder: string[] = [] let profileFromOnStatesReady: OptimizationData['profile'] | undefined = undefined @@ -250,7 +250,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + // The child renders first from the server snapshot, then the mount effect + // runs onStatesReady on the live SDK and the child re-renders against it. + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) expect(profileFromChild).toEqual(serverOptimizationState.profile) rendered.unmount() @@ -277,7 +279,7 @@ describe('OptimizationProvider onStatesReady', () => { sdk.destroy() }) - it('applies serverOptimizationState to injected SDK instances before onStatesReady and child render', async () => { + it('applies serverOptimizationState to injected SDK instances for onStatesReady and child render', async () => { const serverOptimizationState = createServerOptimizationState('injected-ready-profile') const sdk = new ContentfulOptimization(testConfig) const setupOrder: string[] = [] @@ -303,7 +305,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + // The child renders first from the server snapshot, then the mount effect + // hydrates the injected SDK, runs onStatesReady, and the child re-renders. + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) expect(profileFromChild).toEqual(serverOptimizationState.profile) rendered.unmount() @@ -334,15 +338,17 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not construct owned sdk instances during server render', () => { + it('renders children from a snapshot runtime during server render without constructing the owned sdk', () => { let childRendered = false + let sdkPresentDuringRender = false function Probe(): null { childRendered = true + sdkPresentDuringRender = useOptimizationContext().sdk !== undefined return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + // Children render on the server (the provider is isomorphic), backed by a + // read-only snapshot runtime, but the live browser SDK is never constructed. + expect(childRendered).toBe(true) + expect(sdkPresentDuringRender).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -407,24 +415,29 @@ 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 while onStatesReady defers to the client', () => { const sdk = createOptimizationSdk() const onStatesReady = rs.fn() let childRendered = false + let capturedSdk: ReturnType | undefined = undefined function Probe(): null { childRendered = true + capturedSdk = useOptimization() return null } - const markup = renderToString( + renderToString( , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + // The injected SDK backs the server render directly (no serverOptimizationState + // to paint first), so children render against it. The onStatesReady side + // effect defers to the client mount effect and does not run during SSR. + expect(childRendered).toBe(true) + expect(capturedSdk).toBe(sdk) expect(onStatesReady).not.toHaveBeenCalled() }) @@ -454,7 +467,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.ssr.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx new file mode 100644 index 000000000..5894970a7 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx @@ -0,0 +1,94 @@ +import type { OptimizationData } from '@contentful/optimization-web/api-schemas' +import { describe, expect, it } from '@rstest/core' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { useConsentState, useProfileState } from '../hooks/useOptimizationState' +import { OptimizationProvider } from './OptimizationProvider' + +const testConfig = { + clientId: 'test-client', + environment: 'test', + api: { insightsBaseUrl: 'http://localhost/insights', experienceBaseUrl: 'http://localhost/exp' }, +} + +function createServerOptimizationState(profileId: string): OptimizationData { + return { + changes: [], + selectedOptimizations: [], + profile: { + id: profileId, + stableId: profileId, + random: 0.5, + audiences: [], + traits: {}, + location: {}, + session: { + id: `${profileId}-session`, + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'http://localhost/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + }, + } +} + +describe('OptimizationProvider server rendering', () => { + it('renders read-state hooks into server HTML from configured defaults', () => { + function Probe(): ReactElement { + const consent = useConsentState() + return {String(consent)} + } + + const markup = renderToString( + + + , + ) + + // The read hook resolves against the snapshot runtime during server render, + // via useSyncExternalStore's getServerSnapshot — no throw, real HTML. + expect(markup).toContain('data-testid="consent"') + expect(markup).toContain('true') + }) + + it('renders profile from serverOptimizationState during server render', () => { + const serverOptimizationState = createServerOptimizationState('server-profile') + + function Probe(): ReactElement { + const profile = useProfileState() + return {profile?.id ?? 'anonymous'} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('server-profile') + }) + + it('reflects a no-consent default in server HTML', () => { + function Probe(): ReactElement { + const consent = useConsentState() + return {consent === undefined ? 'unset' : String(consent)} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('unset') + }) +}) 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..e66b5dc58 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,9 @@ type ProviderSdkBinding = OptimizationRootSdkBinding interface ProviderState { readonly error: Error | undefined - readonly isReady: boolean - readonly sdk: OptimizationSdk | undefined + /** Whether `runtime` is the live browser SDK (vs the initial snapshot runtime). */ + readonly isLive: boolean + readonly runtime: WebOptimizationRuntime | undefined } interface ServerOptimizationStateProps { @@ -159,6 +169,15 @@ function isPromiseLike(value: T | Promise): value is Promise { return value instanceof Promise } +/** + * Whether an injected SDK can back the initial render directly, with no + * asynchronous setup to run first. + * + * @remarks + * When true, the initial-render runtime is already the final live runtime, so + * the mount effect must not re-set state — this keeps the context identity + * stable across re-renders. + */ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean { return ( props.sdk !== undefined && @@ -167,20 +186,64 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): ) } -export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { +/** + * Whether the initial render is backed by the injected live SDK directly. + * + * @remarks + * An injected SDK backs the initial render unless `serverOptimizationState` must + * paint first. `onStatesReady` does not change this — it is a client-only effect + * — so an injected SDK is used as-is even when `onStatesReady` is provided. + */ +function injectedSdkBacksInitialRender(props: OptimizationProviderProps): boolean { + return props.sdk !== undefined && props.serverOptimizationState === undefined +} + +/** + * Build the runtime used for the initial render (server render and the first + * client render, before the mount effect runs). + * + * @remarks + * An injected SDK backs the initial render directly unless `serverOptimizationState` + * is provided, in which case a read-only snapshot paints the server-resolved + * state first and the effect hydrates the injected SDK with the same data. A + * config-driven provider always renders from a snapshot seeded with server state + * plus the configured consent/locale defaults, matching the value the live SDK + * reports after the effect hydrates it. + */ +function createInitialRuntime(props: OptimizationProviderProps): WebOptimizationRuntime { + if (props.sdk !== undefined) { + // Render the injected SDK directly unless server state must paint first. + return injectedSdkBacksInitialRender(props) + ? props.sdk + : createWebSnapshotRuntime({ data: props.serverOptimizationState }) + } + + return createWebSnapshotRuntime({ + data: props.serverOptimizationState, + consent: props.defaults?.consent, + persistenceConsent: props.defaults?.persistenceConsent, + locale: props.locale, + // Seed with the same allow-list the live SDK will apply so consent-derived + // state (hasConsent, optimizationPossible) does not flip on hydration. + allowedEventTypes: props.allowedEventTypes ?? DEFAULT_WEB_ALLOWED_EVENT_TYPES, + }) +} + +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(() => { const { current: initialProps } = initialPropsRef + // An injected SDK with no async setup already backs the initial render; do + // not re-set state so the context value identity stays stable. if (canUseInjectedSdkDuringInitialRender(initialProps)) { return } @@ -203,12 +266,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 +300,26 @@ 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..79c08857d --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts @@ -0,0 +1,66 @@ +import type ContentfulOptimization from '@contentful/optimization-web' +import { + createSnapshotRuntime, + type OptimizationRuntime, + type OptimizationSnapshot, +} from '@contentful/optimization-web/runtime' + +/** + * Members of the live web SDK that go beyond the universal + * {@link OptimizationRuntime} surface: browser-only imperative APIs used inside + * effects (never during render). + * + * @internal + */ +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' + +/** + * The single runtime object consumed by the React layer. + * + * @remarks + * Composes the universal, isomorphic {@link OptimizationRuntime} (pure resolvers, + * read `states`, and event actions) with the browser-only web SDK surface + * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} + * instance satisfies it by construction; a server/initial-render backing is + * produced by {@link createWebSnapshotRuntime}, where the browser-only members + * are inert no-ops. + * + * Every member is safe to reference in any environment: render-time members + * behave correctly on the server, and effect-only members are no-ops there (the + * server never runs effects, so this only matters defensively). + * + * @public + */ +export interface WebOptimizationRuntime + extends OptimizationRuntime, Pick {} + +const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { + enable: () => undefined, + disable: () => undefined, + enableElement: () => undefined, + disableElement: () => undefined, + clearElement: () => undefined, +} + +/** + * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves and reads from the snapshot, with browser-only + * tracking APIs as inert no-ops. + * + * @remarks + * Used by the provider during server rendering and the initial client render, + * before the live SDK exists. Delegates the universal surface to + * {@link createSnapshotRuntime} and adds no-op `tracking`/`trackCurrentPage`. + * + * @public + */ +export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { + const runtime = createSnapshotRuntime(snapshot) + + return Object.assign(runtime, { + tracking: NOOP_TRACKING, + trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + }) +} 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..458944b64 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -92,6 +92,16 @@ "default": "./dist/core-sdk.cjs" } }, + "./runtime": { + "import": { + "types": "./dist/runtime.d.mts", + "default": "./dist/runtime.mjs" + }, + "require": { + "types": "./dist/runtime.d.cts", + "default": "./dist/runtime.cjs" + } + }, "./api-client": { "import": { "types": "./dist/api-client.d.mts", diff --git a/packages/web/web-sdk/rslib.config.ts b/packages/web/web-sdk/rslib.config.ts index 88147bd85..7ddc803de 100644 --- a/packages/web/web-sdk/rslib.config.ts +++ b/packages/web/web-sdk/rslib.config.ts @@ -61,6 +61,7 @@ export default defineConfig({ 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', + runtime: './src/runtime.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', }, @@ -100,6 +101,7 @@ export default defineConfig({ 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', + runtime: './src/runtime.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.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..468727120 100644 --- a/packages/web/web-sdk/src/constants.ts +++ b/packages/web/web-sdk/src/constants.ts @@ -1,8 +1,24 @@ +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. + * + * @remarks + * `identify` and `page` are permitted pre-consent so the browser can resolve + * the initial page experience. This is the value the Web SDK applies when a + * consumer does not supply `allowedEventTypes`, and the same value a + * server-to-browser snapshot must carry so consent-derived state matches across + * hydration. + * + * @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/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index ba3a6d1fa..0ca8ead51 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -208,9 +208,42 @@ export class OptimizedEntryController { constructor(options: OptimizedEntryControllerOptions) { this.options = normalizeOptions(options) + // Prime from the SDK state's current values so the initial snapshot resolves + // the selected variant during server rendering and the first client render, + // before connect() attaches live subscriptions. + this.primeStateFromSdk() this.snapshot = this.createSnapshot() } + /** + * Read the current SDK state values into local fields without subscribing. + * + * @remarks + * Used at construction (so the first snapshot resolves the variant, including + * during SSR where {@link OptimizedEntryController.connect} never runs) and at + * the start of {@link OptimizedEntryController.resubscribe}. + */ + private primeStateFromSdk(): void { + const { options } = this + const { sdk, isSdkStateReady } = options + if (!sdk || !isSdkStateReady) { + return + } + + const { states } = sdk + const { canOptimize, experienceRequestState, optimizationPossible, selectedOptimizations } = + states + const { current: currentSelectedOptimizations } = selectedOptimizations + const { current: currentCanOptimize } = canOptimize + const { current: currentExperienceRequestState } = experienceRequestState + const { current: currentOptimizationPossible } = optimizationPossible + + this.acceptSelectedOptimizations(currentSelectedOptimizations) + this.canOptimize = currentCanOptimize + this.hasExperienceRequestSettled = isExperienceRequestSettled(currentExperienceRequestState) + this.optimizationPossible = currentOptimizationPossible + } + setSnapshotListener(listener: OptimizedEntrySnapshotListener | undefined): void { this.listener = listener } @@ -290,18 +323,11 @@ export class OptimizedEntryController { return } + this.primeStateFromSdk() + const { states } = sdk const { canOptimize, experienceRequestState, optimizationPossible, selectedOptimizations } = states - const { current: currentSelectedOptimizations } = selectedOptimizations - const { current: currentCanOptimize } = canOptimize - const { current: currentExperienceRequestState } = experienceRequestState - const { current: currentOptimizationPossible } = optimizationPossible - - this.acceptSelectedOptimizations(currentSelectedOptimizations) - this.canOptimize = currentCanOptimize - this.hasExperienceRequestSettled = isExperienceRequestSettled(currentExperienceRequestState) - this.optimizationPossible = currentOptimizationPossible this.subscriptions = [ selectedOptimizations.subscribe((nextSelectedOptimizations) => { diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts new file mode 100644 index 000000000..1609e2621 --- /dev/null +++ b/packages/web/web-sdk/src/runtime.ts @@ -0,0 +1 @@ +export * from '@contentful/optimization-core/runtime'