diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 68527233a..f28dbed8a 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -1435,22 +1435,27 @@ jobs: disk-size: 8G disable-animations: true emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect - script: | - echo "Verifying JAVA_HOME: $JAVA_HOME" - java -version - echo "Setting up adb reverse port forwarding..." - adb reverse tcp:8000 tcp:8000 - echo "Running E2E tests..." - pnpm run implementation:react-native-sdk -- test:e2e:android:run:release -- --loglevel error --retries 1 --shard=${{ matrix.shard }}/2 - - - name: Capture Android E2E diagnostics on failure - if: failure() + # The action runs each script line separately and tears down the emulator before returning. + # Keep tests and failure-time adb diagnostics in one shell command so adb can still reach it. + script: >- + set -e; + echo "Verifying JAVA_HOME: $JAVA_HOME"; + java -version; + echo "Setting up adb reverse port forwarding..."; + adb reverse tcp:8000 tcp:8000; + echo "Running E2E tests..."; + if pnpm run implementation:react-native-sdk -- test:e2e:android:run:release -- --loglevel error --retries 1 --shard=${{ matrix.shard }}/2; then exit 0; else test_status=$?; fi; + mkdir -p /tmp/react-native-e2e-diagnostics || true; + timeout --signal=TERM --kill-after=5s 10s adb reverse --list > /tmp/react-native-e2e-diagnostics/adb-reverse-list.txt 2>&1 || true; + timeout --signal=TERM --kill-after=5s 20s adb logcat -d -v threadtime > /tmp/react-native-e2e-diagnostics/android-logcat.txt 2>&1 || true; + exit "$test_status" + + - name: Capture host E2E diagnostics + if: always() run: | echo "=== Mock Server Logs ===" cat /tmp/mock-server.log || echo "No mock server logs found" mkdir -p /tmp/react-native-e2e-diagnostics - adb reverse --list > /tmp/react-native-e2e-diagnostics/adb-reverse-list.txt 2>&1 || true - adb logcat -d -v threadtime > /tmp/react-native-e2e-diagnostics/android-logcat.txt 2>&1 || true find /tmp -maxdepth 1 -name '*.detox.jsonl' -exec cp {} /tmp/react-native-e2e-diagnostics/ \; 2>/dev/null || true - name: Stop Mock Server diff --git a/AGENTS.md b/AGENTS.md index 182e8ecdc..43104b32d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,6 +271,14 @@ explicitly requests it. impact crosses package families or is uncertain, use aggregate `pnpm size:report` for the complete inventory, then `pnpm size:check` after classification. - On failure, report the command, package or bundle, budget, actual size, and delta. +- For an explicitly approved increase to an exceeded budget, or a request to normalize budgets, + set each budget in the authorized scope to `ceil((measuredGzipBytes + 100) / 100) * 100`: add + 100 bytes to the measured gzip size, then round up to a multiple of 100 bytes. Use the + completed build's `size:report` measurement, including reachable local chunks/assets counted by the + checker; do not calculate from the previous budget or an isolated entry file. Normalization + applies to every requested entry, including budgets that decrease or already satisfy their + limit. This calculation does not authorize additional budget changes or waive the approval, + failure-classification, scope, and validation rules in this section. - Allowed remediation is limited to confirmed current-change regressions, dependency/import mistakes, and measurement issues. Every remediation must preserve maintainability: do not make code harder to read, type, test, debug, or change just to reduce bytes. Prefer fixes such as diff --git a/README.md b/README.md index f20bc0038..50acbd48b 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,17 @@ that surface. The published package surface is intentionally layered. The table below is a package inventory and high-level role summary. -| Package | Kind | Runtime | Role | Package README | -| -------------------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `@contentful/optimization-web` | Environment SDK | Browser | Stateful browser SDK for optimization, tracking, and consent | [Web SDK](./packages/web/web-sdk/README.md) | -| `@contentful/optimization-react-web` | Framework SDK | React on the web | React integration layer on top of the Web SDK | [React Web SDK](./packages/web/frameworks/react-web-sdk/README.md) | -| `@contentful/optimization-nextjs` | Framework SDK | Next.js | Next.js adapter for explicit App Router and Pages Router bound components, SSR, client tracking, and request composition | [Next.js SDK](./packages/web/frameworks/nextjs-sdk/README.md) | -| `@contentful/optimization-node` | Environment SDK | Node.js | Stateless Node SDK for server-side and SSR integrations | [Node SDK](./packages/node/node-sdk/README.md) | -| `@contentful/optimization-react-native` | Environment SDK | React Native | React Native SDK for mobile applications | [React Native SDK](./packages/react-native-sdk/README.md) | -| `@contentful/optimization-web-preview-panel` | Tooling package | Browser | Preview tooling package for existing Web SDK instances | [Web Preview Panel](./packages/web/preview-panel/README.md) | -| `@contentful/optimization-core` | Shared SDK foundation | Runtime-agnostic | Shared optimization foundation, CDA schemas, and aggregate schema pass-throughs for runtime adapters and SDK layers | [Core SDK](./packages/universal/core-sdk/README.md) | -| `@contentful/optimization-api-client` | Universal library | Runtime-agnostic | Direct Experience API and Insights API transport with their validation schemas | [API Client](./packages/universal/api-client/README.md) | -| `@contentful/optimization-api-schemas` | Compatibility facade | Runtime-agnostic | Deprecated compatibility facade for the historical combined API-schema root surface | [API Schemas](./packages/universal/api-schemas/README.md) | +| Package | Kind | Runtime | Role | Package README | +| -------------------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `@contentful/optimization-web` | Environment SDK | Browser | Stateful browser SDK for optimization, tracking, and consent | [Web SDK](./packages/web/web-sdk/README.md) | +| `@contentful/optimization-react-web` | Framework SDK | React on the web | React integration layer on top of the Web SDK | [React Web SDK](./packages/web/frameworks/react-web-sdk/README.md) | +| `@contentful/optimization-nextjs` | Framework SDK | Next.js | Next.js adapter for request preview handoff, browser queue commitment, SSR, and router composition | [Next.js SDK](./packages/web/frameworks/nextjs-sdk/README.md) | +| `@contentful/optimization-node` | Environment SDK | Node.js | Stateless Node SDK for server-side and SSR integrations | [Node SDK](./packages/node/node-sdk/README.md) | +| `@contentful/optimization-react-native` | Environment SDK | React Native | React Native SDK for mobile applications | [React Native SDK](./packages/react-native-sdk/README.md) | +| `@contentful/optimization-web-preview-panel` | Tooling package | Browser | Preview tooling package for existing Web SDK instances | [Web Preview Panel](./packages/web/preview-panel/README.md) | +| `@contentful/optimization-core` | Shared SDK foundation | Runtime-agnostic | Shared optimization foundation, CDA schemas, and aggregate schema pass-throughs for runtime adapters and SDK layers | [Core SDK](./packages/universal/core-sdk/README.md) | +| `@contentful/optimization-api-client` | Universal library | Runtime-agnostic | Direct Experience API and Insights API transport with their validation schemas | [API Client](./packages/universal/api-client/README.md) | +| `@contentful/optimization-api-schemas` | Compatibility facade | Runtime-agnostic | Deprecated compatibility facade for the historical combined API-schema root surface | [API Schemas](./packages/universal/api-schemas/README.md) | General selection rules: @@ -133,10 +133,10 @@ patterns with intentionally minimal application code. SDK - [Node SSR + Web Vanilla](./implementations/node-sdk+web-sdk/README.md) - split server/browser flow using Node and Web SDKs together -- [Next.js SDK App Router](./implementations/nextjs-sdk_app-router/README.md) - Next.js App Router - first-paint SSR with browser-side takeover after hydration -- [Next.js SDK Pages Router](./implementations/nextjs-sdk_pages-router/README.md) - Next.js Pages - Router integration using `getServerSideProps` state handoff +- [Next.js SDK App Router](./implementations/nextjs-sdk_app-router/README.md) - request-preview SSR + with browser queue commitment after hydration +- [Next.js SDK Pages Router](./implementations/nextjs-sdk_pages-router/README.md) - Pages Router + integration using `getServerSideProps` prepared handoff - [React Native](./implementations/react-native-sdk/README.md) - mobile application integration for Android and iOS targets - [iOS Reference App](./implementations/ios-sdk/README.md) - native app and XCUITest surface for diff --git a/documentation/authoring/blueprints/nextjs-app-router.md b/documentation/authoring/blueprints/nextjs-app-router.md index 7e3eecb75..3d280688b 100644 --- a/documentation/authoring/blueprints/nextjs-app-router.md +++ b/documentation/authoring/blueprints/nextjs-app-router.md @@ -53,23 +53,23 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app. ## Section map -| Section | Category | Purpose | Must teach or show | Fact sources | -| ------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| How the SDK fits your app | Required for first integration | Orient the reader to the explicit server/client entry points and binding boundaries before deeper configuration. | `/app-router/server` with `bindNextjsAppRouterServerOptimization` for Server Components; `/app-router/client` with `bindNextjsAppRouterClientOptimization` only for bound Client Components; router-neutral `/client` for hooks and per-entry browser controls; no conditional `/app-router` path; separate app-local server and client binding modules so imports cannot cross runtime boundaries; which initial settings are SDK mechanism versus application policy. Keep that normal path first, then teach one atomic optional request-family change with `beforeInitialPage`: define the initial page decision and server event -> handoff -> live browser runtime -> callback -> direct page attempt or same-route handoff skip -> attempted-route mark -> later-route timeline; capture the callback and export `RequestOptimizationRoot` from a `'use client'` module; inject that component reference through the server binder's `request.OptimizationRoot` option; immediately remove the request tracker export, layout import, and JSX before the reader can stop; show multiple awaited operations represented by one returned Promise and define retained lifetime, after-live-owned-runtime invocation, event delegates, Promise/thenable, and early identity; keep the callback and lazy payload builder in the client module rather than Flight/server config; distinguish the auto-routed request root from the direct client `OptimizationRoot`, which still requires explicit `routeKey` and `buildPagePayload` and rejects eager `initialPagePayload`; state the optional-`never` server-config boundary and unchanged serialized handoff shape. | [package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding) | -| Choose who owns entry fetching | Required for first integration | Establish the app-owned-versus-SDK-managed entry boundary before personalization. | Concise executable app-owned `baselineEntry` and SDK-managed examples; both workflows are supported and neither is framed as historical; the final managed example shows `contentful: { client }`, `prefetchManagedEntries={[entryId]}`, and request `OptimizedEntry entryId={entryId}` together; include an object `managedEntry` alternative; only SDK-owned CDA work receives direct request/CDA overlap; the SDK owns managed cache/dedup and the one handoff merge, so consumers add no duplicate await, cache, or performance option; fixed SDK property names versus app/model values; server-render versus root-prefetch/browser-handoff boundary. | [App Router setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [entry-source boundary](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | -| Request context and the profile cookie | Common but policy-dependent | Teach how the SDK-owned request family receives visitor context and where application policy enters. | The no-argument forwarding handler as the default, the verified narrow matcher, the Next.js 16 `proxy.ts`/`proxy` and Next.js 13 to 15 `middleware.ts`/`middleware` support paths, and the forwarded request URL requirement; `consent.server` and cookie ownership; distinguish the SDK-owned anonymous ID cookie from full profile, selected state, and app-owned consent; define route key, initial page payload, hydration mode, and private-request handoff; trusted response-capable request persistence only as an advanced opt-in; one cached SDK initializer shared by every `optimization.request` wrapper; no app-owned header/cookie/URL/route-key/page-payload plumbing; plus a server-observable verification path. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [experience response](../../internal/sdk-knowledge/shared/concepts.md#experience-response-payload), [consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | -| Personalizing first paint on the server | Required for first integration | Show the request-bound entry render that produces Milestone 1. | A representative renderer diff using `optimization.request.OptimizedEntry`; one skeleton union and content-type narrowing; baseline behavior; the shared initialization guarantee even when an entry starts before the root; managed baseline work starts concurrently with request initialization while selected state remains behind both; and the dynamic-render and private-request cache consequences. | [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/shared/concepts.md#baseline-fallback) | -| The bound root and page events | Required for first integration | Explain SDK-owned request handoff and ownership of the first page event. | Keep the normal `optimization.request.OptimizationRoot` plus `optimization.request.NextAppAutoPageTracker` path first: request-independent public chrome outside the private root and `Suspense`; all provider-dependent UI and the tracker inside; meaningful fallback; `Link` prefetch retained; request-family handoff, route, initial payload, and first-event ownership; mounted accepted/blocked diagnostic. Then deepen the atomic alternative with `beforeInitialPage`: the injected `ClientRequestOptimizationRoot` replaces the default browser root for the request family, receives only serializable handoff/hydration/defaults/children, derives route and lazy payload from the non-emitting App Router inputs hook, and has no separate tracker. Define direct attempt, emitter, initial `skip`, and `{ accepted: false }`; teach callback -> latest direct page attempt or same-route handoff skip -> attempted-route initial `skip` mark -> later-route `emit`, watchdog/error continuation only while mounted on the current live runtime, best-effort and verified in-flight route-interleaving limits, visible five-second fallback/freeze with live-update remedy, and a performable accepted/blocked ordering/duplicate-page check. Qualify every later tracker example, production check, and troubleshooting row with normal tracker mode versus `beforeInitialPage` mode; route Optional readers here without a new section; preserve provider/analytics exclusions and explicit top-level static/public-permutation/analytics roots. | [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web failure](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior), [Web observables](../../internal/sdk-knowledge/web/web.md#consent--persistence) | -| Browser takeover and live updates | Required for first integration | Explain the handoff mental model every integration uses while marking re-personalization as opt-in. | Snapshot-to-live behavior, opt-in controls, a browser example with an observable before/after state change, and provider ownership. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | -| Entry interaction tracking | Common but policy-dependent | Make default interaction instrumentation and policy controls visible. | Tracked interactions, attribution, binding and per-entry controls, consent/profile consequences, and one performable check. | [App Router events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [shared consent](../../internal/sdk-knowledge/shared/concepts.md#consent--persistence) | -| Consent, identity, profile, and reset | Common but policy-dependent | Replace the quick-start consent shortcut with application-owned policy and identity flows. | A production consent flow shared by server and browser, identity/reset controls, and clear SDK-versus-application cleanup ownership. | [App Router consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [React Web consent/actions](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [Web failure behavior](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | -| Analytics forwarding | Optional | Teach the safe browser forwarding seam, then route vendor-specific setup to the supplemental guide. | A lifecycle-safe subscription with consent gating, deduplication and failure handling, plus the supplemental-guide route. | [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [Web consent/observables](../../internal/sdk-knowledge/web/web.md#consent--persistence) | -| Merge tags and Custom Flags | Optional | Cover content-model-dependent personalization beyond entry replacement. | One complete Rich Text merge-tag example, the alternate hook route, and one observable Custom Flag example with authoring links. | [App Router components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [React Web rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [shared entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | -| Preview panel | Optional | Provide an environment-gated authoring and debugging path. | Installation and attach flow, readiness/environment/error handling, browser-safety considerations, and a forced-variant check. | [React Web runtime quirks](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [Web fallback/preview](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | -| Route-level SSR, browser takeover, and browser-owned islands | Advanced or production-only | Help mature applications choose ownership per route. | A decision table comparing first-paint, browser, event, cache ownership, and tradeoffs; teach the private-slot composition as the preferred request-performance shape, with public request-independent chrome outside the request root and `Suspense`, meaningful fallback, provider-dependent UI inside, and version-appropriate `connection()` handling: call it at the Cache Components private request boundary on Next.js 15 and later, but omit it on Next.js 13 to 14; preserve the independent hidden-until-ready browser hydration scenario; use the nested request family only for private request rendering, the top-level `/app-router/server` family for explicit static/public/analytics handoffs, and `/app-router/client` or router-neutral `/client` for browser-owned surfaces; route full static/ISR/edge recipes to the supplemental guide. | [App Router components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [App Router runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | -| Manual server and client escape hatches | Advanced or production-only | Expose lower-level APIs only after the request-family path is understood. | A precise API and ownership map; keep bound `createRequestHandoff()` and lower-level `/server` request APIs as advanced orchestration paths; keep trusted response-capable handler persistence here as an explicit opt-in while forwarding-only remains the default; show a complete manual request-to-provider flow only when the guide recommends that path; do not present manual request plumbing as the private-request default. | [package and setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | -| Caching and request deduplication | Advanced or production-only | Prevent profile-specific output from entering shared caches without recreating SDK request plumbing. | A cache-safety table; the request family's one-initialization-per-RSC-request contract and separation across requests; the distinction between SDK initialization sharing, managed cache/dedup, the single handoff merge, and shared output caching; managed baseline fetching/prefetch overlaps request initialization automatically while selected state waits for both; no app-owned request cache, duplicate awaits, or performance option; links to the handoff concept and supplemental rendering guide; and a two-profile validation. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [managed fetching](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | -| Strict consent and duplicate-event controls | Advanced or production-only | Turn privacy and duplicate-event policy into explicit production decisions. | A strict event-policy example, blocked-event diagnostics, an accepted local page-event check triggered by a normal participating Next.js `Link` navigation after consent, first-event ownership, and the separate steps in consent withdrawal. | [App Router consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [App Router events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [Web failure behavior](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| How the SDK fits your app | Required for first integration | Orient the reader to the explicit server/client entry points and binding boundaries before deeper configuration. | `/app-router/server` with `bindNextjsAppRouterServerOptimization` for Server Components; `/app-router/client` with `bindNextjsAppRouterClientOptimization` only for bound Client Components; router-neutral `/client` for hooks and per-entry browser controls; no conditional `/app-router` path; separate app-local server and client binding modules so imports cannot cross runtime boundaries; which initial settings are SDK mechanism versus application policy. Keep that normal path first, then teach optional browser-owned `beforeInitialPage` sequencing. Keep paired request `initialEvents` primary; every valid, consent-eligible request handoff contains replay even when preview fails, and its request-family root bypasses `beforeInitialPage`. Do not inject a callback-enabled root into the server request family. On a route with no handoff, use the direct `/app-router/client` binder root; use public Next.js `usePathname` and `useSearchParams` hooks to build stable `routeKey` as pathname plus search and lazy `buildPagePayload` from those route values; keep `useSearchParams` under a meaningful `Suspense` fallback, let the root own initial and later page tracking, and mount no separate tracker. Capture the callback and export the direct `OptimizationRoot` from a 'use client' module; do not inject it through the server binder or mount another tracker; show multiple awaited operations represented by one returned Promise and define retained lifetime, after-live-owned-runtime invocation, event delegates, Promise/thenable, and early identity; keep the callback and lazy payload builder in the client module rather than Flight/server config; distinguish the auto-routed request root from the direct client `OptimizationRoot`, which still requires explicit `routeKey` and `buildPagePayload` and rejects eager `initialPagePayload`; state the optional-`never` server-config boundary and unchanged serialized handoff shape. | [package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding) | +| Choose who owns entry fetching | Required for first integration | Establish the app-owned-versus-SDK-managed entry boundary before personalization. | Concise executable app-owned `baselineEntry` and SDK-managed examples; both workflows are supported and neither is framed as historical; the final managed example shows `contentful: { client }`, `prefetchManagedEntries={[entryId]}`, and request `OptimizedEntry entryId={entryId}` together; include an object `managedEntry` alternative; only SDK-owned CDA work receives direct request/CDA overlap; the SDK owns managed cache/dedup and the one handoff merge, so consumers add no duplicate await, cache, or performance option; fixed SDK property names versus app/model values; server-render versus root-prefetch/browser-handoff boundary. | [App Router setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [entry-source boundary](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Request context and the profile cookie | Common but policy-dependent | Teach how the SDK-owned request family receives visitor context and where application policy enters. | The no-argument forwarding handler as the default, the verified narrow matcher, the Next.js 16 `proxy.ts`/`proxy` and Next.js 13 to 15 `middleware.ts`/`middleware` support paths, and the forwarded request URL requirement; `consent.server` and cookie ownership; distinguish the SDK-owned anonymous ID cookie from full profile, selected state, and app-owned consent; define route key, initial page payload, hydration mode, and private-request handoff; trusted response-capable request persistence only as an advanced opt-in; one cached SDK initializer shared by every `optimization.request` wrapper; no app-owned header/cookie/URL/route-key/page-payload plumbing; plus a server-observable verification path. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [experience response](../../internal/sdk-knowledge/shared/concepts.md#experience-response-payload), [consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | +| Personalizing first paint on the server | Required for first integration | Show the request-bound entry render that produces Milestone 1. | A representative renderer diff using `optimization.request.OptimizedEntry`; one skeleton union and content-type narrowing; baseline behavior; the shared initialization guarantee even when an entry starts before the root; managed baseline work starts concurrently with request initialization while selected state remains behind both; and the dynamic-render and private-request cache consequences. | [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/shared/concepts.md#baseline-fallback) | +| The bound root and page events | Required for first integration | Explain SDK-owned request handoff and ownership of the first page event. | Keep the normal `optimization.request.OptimizationRoot` plus `optimization.request.NextAppAutoPageTracker` path first: request-independent public chrome outside the private root and `Suspense`; all provider-dependent UI and the tracker inside; meaningful fallback; `Link` prefetch retained. Explain that request initialization prepares the initial event batch and preflights it per request for server rendering; the server's preview is not event commitment. The browser tracker passes the handoff to the Web SDK, which admits the prepared batch as one submission to its existing Experience queue; queue admission is reported independently of HTTP delivery, and a matching replay prevents a second ordinary page submission. Include route, initial payload, and first-event ownership; a mounted accepted/blocked diagnostic. Then deepen the atomic alternative with `beforeInitialPage`: the direct root from the `/app-router/client` binder runs on a browser-owned route with no handoff, reads pathname and search with public Next.js `usePathname` and `useSearchParams` hooks to supply stable `routeKey` and lazy `buildPagePayload`, and owns page tracking without a separate tracker. Do not use the injected request-family root for this callback: valid, consent-eligible request handoffs contain replay even when preview fails and bypass `beforeInitialPage`. For this no-handoff path, define direct attempt, emitter, initial `skip`, and `{ accepted: false }`; teach callback -> latest direct page attempt -> attempted-route initial `skip` mark -> later-route `emit`, watchdog/error continuation only while mounted on the current live runtime, best-effort and verified in-flight route-interleaving limits, visible five-second fallback/freeze with live-update remedy, and a performable accepted/blocked ordering/duplicate-page check. Qualify every later tracker example, production check, and troubleshooting row with normal tracker mode versus `beforeInitialPage` mode; route Optional readers here without a new section; preserve provider/analytics exclusions and explicit top-level static/public-permutation/analytics roots. | [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web failure](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior), [Web observables](../../internal/sdk-knowledge/web/web.md#consent--persistence) | +| Browser takeover and live updates | Required for first integration | Explain the handoff mental model every integration uses while marking re-personalization as opt-in. | Snapshot-to-live behavior, opt-in controls, a browser example with an observable before/after state change, and provider ownership. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | +| Entry interaction tracking | Common but policy-dependent | Make default interaction instrumentation and policy controls visible. | Tracked interactions, attribution, binding and per-entry controls, consent/profile consequences, and one performable check. | [App Router events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [shared consent](../../internal/sdk-knowledge/shared/concepts.md#consent--persistence) | +| Consent, identity, profile, and reset | Common but policy-dependent | Replace the quick-start consent shortcut with application-owned policy and identity flows. | A production consent flow shared by server and browser, identity/reset controls, and clear SDK-versus-application cleanup ownership. | [App Router consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [React Web consent/actions](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [Web failure behavior](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | +| Analytics forwarding | Optional | Teach the safe browser forwarding seam, then route vendor-specific setup to the supplemental guide. | A lifecycle-safe subscription with consent gating, deduplication and failure handling, plus the supplemental-guide route. | [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [Web consent/observables](../../internal/sdk-knowledge/web/web.md#consent--persistence) | +| Merge tags and Custom Flags | Optional | Cover content-model-dependent personalization beyond entry replacement. | One complete Rich Text merge-tag example, the alternate hook route, and one observable Custom Flag example with authoring links. | [App Router components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [React Web rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [shared entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Preview panel | Optional | Provide an environment-gated authoring and debugging path. | Installation and attach flow, readiness/environment/error handling, browser-safety considerations, and a forced-variant check. | [React Web runtime quirks](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [Web fallback/preview](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | +| Route-level SSR, browser takeover, and browser-owned islands | Advanced or production-only | Help mature applications choose ownership per route. | A decision table comparing first-paint, browser, event, cache ownership, and tradeoffs; teach the private-slot composition as the preferred request-performance shape, with public request-independent chrome outside the request root and `Suspense`, meaningful fallback, provider-dependent UI inside, and version-appropriate `connection()` handling: call it at the Cache Components private request boundary on Next.js 15 and later, but omit it on Next.js 13 to 14; preserve the independent hidden-until-ready browser hydration scenario; use the nested request family only for private request rendering, the top-level `/app-router/server` family for explicit static/public/analytics handoffs, and `/app-router/client` or router-neutral `/client` for browser-owned surfaces; route full static/ISR/edge recipes to the supplemental guide. | [App Router components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [App Router runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | +| Manual server and client escape hatches | Advanced or production-only | Expose lower-level APIs only after the request-family path is understood. | A precise API and ownership map; keep bound `createRequestHandoff()` and lower-level `/server` request APIs as advanced orchestration paths; keep trusted response-capable handler persistence here as an explicit opt-in while forwarding-only remains the default; show a complete manual request-to-provider flow only when the guide recommends that path; do not present manual request plumbing as the private-request default. | [package and setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | +| Caching and request deduplication | Advanced or production-only | Prevent profile-specific output from entering shared caches without recreating SDK request plumbing. | A cache-safety table; the request family's one-initialization-per-RSC-request contract and separation across requests; the distinction between SDK initialization sharing, managed cache/dedup, the single handoff merge, and shared output caching; managed baseline fetching/prefetch overlaps request initialization automatically while selected state waits for both; no app-owned request cache, duplicate awaits, or performance option; links to the handoff concept and supplemental rendering guide; and a two-profile validation. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [managed fetching](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | +| Strict consent and duplicate-event controls | Advanced or production-only | Turn privacy and duplicate-event policy into explicit production decisions. | A strict event-policy example, blocked-event diagnostics, an accepted local page-event check triggered by a normal participating Next.js `Link` navigation after consent, first-event ownership, and the separate steps in consent withdrawal. | [App Router consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [App Router events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [Web failure behavior](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | ## SDK-specific authoring overrides @@ -100,6 +100,11 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app. - Keep the integration guide focused on request-family initialization and route ownership. Static, ISR, edge, and analytics-only recipes use top-level request-free surfaces and live in the supplemental rendering guide; deeper handoff mechanics live in the concept. +- For private App Router requests and per-request Edge handoffs, describe server work as event + preparation plus per-request preflight for rendering. Browser takeover admits the prepared batch + through the SDK queue; preflight is not commitment. Keep private replay and API-issued profile + identity out of static/ISR output. Use public-permutation handoffs only for explicit + visitor-independent selections, or shared baseline output with browser-owned personalization. - Keep the quick start self-contained: show where the Optimization values come from and their `.env.local` placement, gloss the consent and hydration settings, define the public-chrome, provider-dependent body, and meaningful-fallback responsibilities, and explain the diff --git a/documentation/authoring/blueprints/nextjs-pages-router.md b/documentation/authoring/blueprints/nextjs-pages-router.md index a6ba5bb7e..83b26d9fb 100644 --- a/documentation/authoring/blueprints/nextjs-pages-router.md +++ b/documentation/authoring/blueprints/nextjs-pages-router.md @@ -35,23 +35,23 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-ap ## Section map -| Section | Category | Purpose | Must teach or show | Fact sources | -| ------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| How the SDK fits your app | Required for first integration | Establish the explicit browser/server module split. | Import-path table; client and server binding modules; bridge from quick start. | [package and setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points) | -| Fetching Contentful entries | Required for first integration | Establish manual and managed server entry sources. | Introduce the `ManagedEntryDescriptor` import, `contentful.client` on the browser/server bindings, and the wrapper's `prefetchManagedEntries` input here rather than in the manual-baseline quick start; manual path plus an actual dynamic route whose parameter builds a direct ID/slug descriptor for request prefetch and passes the object descriptor to bound `OptimizedEntry` under `managedEntry`; fixed SDK property names versus app/model values; one shared helper supplies the same effective source values to request prefetch, browser render, and handoff; `entryQuery` and default slug field; isolated exact not-found/duplicate templates with placeholder explanation; slug handoff nests the descriptor under `managedEntry` and retains fetched `sys.id` as `entryId`; locale/include/client ownership. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | -| The getServerSideProps request handoff and the profile cookie | Required for first integration | Teach how server decisions reach `_app.tsx`. | Bind the server SDK with `bindNextjsPagesRouterServerOptimization`; call its returned `createRequestHandoff` from `getServerSideProps`; complete props merge; define any app-owned wrapper before use; failure handling; cookie ownership; explicit no-middleware note. | [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding), [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | -| The bound root and page events | Required for first integration | Apply serialized state before children and assign first-event ownership. | Keep the normal `_app.tsx` root-plus-tracker path first. Then show an adaptable client-binder `beforeInitialPage` path that captures the callback only for the bound content root; define the initial page decision at first use and show the server event -> handoff -> live browser runtime -> callback -> direct page attempt or same-route handoff skip -> attempted-route mark -> later-route sequence; define early identity, retained lifetime, after-live-owned-runtime invocation, `identify`/`screen`/`track`, Promise/thenable, direct attempt, emitter, initial `skip`, and `{ accepted: false }`; represent multiple awaited operations with one returned Promise; require `routeKey` and lazy `buildPagePayload` while rejecting eager `initialPagePayload`; remove the separate tracker; and teach watchdog/error continuation only while mounted on the current live runtime, best-effort and verified in-flight route-interleaving limits, a visible five-second fallback/freeze callout with live-update remedy, and a performable accepted/blocked ordering/duplicate-page check. Qualify production checks and troubleshooting with normal tracker mode versus `beforeInitialPage` mode, route Optional readers here without a new section, and preserve provider/analytics exclusions. State narrowly that the Pages server binder rejects a client config containing `beforeInitialPage`, does not run its callback, and does not serialize it into the handoff passed through page props. | [components](../../internal/sdk-knowledge/web/nextjs-pages-router.md#components--hooks), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web failure](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior) | -| Personalizing entries | Required for first integration | Complete Milestone 1 at the renderer boundary. | Render-prop diff; one skeleton union and content-type narrowing; fallback; nested-wrap warning; managed-prefetch shape. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | -| Browser takeover and live updates | Common but policy-dependent | Implement Milestone 2 when mounted content must change. | App-wide and per-entry controls; identity/reset example. | [components](../../internal/sdk-knowledge/web/nextjs-pages-router.md#components--hooks) | -| Entry interaction tracking | Common but policy-dependent | Explain default browser interactions and consent. | Defaults; opt-outs; resolved-entry metadata. | [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | -| Consent, identity, profile, and reset | Common but policy-dependent | Replace the quick-start shortcut across server and browser. | App-owned cookie example; server callback; browser actions. | [consent](../../internal/sdk-knowledge/web/nextjs-pages-router.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | -| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Early subscription seam; duplicate-forwarding warning; link. | [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | -| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Guard/resolver and flag examples. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | -| Preview panel | Optional | Add environment-gated authoring tooling. | Separate package; readiness attach; reader-owned gate. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks) | -| Mixed route strategies | Advanced or production-only | Help mature apps choose ownership by route. | Strategy decision table and links to the supplemental rendering guide for static, ISR, edge, and analytics-only recipes. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | -| Manual server and client escape hatches | Advanced or production-only | Expose lower-level APIs after the bound path. | Server/client API map and ownership boundary. | [package and setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points) | -| Caching and request policy | Advanced or production-only | Keep personalized state and HTML out of shared caches. | Cache-safety table, request failure policy, and links to the handoff concept and supplemental rendering guide. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | -| Strict consent and duplicate-event controls | Advanced or production-only | Make production event posture explicit. | Empty allow-list; blocked diagnostics; first-event check. | [consent](../../internal/sdk-knowledge/web/nextjs-pages-router.md#consent--persistence), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| How the SDK fits your app | Required for first integration | Establish the explicit browser/server module split. | Import-path table; client and server binding modules; bridge from quick start. | [package and setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points) | +| Fetching Contentful entries | Required for first integration | Establish manual and managed server entry sources. | Introduce the `ManagedEntryDescriptor` import, `contentful.client` on the browser/server bindings, and the wrapper's `prefetchManagedEntries` input here rather than in the manual-baseline quick start; manual path plus an actual dynamic route whose parameter builds a direct ID/slug descriptor for request prefetch and passes the object descriptor to bound `OptimizedEntry` under `managedEntry`; fixed SDK property names versus app/model values; one shared helper supplies the same effective source values to request prefetch, browser render, and handoff; `entryQuery` and default slug field; isolated exact not-found/duplicate templates with placeholder explanation; slug handoff nests the descriptor under `managedEntry` and retains fetched `sys.id` as `entryId`; locale/include/client ownership. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | +| The getServerSideProps request handoff and the profile cookie | Required for first integration | Prepare the server render and deliver its initial events to the browser through `_app.tsx`. | Bind the server SDK with `bindNextjsPagesRouterServerOptimization`; call its returned `createRequestHandoff` from `getServerSideProps`; explain that it prepares the initial event batch and evaluates with per-request preflight, while browser commitment happens later through the Web SDK's existing Experience queue; include the complete props merge; define any app-owned wrapper before use; failure handling; API-issued profile-ID cookie ownership; explicit no-middleware note. Do not describe server preview as an accepted committed page event or a browser skip signal. | [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding), [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| The bound root and page events | Required for first integration | Apply serialized state before children and assign first-event ownership. | Keep the normal `_app.tsx` root-plus-tracker path first. Explain that the server prepares the initial event batch and runs request-scoped preflight for personalized rendering; the browser tracker passes the handoff to the Web SDK, whose existing Experience queue admits the prepared batch as one submission. Queue admission is independent of HTTP delivery, and a matching replay prevents a second ordinary page submission. Keep paired `getServerSideProps` handoff and request `initialEvents` as the primary flow. Every valid, consent-eligible request handoff contains replay even when preview fails, so the bound root bypasses `beforeInitialPage`. Demonstrate the optional callback only on a browser-owned route with no handoff: use the client binder's direct `OptimizationRoot`, pass a stable `routeKey` and lazy `buildPagePayload`, and let that root own initial and later page tracking without a separate tracker. Show the callback -> direct page attempt -> attempted-route mark -> later-route sequence; define early identity, retained lifetime, after-live-owned-runtime invocation, `identify`/`screen`/`track`, Promise/thenable, direct attempt, emitter, initial `skip`, and `{ accepted: false }`; represent multiple awaited operations with one returned Promise; require `routeKey` and lazy `buildPagePayload` while rejecting eager `initialPagePayload`; remove the separate tracker; and teach watchdog/error continuation only while mounted on the current live runtime, best-effort and verified in-flight route-interleaving limits, a visible five-second fallback/freeze callout with live-update remedy, and a performable accepted/blocked ordering/duplicate-page check. Qualify production checks and troubleshooting with normal tracker mode versus `beforeInitialPage` mode, route Optional readers here without a new section, and preserve provider/analytics exclusions. State narrowly that the Pages server binder rejects a client config containing `beforeInitialPage`, does not run its callback, and does not serialize it into the handoff passed through page props. | [components](../../internal/sdk-knowledge/web/nextjs-pages-router.md#components--hooks), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [React Web setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [React Web failure](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior) | +| Personalizing entries | Required for first integration | Complete Milestone 1 at the renderer boundary. | Render-prop diff; one skeleton union and content-type narrowing; fallback; nested-wrap warning; managed-prefetch shape. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | +| Browser takeover and live updates | Common but policy-dependent | Implement Milestone 2 when mounted content must change. | App-wide and per-entry controls; identity/reset example. | [components](../../internal/sdk-knowledge/web/nextjs-pages-router.md#components--hooks) | +| Entry interaction tracking | Common but policy-dependent | Explain default browser interactions and consent. | Defaults; opt-outs; resolved-entry metadata. | [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| Consent, identity, profile, and reset | Common but policy-dependent | Replace the quick-start shortcut across server and browser. | App-owned cookie example; server callback; browser actions. | [consent](../../internal/sdk-knowledge/web/nextjs-pages-router.md#consent--persistence), [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | +| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Early subscription seam; duplicate-forwarding warning; link. | [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Guard/resolver and flag examples. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | +| Preview panel | Optional | Add environment-gated authoring tooling. | Separate package; readiness attach; reader-owned gate. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks) | +| Mixed route strategies | Advanced or production-only | Help mature apps choose ownership by route. | Strategy decision table and links to the supplemental rendering guide for static, ISR, edge, and analytics-only recipes. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | +| Manual server and client escape hatches | Advanced or production-only | Expose lower-level APIs after the bound path. | Server/client API map and ownership boundary. | [package and setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points) | +| Caching and request policy | Advanced or production-only | Keep personalized state and HTML out of shared caches. | Cache-safety table, request failure policy, and links to the handoff concept and supplemental rendering guide. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | +| Strict consent and duplicate-event controls | Advanced or production-only | Make production event posture explicit. | Empty allow-list; blocked diagnostics; first-event check. | [consent](../../internal/sdk-knowledge/web/nextjs-pages-router.md#consent--persistence), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | ## SDK-specific authoring overrides @@ -62,6 +62,11 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-ap - Keep the integration guide focused on Pages request handoff. Static, ISR, edge, and analytics-only selection handoff recipes live in the supplemental rendering guide; deeper handoff mechanics live in the concept. +- Explain that `getServerSideProps` and per-request Edge integrations prepare and preflight their + initial event batch for rendering; the browser commits the batch through the SDK queue. Keep + private replay and API-issued profile identity out of `getStaticProps`/ISR output. Static routes + use public-permutation handoffs only for explicit visitor-independent selections, or shared + baseline output with browser-owned personalization. ## Troubleshooting scope diff --git a/documentation/authoring/blueprints/node.md b/documentation/authoring/blueprints/node.md index 42e7c006e..5c85ff77b 100644 --- a/documentation/authoring/blueprints/node.md +++ b/documentation/authoring/blueprints/node.md @@ -51,6 +51,10 @@ guide: ../../guides/integrating-the-node-sdk-in-a-node-app.md ## SDK-specific authoring overrides - Use a server `process.env` note rather than the recipe's browser-visible environment convention. +- When explaining Node/Web continuity, distinguish ordinary Node `page()` delivery from paired + `prepareRequestHandoff()` preparation: the latter uses per-request preflight for rendering and + the browser admits the prepared event batch through its existing queue. Profile identity is + supplied by Experience API responses. - Do not force a React render-prop example into this guide; explain the skeleton union, content-type narrowing, and mutation boundary instead. Facts: [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution). diff --git a/documentation/authoring/blueprints/react-web.md b/documentation/authoring/blueprints/react-web.md index 5bdbe36d1..e617da906 100644 --- a/documentation/authoring/blueprints/react-web.md +++ b/documentation/authoring/blueprints/react-web.md @@ -32,23 +32,23 @@ guide: ../../guides/integrating-the-react-web-sdk-in-a-react-app.md ## Section map -| Section | Category | Purpose | Must teach or show | Fact sources | -| ---------------------------------------------- | ------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| How the SDK fits your app | Required for first integration | Establish the single root and provider composition. | Root diff; config ownership; one-mount rule. | [setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding) | -| SDK readiness, loading, and error states | Required for first integration | Make the asynchronous browser lifecycle explicit. | Readiness timeline; loading/error UI; baseline-reveal behavior. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior) | -| Fetching Contentful entries | Required for first integration | Explain manual and managed entry sources. | Manual `baselineEntry`; managed `entryId` plus `entryQuery`; object descriptors under `managedEntry={{ contentType, slug, slugField?, entryQuery? }}` for components and hooks; default slug field; refetch behavior; exact not-found/duplicate errors; real `sys.id`; client/locale/include ownership. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | -| Resolving entries and rendering the result | Required for first integration | Complete Milestone 1 at the component boundary. | Render prop; one skeleton union and narrowing; host/double-wrap rules; fallback. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution) | -| Page events and route tracking | Required for first integration | Keep route-based evaluation current. | Router tracker example; first-route behavior; one-tracker rule. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | -| Consent and privacy handoff | Common but policy-dependent | Replace the quick-start shortcut. | Two-axis consent example; pre-consent admission; app-owned record. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | -| Entry interaction tracking | Common but policy-dependent | Explain component-owned view/click/hover instrumentation. | Defaults and opt-outs; consent relationship. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | -| Identity, profile, and reset | Common but policy-dependent | Integrate authentication lifecycle. | Identify/reset example and consent persistence distinction. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | -| Run work before the initial page decision | Optional | Sequence returned work before root-owned page tracking. | Adaptable owned-root `beforeInitialPage` alternative to the normal router tracker; define the initial page decision at first use; plain-language retained-lifetime, after-live-owned-runtime invocation, `identify`/`screen`/`track`, Promise/thenable, direct-attempt, emitter, initial-`skip`, and `{ accepted: false }` definitions; multiple awaited operations represented by one returned Promise; callback -> latest direct page attempt or same-route handoff skip -> attempted-route initial `skip` mark -> later-route `emit`; app ownership of stable `routeKey` and lazy `buildPagePayload` versus eager `initialPagePayload`; sole-page-owner rule across every later tracker example/check; watchdog domain and exact invalid-config failure; callback/page failure continuation only while mounted on the current live runtime; fire-and-forget, non-cancellation, verified in-flight route-interleaving, and default-freeze limits; visible five-second fallback/freeze callout with live-update remedy; minimal inline development-only accepted/blocked observer with cleanup and a performable ordering/duplicate-page check; injected-provider, analytics, direct Web, and direct Node boundaries. | [setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [fallback](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior), [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | -| Live updates | Optional | Implement Milestone 2 without implying it is default. | Root and per-entry scopes; state-change example. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | -| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Hook/render helper examples and event behavior. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution) | -| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Early subscription seam and message dedupe; guide link. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | -| Preview panel | Optional | Add environment-gated authoring tooling. | Separate-package dynamic attach and readiness seam. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | -| Owning the Web SDK instance | Advanced or production-only | Explain explicit provider composition and SSR handoff. | Injected instance; provider stack; direct ID/slug prefetch descriptors; slug handoff nests its descriptor under `managedEntry`, retains the fetched `sys.id` as `entryId`, and matches the same effective source values. | [components](../../internal/sdk-knowledge/web/react-web.md#components--hooks), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | -| Strict consent, storage, and delivery controls | Advanced or production-only | Expose production posture. | Empty allow-list; storage/queue settings; blocked diagnostics. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | +| Section | Category | Purpose | Must teach or show | Fact sources | +| ---------------------------------------------- | ------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| How the SDK fits your app | Required for first integration | Establish the single root and provider composition. | Root diff; config ownership; one-mount rule. | [setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding) | +| SDK readiness, loading, and error states | Required for first integration | Make the asynchronous browser lifecycle explicit. | Readiness timeline; loading/error UI; baseline-reveal behavior. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior) | +| Fetching Contentful entries | Required for first integration | Explain manual and managed entry sources. | Manual `baselineEntry`; managed `entryId` plus `entryQuery`; object descriptors under `managedEntry={{ contentType, slug, slugField?, entryQuery? }}` for components and hooks; default slug field; refetch behavior; exact not-found/duplicate errors; real `sys.id`; client/locale/include ownership. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | +| Resolving entries and rendering the result | Required for first integration | Complete Milestone 1 at the component boundary. | Render prop; one skeleton union and narrowing; host/double-wrap rules; fallback. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution) | +| Page events and route tracking | Required for first integration | Keep route-based evaluation current. | Router tracker example; first-route behavior; one-tracker rule. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | +| Consent and privacy handoff | Common but policy-dependent | Replace the quick-start shortcut. | Two-axis consent example; pre-consent admission; app-owned record. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | +| Entry interaction tracking | Common but policy-dependent | Explain component-owned view/click/hover instrumentation. | Defaults and opt-outs; consent relationship. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | +| Identity, profile, and reset | Common but policy-dependent | Integrate authentication lifecycle. | Identify/reset example and consent persistence distinction. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | +| Run work before the initial page decision | Optional | Sequence returned work before root-owned page tracking. | Adaptable owned-root `beforeInitialPage` alternative to the normal router tracker; define the initial page decision at first use; plain-language retained-lifetime, after-live-owned-runtime invocation, `identify`/`screen`/`track`, Promise/thenable, direct-attempt, emitter, initial-`skip`, and `{ accepted: false }` definitions; multiple awaited operations represented by one returned Promise; a prepared replay bypasses the callback and follows the normal handoff queue path; without replay, callback -> latest direct page attempt -> attempted-route initial `skip` mark -> later-route `emit`; app ownership of stable `routeKey` and lazy `buildPagePayload` versus eager `initialPagePayload`; sole-page-owner rule across every later tracker example/check; watchdog domain and exact invalid-config failure; callback/page failure continuation only while mounted on the current live runtime; fire-and-forget, non-cancellation, verified in-flight route-interleaving, and default-freeze limits; visible five-second fallback/freeze callout with live-update remedy; minimal inline development-only accepted/blocked observer with cleanup and a performable ordering/duplicate-page check; injected-provider, analytics, direct Web, and direct Node boundaries. | [setup](../../internal/sdk-knowledge/web/react-web.md#setup--initialization-and-binding), [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [fallback](../../internal/sdk-knowledge/web/react-web.md#failure--fallback-behavior), [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | +| Live updates | Optional | Implement Milestone 2 without implying it is default. | Root and per-entry scopes; state-change example. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | +| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Hook/render helper examples and event behavior. | [rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution) | +| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Early subscription seam and message dedupe; guide link. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | +| Preview panel | Optional | Add environment-gated authoring tooling. | Separate-package dynamic attach and readiness seam. | [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | +| Owning the Web SDK instance | Advanced or production-only | Explain explicit provider composition and SSR handoff. | Injected instance; provider stack; direct ID/slug prefetch descriptors; slug handoff nests its descriptor under `managedEntry`, retains the fetched `sys.id` as `entryId`, and matches the same effective source values. | [components](../../internal/sdk-knowledge/web/react-web.md#components--hooks), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | +| Strict consent, storage, and delivery controls | Advanced or production-only | Expose production posture. | Empty allow-list; storage/queue settings; blocked diagnostics. | [consent](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | ## SDK-specific authoring overrides diff --git a/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md b/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md index bbcdfc67c..c0251f62a 100644 --- a/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md +++ b/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md @@ -30,14 +30,14 @@ guide: ../../guides/migrating-experience-js-next-to-nextjs-app-router.md ## Section plan -| Section | Purpose | Must route to | Fact sources | -| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Remove legacy Next.js package assumptions | Prevent writers from treating ESR middleware/selector source as supported imports. | Legacy package mapping and unsupported boundaries. | [package mapping](../../internal/migration-knowledge/experience-js.md#package-mapping), [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries) | -| Install and bind the App Router SDK | Restore the explicit server binding, nested request family, and forwarding-only handler before replacing render calls. | App Router setup, package, and runtime sections. | [package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | -| Replace SSR/ESR profile continuity | Move profile cookie and request evaluation to the SDK-owned request family while consent remains app-owned. | Request context, identifiers, consent, and first-event ownership. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [legacy identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence), [target identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | -| Replace server-rendered personalization | Route legacy mapped experiences and React wrappers to target entry resolution and server first paint. | App Router rendering and content-model migration. | [React legacy](../../internal/migration-knowledge/experience-js.md#react-render-and-hooks), [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | -| Replace browser takeover features | Move flags, analytics forwarding, preview, and live updates through the target client runtime. | App Router browser/client sections and supplemental plugin guide. | [plugins](../../internal/migration-knowledge/experience-js.md#plugins-and-preview), [flag views](../../internal/sdk-knowledge/shared/concepts.md#custom-flag-views), [event streams](../../internal/sdk-knowledge/shared/concepts.md#stateful-event-forwarding-streams), [preview](../../internal/sdk-knowledge/shared/concepts.md#preview-overrides), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | -| Validate App Router migration | Verify server HTML, hydration stability, profile continuity, event ownership, and cache safety. | App Router production checks and troubleshooting. | [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | +| Section | Purpose | Must route to | Fact sources | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Remove legacy Next.js package assumptions | Prevent writers from treating ESR middleware/selector source as supported imports. | Legacy package mapping and unsupported boundaries. | [package mapping](../../internal/migration-knowledge/experience-js.md#package-mapping), [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries) | +| Install and bind the App Router SDK | Restore the explicit server binding, nested request family, and forwarding-only handler before replacing render calls. | App Router setup, package, and runtime sections. | [package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | +| Replace SSR/ESR profile continuity | Move profile cookie and request preparation to the SDK-owned request family while consent remains app-owned. The server uses per-request preflight for personalized rendering; browser takeover commits the prepared batch through the SDK queue. Preserve API-issued profile identity and do not treat preview as delivery. | Request context, identifiers, consent, and browser commitment. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [legacy identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence), [target identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | +| Replace server-rendered personalization | Route legacy mapped experiences and React wrappers to target entry resolution and server first paint. | App Router rendering and content-model migration. | [React legacy](../../internal/migration-knowledge/experience-js.md#react-render-and-hooks), [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Replace browser takeover features | Move flags, analytics forwarding, preview, and live updates through the target client runtime. | App Router browser/client sections and supplemental plugin guide. | [plugins](../../internal/migration-knowledge/experience-js.md#plugins-and-preview), [flag views](../../internal/sdk-knowledge/shared/concepts.md#custom-flag-views), [event streams](../../internal/sdk-knowledge/shared/concepts.md#stateful-event-forwarding-streams), [preview](../../internal/sdk-knowledge/shared/concepts.md#preview-overrides), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | +| Validate App Router migration | Verify server HTML, hydration stability, profile continuity, event ownership, and cache safety. | App Router production checks and troubleshooting. | [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | ## Handoffs diff --git a/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md b/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md index 4fe4f9224..b1a4bd5d0 100644 --- a/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md +++ b/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md @@ -15,8 +15,9 @@ guide: ../../guides/migrating-experience-js-next-to-nextjs-pages-router.md page tracking, and target entry resolution. - **Guide file:** `documentation/guides/migrating-experience-js-next-to-nextjs-pages-router.md` - **Write after:** `choosing-a-nextjs-migration-path-from-experience-js.md`. -- **First verification:** One Pages Router server data path renders an all-visitors variant and - verifies accepted server evaluation plus denied-consent behavior. +- **First verification:** One Pages Router server data path renders an all-visitors variant; a + browser observer confirms the prepared page batch is admitted when consent permits, and denied + consent blocks admission. Treat server preflight as a rendering preview, not committed delivery. ## Migration route @@ -29,14 +30,14 @@ guide: ../../guides/migrating-experience-js-next-to-nextjs-pages-router.md ## Section plan -| Section | Purpose | Must route to | Fact sources | -| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Inventory legacy Pages Router wiring | Identify provider placement, tracker override, SSR plugin use, and profile cookie assumptions. | Legacy Next.js and identifiers. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence) | -| Install and bind the Pages Router SDK | Establish the target package, browser binding, and `bindNextjsPagesRouterServerOptimization` before rendering changes. | Pages setup and runtime sections. | [package](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding), [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks) | -| Replace server profile and page evaluation | Replace the legacy SSR plugin with the returned `createRequestHandoff` call inside `getServerSideProps`; define any app-owned wrapper before use and preserve duplicate-event controls. | Pages events, identifiers, and consent. | [legacy SSR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [target identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [page events](../../internal/sdk-knowledge/shared/concepts.md#page-events) | -| Replace personalized rendering | Move legacy React components and Contentful mapper output to Pages Router entry resolution. | Pages rendering and content-model migration. | [React legacy](../../internal/migration-knowledge/experience-js.md#react-render-and-hooks), [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | -| Replace client-side extras | Route flags, analytics forwarding, preview, and consent diagnostics through React Web client behavior. | Pages/React Web events plus supplemental plugin guide. | [plugins](../../internal/migration-knowledge/experience-js.md#plugins-and-preview), [flag views](../../internal/sdk-knowledge/shared/concepts.md#custom-flag-views), [event streams](../../internal/sdk-knowledge/shared/concepts.md#stateful-event-forwarding-streams), [preview](../../internal/sdk-knowledge/shared/concepts.md#preview-overrides) | -| Validate Pages Router migration | Verify server props, rendered variant/baseline, tracker ownership, profile continuity, and cache boundaries. | Pages production checks and troubleshooting. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| Section | Purpose | Must route to | Fact sources | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Inventory legacy Pages Router wiring | Identify provider placement, tracker override, SSR plugin use, and profile cookie assumptions. | Legacy Next.js and identifiers. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence) | +| Install and bind the Pages Router SDK | Establish the target package, browser binding, and `bindNextjsPagesRouterServerOptimization` before rendering changes. | Pages setup and runtime sections. | [package](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points), [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding), [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks) | +| Replace server profile and page evaluation | Replace the legacy SSR plugin with the returned `createRequestHandoff` call inside `getServerSideProps`; the server prepares initial Experience events and uses per-request preflight for rendering, while the browser commits the prepared batch through the SDK queue. Preserve API-issued profile identity, define any app-owned wrapper before use, and remove legacy duplicate-event controls based on server commit/skip. | Pages events, identifiers, and consent. | [legacy SSR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [target identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [page events](../../internal/sdk-knowledge/shared/concepts.md#page-events) | +| Replace personalized rendering | Move legacy React components and Contentful mapper output to Pages Router entry resolution. | Pages rendering and content-model migration. | [React legacy](../../internal/migration-knowledge/experience-js.md#react-render-and-hooks), [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Replace client-side extras | Route flags, analytics forwarding, preview, and consent diagnostics through React Web client behavior. | Pages/React Web events plus supplemental plugin guide. | [plugins](../../internal/migration-knowledge/experience-js.md#plugins-and-preview), [flag views](../../internal/sdk-knowledge/shared/concepts.md#custom-flag-views), [event streams](../../internal/sdk-knowledge/shared/concepts.md#stateful-event-forwarding-streams), [preview](../../internal/sdk-knowledge/shared/concepts.md#preview-overrides) | +| Validate Pages Router migration | Verify server props, rendered variant/baseline, tracker ownership, profile continuity, and cache boundaries. | Pages production checks and troubleshooting. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | ## Handoffs diff --git a/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md b/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md index 14d56f02d..3e3f89310 100644 --- a/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md +++ b/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md @@ -21,22 +21,22 @@ guide: ../../guides/migrating-experience-js-node-ssr-and-esr.md ## Migration route -| Legacy surface | Target route | Detail owner | -| ------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `NinetailedAPIClient` | Node SDK request client and event calls | [Node blueprint](../blueprints/node.md) | -| SSR plugin profile handoff | App-owned cookie/profile continuity | [Node identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership) | -| ESR helper preflight behavior | App Router SDK where possible; Node/Web hybrid otherwise | Next.js router-choice migration | -| Server-rendered content mapping | Node entry resolution or framework SDK rendering | [Node rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | +| Legacy surface | Target route | Detail owner | +| ------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `NinetailedAPIClient` | Node SDK request client and event calls | [Node blueprint](../blueprints/node.md) | +| SSR plugin profile handoff | App-owned cookie/profile continuity | [Node identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership) | +| ESR helper preflight behavior | Per-request Next.js/Edge preparation and browser queue commitment | Next.js router-choice migration | +| Server-rendered content mapping | Node entry resolution or framework SDK rendering | [Node rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | ## Section plan -| Section | Purpose | Must route to | Fact sources | -| -------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Inventory server profile and event ownership | Identify which server path creates profiles, emits page events, and persists visitor identity. | Legacy Node, SSR, ESR, and identifiers. | [package mapping](../../internal/migration-knowledge/experience-js.md#package-mapping), [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence) | -| Replace Node API client calls | Move server mutations and event handling to request-scoped Node SDK calls. | Node setup, context, events, and consent sections. | [legacy runtime](../../internal/migration-knowledge/experience-js.md#legacy-runtime-surfaces), [Node setup](../../internal/sdk-knowledge/node/node.md#setup--initialization-and-binding), [Node events](../../internal/sdk-knowledge/node/node.md#events--tracking), [Node consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | -| Replace SSR and ESR handoff | Pick framework SDK when available and keep manual hybrid as an escape hatch, not the default. | Next.js router-choice guide; Node/Web continuity sections. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries), [Node identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership), [Web identifiers](../../internal/sdk-knowledge/web/web.md#identifier-ownership) | -| Replace server content resolution | Move from legacy experience-config mapping to target entry resolution and fallback behavior. | Node rendering and content-model migration guide. | [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries), [Node rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | -| Validate server migration | Verify accepted or blocked events, profile continuity, rendered content, and cache safety. | Node production checks and troubleshooting. | [Node events](../../internal/sdk-knowledge/node/node.md#events--tracking), [Node fallback](../../internal/sdk-knowledge/node/node.md#failure--fallback-behavior), [Node runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | +| Section | Purpose | Must route to | Fact sources | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Inventory server profile and event ownership | Identify which server path prepares or commits page events and persists API-issued visitor identity. Distinguish standalone Node delivery from paired request preview followed by browser queue commitment. | Legacy Node, SSR, ESR, and identifiers. | [package mapping](../../internal/migration-knowledge/experience-js.md#package-mapping), [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence) | +| Replace Node API client calls | Move server mutations and event handling to request-scoped Node SDK calls. | Node setup, context, events, and consent sections. | [legacy runtime](../../internal/migration-knowledge/experience-js.md#legacy-runtime-surfaces), [Node setup](../../internal/sdk-knowledge/node/node.md#setup--initialization-and-binding), [Node events](../../internal/sdk-knowledge/node/node.md#events--tracking), [Node consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | +| Replace SSR and ESR handoff | Pick framework SDK when available and keep manual hybrid as an escape hatch, not the default. | Next.js router-choice guide; Node/Web continuity sections. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries), [Node identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership), [Web identifiers](../../internal/sdk-knowledge/web/web.md#identifier-ownership) | +| Replace server content resolution | Move from legacy experience-config mapping to target entry resolution and fallback behavior. | Node rendering and content-model migration guide. | [content model](../../internal/migration-knowledge/experience-js.md#contentful-model-and-mapper), [unsupported boundaries](../../internal/migration-knowledge/experience-js.md#unsupported-or-manual-migration-boundaries), [Node rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution), [entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Validate server migration | Verify accepted or blocked events, profile continuity, rendered content, and cache safety. | Node production checks and troubleshooting. | [Node events](../../internal/sdk-knowledge/node/node.md#events--tracking), [Node fallback](../../internal/sdk-knowledge/node/node.md#failure--fallback-behavior), [Node runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | ## Handoffs diff --git a/documentation/authoring/recipes/integration.md b/documentation/authoring/recipes/integration.md index 51db4bd61..2817f8c33 100644 --- a/documentation/authoring/recipes/integration.md +++ b/documentation/authoring/recipes/integration.md @@ -26,6 +26,23 @@ Three artifacts own three different things — keep them separate: compose time; the blueprint makes the required coverage explicit, and the source verifier checks the claims the guide makes. +For a paired server/browser integration, distinguish server preview from event commitment. The +server prepares the initial Experience events and uses the per-request preflight option when it +evaluates the request for personalized rendering. The browser receives the prepared handoff and +admits the complete event batch through the SDK's existing queue; queue admission is separate from +the eventual HTTP response. Do not model paired delivery as a server commit followed by a browser +skip: the browser submits the prepared sequence, including its page event, as one batch. A matching +replay admitted by the queue suppresses a separate ordinary-page submission. Profile IDs come from Experience API +responses; never describe an SDK-generated profile ID. +Give the reader a replay-specific verification step, such as a distinctive prepared event next to +the page in the same browser batch. A page-only browser request cannot distinguish prepared replay +from ordinary page tracking after a missing or mismatched handoff. +For a cross-runtime recipe, show the prepared input shape, the returned render data and handoff, +the transfer into browser code, and the browser continuation together, or link the exact guide +section that does. When optional page owners differ, qualify later consent, validation, and +troubleshooting steps by mode. Distinguish a recoverable preview result from an error thrown by +application fetching or rendering code. + Shared-copy files contain small pieces of reader-facing wording reused verbatim. Any variation choice is explicit in the blueprint; shared copy must not infer an SDK family or restate behavior. 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 e55656f49..da8673e71 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -432,11 +432,12 @@ delivery. Choose one of these patterns before enabling interaction tracking: call the Node SDK with the request's profile ID. This is a manual tracking architecture, not the Web SDK auto-tracking path. -In Next.js server-rendered integrations, `initialPageEvent="skip"` intentionally avoids the initial -browser Experience API `page()` request when the server or edge helper already accepted that page -event. If that skip leaves the browser with neither a bound root or provider `handoff` nor manual -handoff, 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. +In Next.js private request handoffs, the server prepares the initial page event and previews the +result for rendering. The browser admits the prepared event batch, including that page, to its normal +Experience queue. Keep the request handoff with the page rendered from its preview so the matching +browser route can commit the batch and hydrate the profile state used by automatic entry tracking. +If preview fails before an API-issued profile ID is available, the prepared batch can still reach the +browser and use the API's profile-creation path when admitted. 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/interaction-tracking-in-web-sdks.md b/documentation/concepts/interaction-tracking-in-web-sdks.md index 9937588ca..fc49907aa 100644 --- a/documentation/concepts/interaction-tracking-in-web-sdks.md +++ b/documentation/concepts/interaction-tracking-in-web-sdks.md @@ -434,13 +434,17 @@ React Web router adapters emit `page()` calls when supported routers change rout event helpers, not entry interaction detectors. Entry views, clicks, and hovers still come from the Web SDK runtime. -`OptimizationRoot` and `OptimizationAnalyticsRoot` use handoff `initialPageEvent` ownership for the -first browser route. When an analytics-only handoff skips that route, React StrictMode effect replay -does not emit a duplicate page event; later route-key changes still emit through the analytics -runtime. If the analytics root unmounts or a newer hydration starts before async hydration finishes, -the stale hydration stops before state apply, warning, or page tracking. Profileless static or -public analytics handoffs hydrate live tracking state without overwriting durable browser -continuity. +`OptimizationRoot` hydrates content handoff state through the Web SDK. A matching request replay is +admitted as one prepared Experience batch. When the handoff has no matching replay, the root emits +an ordinary page only when supplied with `buildPagePayload` or `initialPagePayload`. Otherwise, the +router tracker owns that fallback and uses its `pagePayload` and `getPagePayload` settings. + +`OptimizationAnalyticsRoot` hydrates analytics handoff state and admits a matching replay or emits +an ordinary page through the Web SDK. Repeated calls for the current initialization share its work, +while later visits and distinct handoffs remain trackable. If the analytics root unmounts or a newer +hydration starts before async hydration finishes, the stale hydration stops before state apply, +warning, or page tracking. Profileless static or public analytics handoffs hydrate live tracking +state without overwriting durable browser continuity. ## Delivery and flushing diff --git a/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md b/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md index bb522060b..fc662d1c2 100644 --- a/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md +++ b/documentation/concepts/ios-sdk-runtime-and-interaction-mechanics.md @@ -328,8 +328,8 @@ event-delivery model for SwiftUI and UIKit integrations. Default delivery behavior: - Experience events queue in memory when the device is offline. -- Core caps the offline Experience queue at 100 events by default. When the queue is full, it drops - the oldest offline events before accepting the next event. +- Core caps the offline Experience queue at 100 events by default. When the queue is full, it rejects + the incoming event and retains previously accepted events in order. - Insights events batch in memory by profile and flush periodically or when the batch reaches the Core batch threshold. - Queued events flush when connectivity returns. On iOS/UIKit, the SDK also performs an online @@ -343,8 +343,9 @@ interactions while an entry session remains visible. Use `OptimizationConfig(queuePolicy:)` when production behavior needs non-default delivery constraints. `QueuePolicy.offlineMaxEvents` changes the Experience offline cap, `QueueFlushPolicy` -configures retry timing, backoff, jitter, failure thresholds, and circuit-open timing, and queue -callbacks report offline drops, flush failures, circuit-open events, and recovery. Call +configures retry timing, backoff, jitter, failure thresholds, and circuit-open timing. The +`onOfflineDrop` compatibility callback is deprecated and inactive; other queue callbacks report +flush failures, circuit-open events, and recovery. Call `client.flush()` only for deliberate app-owned checkpoints; reconnect flushing and iOS/UIKit background flushing already perform best-effort delivery. diff --git a/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md b/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md index ef7876e13..74c10d868 100644 --- a/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md +++ b/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md @@ -28,7 +28,7 @@ from live updates, and how analytics-only markup can still carry Optimization tr - [Cache scopes](#cache-scopes) - [Customer-owned permutations](#customer-owned-permutations) - [Hydration and live updates](#hydration-and-live-updates) -- [Initial page event ownership](#initial-page-event-ownership) +- [Server preparation and browser commitment](#server-preparation-and-browser-commitment) - [Analytics-only handoff and tracking attributes](#analytics-only-handoff-and-tracking-attributes) - [Why profile state stays out of public caches](#why-profile-state-stays-out-of-public-caches) - [Related documentation](#related-documentation) @@ -65,10 +65,14 @@ It can contain: `sys.id` in `entryId`. - `cache` - metadata that describes where the rendered output is allowed to be cached. -Browser handoffs add two fields: +Browser handoffs add one field: - `hydration` - the browser presentation policy for already-rendered content. -- `initialPageEvent` - whether the browser emits or skips the first page event for this route. + +A request handoff can also contain `replay`, an ordered batch of prepared Experience events and the +visible route key they belong to. A matching browser route hydrates the handoff and attempts to admit +that batch to the normal Experience queue. A route without a matching replay follows ordinary browser +page tracking. The SDK serializes and hydrates the state it receives. Browser hydration applies only state fields that are present on the handoff. During Web handoff state interception, omitted interceptor fields @@ -100,8 +104,8 @@ The handoff is not a cache key by itself. Cache safety comes from matching the r handoff state, and the cache scope. In App Router, managed-entry prefetch without a supplied handoff creates a baseline `static` handoff -with `hydration: 'preserve-server'`, `selectedOptimizations: []`, and -`initialPageEvent: 'emit'`. Treat it as baseline entry warming, not request-personalized state. +with `hydration: 'preserve-server'` and `selectedOptimizations: []`. Treat it as baseline entry +warming, not request-personalized state. Prefetch accepts ID and content-type/slug descriptors. A matching browser source uses the handed-off baseline through either the source key or resolved `sys.id`, so it does not repeat the CDA request. @@ -193,7 +197,9 @@ becomes the browser commitment: it is the first content shown, and with live upd place. Later selections, pending state, and failure state do not replace it or restore loading. With live updates on, later defined selections can replace it; an empty `selectedOptimizations` array (`[]`) means no selection applies and resolves baseline content with matching baseline tracking -metadata. A different baseline entry ID starts a new presentation. +metadata. A different baseline entry ID starts a new presentation. This presentation commitment is +separate from event commitment: rendering handoff state does not mean the browser has admitted or +delivered the prepared event batch. `liveUpdates` controls later browser re-resolution after startup. A route can preserve the rendered content for stable first paint and still keep live updates off. Turn live updates on when visible @@ -206,30 +212,35 @@ existing durable browser profile continuity by suppressing durable continuity pe handoff. A `private-request` handoff, or any profile-backed handoff that passes cache safety, follows normal persistence behavior when persistence consent allows. -## Initial page event ownership - -The first page event must have one owner. - -- Use `initialPageEvent: 'skip'` when a request or edge helper already accepted the first page - event for the same route. -- Use `initialPageEvent: 'emit'` when the browser owns the first page event for a static, - public-permutation, or browser-owned route. - -Next.js request helpers set this value from the accepted page event result. The App Router request -family passes its handoff-owned value to the nested route tracker. When the binding opts into trusted -request handoff, a response-capable request handler can forward `pageAccepted` so the Server -Component path does not call `page()` a second time. That forwarded context is compact: `consent`, -`pageAccepted`, and optional `profileId`. The request family refetches profile and selection state -server-side when `profileId` is present instead of forwarding full `OptimizationData`. Manual -`createRequestHandoff()` remains available for advanced orchestration. Selection handoff helpers -require application code to supply the initial page-event owner because customer-owned static and -public permutations do not emit a server request event by themselves. - -React Web roots can emit the handoff-owned initial page event when they receive `routeKey` and -either `buildPagePayload` or `initialPagePayload`. A skip can mark the initial route accepted with -only the route key. A skip applies only to the first route hydrated from that handoff; later -route-key changes emit browser page events. Next.js route trackers use the same `"emit"` or -`"skip"` control for the first browser route and then track later navigations. +## Server preparation and browser commitment + +For a private request, the server builds the initial `identify` and `track` events in order, followed +by one page event. It applies server event transformations and validates the batch before previewing +it with the Experience API. The preview uses the per-request mutation option `preflight: true`, so +the API returns evaluated profile data for rendering without storing those event mutations. The +handoff carries the prepared events and visible route key to the browser. + +On the matching browser route, the Web SDK hydrates presentation state and admits the prepared batch +to its normal Experience queue. Admission is the browser's commitment point; the request-side +preview is not event commitment. The queue applies current consent and handles delivery, retries, +and response state through the same path as other Experience events. A repeated invocation for the +current handoff joins current work instead of admitting the batch again. This guard covers the +current initialization, not every future visit to the route. Later visits and distinct handoffs +remain trackable. + +If the handoff has no replay for the current route, the browser builds and tracks an ordinary page +event subject to current consent. If a matching prepared batch fails validation or admission, the +Web SDK attempts ordinary current-page tracking with the supplied payload builder or its default. +That attempt remains subject to the current route, lifecycle, deduplication, consent, and queue +guards. An admitted replay stays in the queue for delivery and retries; an HTTP failure does not +trigger an ordinary page attempt. Admission means the queue owns the batch; it does not guarantee +exactly-once backend commitment when a response is lost. + +Static and public-permutation handoffs contain selected state and cache metadata, not request replay. +Their browser route tracking follows the ordinary page path. App Router request components create +the request handoff from one request-scoped preparation and pass it to the root or provider that +renders the matching page. The framework must deliver the page's handoff across navigation; +request memoization alone does not transfer a new handoff through a persistent layout. ## Analytics-only handoff and tracking attributes @@ -242,11 +253,10 @@ browser SDK only for page and interaction tracking. Those routes use an analytic - The `data-ctfl-*` attributes describe the resolved entry, baseline entry, optimization context, variant index, sticky selection, and clickable state. -When an analytics-only handoff skips the initial route, React StrictMode effect replay does not -turn that skip into a duplicate browser page event. Later route-key changes still emit route events -through the analytics runtime. If a newer analytics hydration starts or the root unmounts before -async hydration finishes, the stale hydration stops before writing state, warning, or tracking the -page. +Analytics-only handoffs can also carry a prepared replay. The analytics runtime hydrates tracking +state and admits a matching replay, or tracks the ordinary current page when no matching replay is +present. If a newer analytics hydration starts or the root unmounts before async hydration finishes, +the stale hydration stops before writing state, warning, or tracking the page. Analytics-only rendering still needs the same cache decision as the markup it tracks. A static analytics handoff is static; a public permutation needs an application-owned key; request-personalized diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 1eb18e72b..4efafba80 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -101,9 +101,11 @@ profile-changing events: - **Storage availability** - Browser localStorage, readable cookies, AsyncStorage, UserDefaults, or SharedPreferences must be available for relaunch or client-server handoff continuity. Storage failure does not change the Experience API source of truth, but it can break local durability. -- **Preview and preflight mode** - Preview and preflight flows can evaluate profile state without - storing normal profile mutations. Do not use them as the durability path for a profile ID you - intend to continue. +- **Request preview** - A private request handoff prepares its initial Experience events and uses + the per-request `preflight: true` mutation option to evaluate profile state for server rendering + without storing those mutations. The handoff carries the prepared events to the browser, where + the normal queue admits them for commitment. The Experience API supplies the profile ID; the SDK + does not create a placeholder identity when preview cannot return one. - **Offline behavior** - Stateful clients can queue Experience events while offline, but no newer profile data is available until a request succeeds. - **Configured defaults** - `defaults.profile`, `defaults.selectedOptimizations`, @@ -304,9 +306,13 @@ Consent policy belongs to the application layer on the server. A conservative se - When consent is revoked, clear the stored profile ID and stop sending events until consent is granted again. -If the server uses `preflight: true`, the Experience API evaluates a profile state without storing -the mutation. Use that for preview or evaluation flows, not as the normal continuity path for a -profile ID you intend to persist. +For private server-rendered handoffs, request preview is the rendering step, not the commitment +step. The server prepares the initial event batch and previews it with the Experience API's +per-request `preflight: true` option. The browser later admits that same prepared batch to its normal +Experience queue. When the API returns a profile ID and persistence consent allows it, the +application can persist that API-issued ID independently of event commitment. If preview fails +before an ID is available, the handoff can still carry its prepared batch without an ID; the browser +can submit it through the API's profile-creation path. ## Browser-side mechanics @@ -510,17 +516,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 private App Router rendering, use the request root or provider so the SDK supplies the request handoff. For top-level App Router selection or manual paths, pass the explicit handoff. For Pages Router, pass `pageProps.contentfulOptimization.handoff` through the bound root or provider. For React Web and manual Next.js provider or root handoff, use the `handoff` prop. | -| 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 private App Router rendering, use the request root or provider so the SDK supplies the request handoff. For top-level App Router selection or manual paths, pass the explicit handoff. For Pages Router, pass `pageProps.contentfulOptimization.handoff` through the bound root or provider. For React Web and manual Next.js provider or root handoff, use the `handoff` prop. | +| Server preview succeeds but browser commitment is pending | The rendered profile data comes from preview; the prepared events remain uncommitted until the browser admits them. | Keep the prepared request handoff with the matching rendered page and let the browser's normal Experience queue own admission and delivery. | +| 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 diff --git a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md index 3bd0a50d8..74269004b 100644 --- a/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md +++ b/documentation/concepts/react-native-sdk-interaction-tracking-mechanics.md @@ -207,9 +207,10 @@ The React Native SDK layers React Native-specific behavior on top: session resets without an event. Foregrounding re-evaluates visibility and starts a new session when the entry still meets the visibility threshold. -`queuePolicy.offlineMaxEvents` caps offline Experience events, and `queuePolicy.onOfflineDrop` is -called when older offline Experience events are dropped to honor that cap. Insights events share the -flush policy, but these two offline drop controls belong to the Experience queue. See the +`queuePolicy.offlineMaxEvents` caps offline Experience events at 100 by default. Once full, the +queue rejects an incoming event and keeps previously accepted events in order. A rejected call +returns `{ accepted: false }`; `onOfflineDrop` is a deprecated compatibility option and is not +called. Insights events share the flush policy, but the offline cap belongs to the Experience queue. See the [React Native SDK README](../../packages/react-native-sdk/README.md#common-configuration) for the common queue configuration entry point. @@ -515,8 +516,8 @@ for exhaustive prop and type details. even when `trackClick` is not allowed. - The preview panel forces live entry updates while it is open so authoring overrides can render immediately. -- `queuePolicy.flush` controls shared retry, backoff, and circuit behavior. `offlineMaxEvents` and - `onOfflineDrop` apply to the offline Experience buffer. +- `queuePolicy.flush` controls shared retry, backoff, and circuit behavior. `offlineMaxEvents` caps + the offline Experience buffer; `onOfflineDrop` is deprecated and inactive. Use `onStatesReady` when diagnostics or app-level observers must attach as soon as SDK state exists and before provider children can emit `screen`, `eventStream`, or `blockedEventStream` updates. diff --git a/documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md b/documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md index fff0da647..263102bf3 100644 --- a/documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md +++ b/documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md @@ -52,7 +52,7 @@ Use these terms consistently: 1. Classify the current router and legacy SSR/ESR surfaces. 2. Choose the target package by the route that owns personalization. -3. Decide which layer owns the first page event. +3. Plan server preview and browser commitment for the first page event. 4. Decide how profile continuity moves from `ntaid` to the target `ctfl-opt-aid` policy. 5. Follow the selected runtime migration guide. @@ -89,27 +89,28 @@ never share personalized output across visitors. Use the highest-level adapter that matches the app. In an App Router request path, the server binding's nested `optimization.request` family owns request initialization, provider state handoff, -and first-page tracking that a manual hybrid would otherwise need to rebuild. +and browser page tracking that a manual hybrid would otherwise need to wire. -### Route SSR and first page event ownership +### Route server preview and browser page commitment -Avoid duplicate page evaluation. Legacy Next tracking emits page events on the first route and on -route changes, while SSR helpers can also evaluate the first request. +Legacy Next tracking can emit page events on the first route and route changes, while SSR helpers +can also evaluate the first request. Remove legacy first-route calls as you adopt the target path. In the target App Router path, the no-argument request handler only forwards the original request URL and sanitized request context. The server binding's nested `optimization.request` family -evaluates the request, creates the handoff, and gives its `NextAppAutoPageTracker` first-page-event -ownership automatically. Mount that tracker inside `optimization.request.OptimizationRoot`; do not -create or pass a handoff or `initialPageEvent` prop for this ordinary request-family path. +prepares events, previews request state for server rendering, and creates a handoff. Mount its +`NextAppAutoPageTracker` inside `optimization.request.OptimizationRoot`. The browser commits the +matching prepared events through the SDK queue and tracks later navigation. The ordinary request +family supplies the handoff and route inputs itself. In the target Pages Router path, bind the server SDK with `bindNextjsPagesRouterServerOptimization(config)` and call its returned -`createRequestHandoff(context, options)` inside `getServerSideProps`. The returned handoff records -accepted server evaluation as `handoff.initialPageEvent === 'skip'` and a server path that did not -report the view as `'emit'`. +`createRequestHandoff(context, options)` inside `getServerSideProps`. The server preview supplies +selections for useful first paint when it succeeds; the handoff retains prepared events for browser +delivery even if the preview fails. -Pass that Pages Router handoff to `OptimizationRoot`, which consumes the instruction. Its browser -tracker uses the handoff's `initialPageEvent` value and continues to track later browser navigations. +Pass that Pages Router handoff to `OptimizationRoot`. On the matching route, the browser admits the +prepared events to its queue. The tracker handles routes without a handoff and later navigation. ### Route cookie and profile continuity @@ -126,18 +127,18 @@ contract. ## Validate the migration - The selected guide matches the route that renders personalized content. -- Exactly one layer owns the first page event for the first route. -- The App Router request tracker receives first-page-event ownership automatically; explicit paths - set it intentionally. +- The server preview and browser event delivery are verified separately. +- The App Router request family supplies its handoff to the browser automatically; explicit paths + pass a matching route key and handoff. - Cookie and consent ownership are documented in app code before deleting legacy packages. ## Troubleshooting -| Symptom | Check | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Both server and browser emit the first page event | Use the App Router nested request root and tracker together; reserve explicit `initialPageEvent` plumbing for manual or Pages Router paths. | -| App Router route no longer behaves statically | Request-family personalization reads request data; use a public-permutation, static, or browser-only path if static output is required. | -| ESR migration has no matching import | The legacy ESR package did not export every helper present in source; use the explicit App Router server entry point or a manual Node/Web hybrid. | +| Symptom | Check | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| First page events duplicate | Remove legacy page calls and pass the same handoff and route to the browser root. | +| App Router route no longer behaves statically | Request-family personalization reads request data; use a public-permutation, static, or browser-only path if static output is required. | +| ESR migration has no matching import | The legacy ESR package did not export every helper present in source; use the explicit App Router server entry point or a manual Node/Web hybrid. | ## Related guides diff --git a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md index 75123cdef..c1dbfe48e 100644 --- a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md +++ b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md @@ -648,8 +648,9 @@ Then confirm the broader tracking contract: - The third-party destination receives only the intended `contentful_*` fields. - Sticky view tracking produces one intended downstream exposure, not one Experience exposure plus one Insights exposure. -- Server-rendered first paint and browser follow-up tracking have one owner for each event in the - tracking plan. +- On paired server-rendered pages, the server previews prepared events for first paint and the + browser admits them through the SDK queue. Forward accepted browser events separately from the + server preview; later navigation uses normal browser page tracking. - Known-user identity uses your application user ID. The Optimization profile ID is metadata only when your policy allows forwarding it. - Event-stream `optimization` enrichment is reduced to approved primitive fields, such as audience diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index 8fdc3a2e5..201e5b9ed 100644 --- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md @@ -1031,11 +1031,83 @@ guidance. Add `@contentful/optimization-web` when the browser also needs to participate after server render. Use the Node SDK alone when the server chooses the variant and renders the full response. +For a paired server and browser route, prepare any initial `identify` or `track` events followed by +the page event with `requestOptimization.prepareRequestHandoff({ routeKey, initialEvents, page })`. The +server previews the batch to select content without committing its events. Pass the returned +`handoff` to the browser, then call the Web SDK's +`hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload })` for the same visible route. That +call admits a matching prepared batch to the browser queue; the queue sends it and retries delivery +under the usual policy. If preview fails, render baseline content and retain the prepared replay for +browser commitment. A page blocked by the effective event gate or invalid input produces no replay, so the browser uses an +ordinary page event instead. Keep `routeKey` as the visible pathname plus search on both sides. + +The prepared batch holds at most 100 events total: up to 99 initial `identify` or `track` events +followed by exactly one page, with at most 50 identifies. Invalid batches are rejected before the +server preview request. Browser queue admission also requires capacity for the entire batch, so a +rejection preserves accepted queued events and returns `{ accepted: false }`. + +The preview response can supply a profile ID for a first request. It is issued by the Experience +API, not generated by the SDK. A browser commitment response can return a linked ID. Persist an +available API-issued ID only when persistence consent permits it; an ID need not be available after +a failed preview. The `accepted` result from browser admission means the complete batch entered the +queue, not that HTTP delivery finished. + +**Adapt this to your use case:** in the Express handler from the quick start, use the existing +request-context and cookie helpers defined above, including the cookie parser in +[Persist profile identity between requests](#persist-profile-identity-between-requests). +`app-consent` and `app-user-id` are app-owned +cookies; replace them with your consent and identity policy. `req.originalUrl` supplies the +visible pathname plus search. `getRequestContext()` supplies default page context; the optional +`page` argument below adds or overrides properties for this prepared page. Render `data` through the entry-resolution path earlier in this +guide, and pass the serialized handoff to the browser even when `data` is unavailable after a +recoverable preview failure. This excerpt assumes the app uses EJS as its template renderer. + +```ts +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' + +const appConsent = req.cookies?.['app-consent'] === 'accepted' +if (!appConsent) { + res.render('page', { handoffJson: 'null', optimizationData: null }) + return +} + +const userId = req.cookies?.['app-user-id'] +const requestOptimization = optimization.forRequest({ + consent: { events: true, persistence: true }, + eventContext: getRequestContext(req, APP_LOCALE), + locale: APP_LOCALE, + profile: getProfileFromRequest(req), +}) +const { data, handoff } = await requestOptimization.prepareRequestHandoff({ + routeKey: req.originalUrl, + initialEvents: userId ? [{ type: 'identify', userId }] : undefined, + page: { properties: { path: req.path } }, +}) + +const profileId = data?.profile.id ?? handoff.profileId +if (profileId && requestOptimization.canPersistProfile) { + res.cookie(ANONYMOUS_ID_COOKIE, profileId, { path: '/', sameSite: 'lax' }) +} +res.render('page', { + handoffJson: JSON.stringify(handoff).replace(/ + <%- handoffJson %> + +``` + 1. Store the shared anonymous profile ID in `ANONYMOUS_ID_COOKIE` when consent permits persistence. 2. Leave the shared cookie readable by browser-side code in hybrid Node + Web SDK apps. 3. Initialize the Web SDK with the same Contentful space ID, environment, and application locale. -4. Let browser code handle later client-side consent, page events, entry interactions, and live - updates. +4. Let browser code commit the prepared first-route batch and handle later client-side consent, + page events, entry interactions, and live updates. The Node SDK does not provide browser live updates or a preview UI. Keep those concerns in browser-side SDK code or app-owned Contentful preview tooling. @@ -1058,7 +1130,8 @@ per-request API options. types. 3. Use `onEventBlocked` for diagnostics when consent blocks a request-bound event call. 4. Use request-scoped `experienceOptions` and `insightsOptions` for advanced API behavior such as - `preflight`, IP override, or a custom Insights `beacon` sender. + IP override or a custom Insights `beacon` sender. `prepareRequestHandoff()` selects preflight for + its preview internally; ordinary `forRequest()` calls have no preflight setting. **Follow this pattern:** @@ -1078,7 +1151,7 @@ const requestOptimization = optimization.forRequest({ consent: { events: false, persistence: false }, eventContext: getRequestContext(req, appLocale), // Advanced Experience API options stay request-scoped in stateless runtimes. - experienceOptions: { preflight: true }, + experienceOptions: { ip: req.ip }, locale: appLocale, profile: getProfileFromRequest(req), }) diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md index 1a197888d..34932d8a2 100644 --- a/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md @@ -1082,7 +1082,6 @@ val strictConfig = OptimizationConfig( allowedEventTypes = emptyList(), queuePolicy = QueuePolicy( offlineMaxEvents = 100, - onOfflineDrop = { event -> logDroppedOptimizationEvent(event) }, ), // Blocked events are for verification; they are not re-sent after later consent. onEventBlocked = { blockedEvent -> logBlockedOptimizationEvent(blockedEvent) }, @@ -1102,8 +1101,9 @@ After initialization the SDK monitors network reachability and app lifecycle. A (`ConnectivityManager`) calls `setOnline(...)` on connectivity changes and `flush()` on reconnect; an `AppLifecycleHandler` (a `ProcessLifecycleOwner` observer) calls `flush()` when the app moves to the background for a best-effort drain. Queues are in-memory only — there is no durable outbox — and the -offline Experience buffer is capped at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); -nothing survives process death. +offline Experience buffer is capped at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`). +At capacity, incoming events are rejected and accepted events keep their order. Nothing survives +process death. 1. Let the SDK manage normal network and lifecycle handling after `OptimizationRoot` initializes the client. diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md index 57202eecc..00b6e527b 100644 --- a/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md @@ -1158,7 +1158,8 @@ path. 1. Add a `QueuePolicy` only when production telemetry needs queue bounds or delivery callbacks. The offline Experience queue holds up to 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); queues are in-memory only, with no durable outbox, and do not - survive process death. + survive process death. At capacity, an incoming Experience event is rejected and accepted events + keep their order. **Adapt this to your use case:** @@ -1168,12 +1169,11 @@ path. queuePolicy = QueuePolicy( // Cap offline storage to the app's production delivery budget. offlineMaxEvents = 100, - onOfflineDrop = { event -> Log.w("Optimization", "Dropped offline event: ${event.context}") }, ), ) ``` -2. Use queue callbacks for operational diagnostics, not for resending blocked or dropped events. +2. Use active queue callbacks for flush diagnostics. `onOfflineDrop` is deprecated and inactive. 3. Keep Contentful entry caching in the application layer — the SDK does not cache CDA responses for Views rendering; you own content caching with locale-aware keys. 4. Call `flush()` only for deliberate release, test, or lifecycle flows; the SDK already flushes on diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md index dffb11c66..ccffeb373 100644 --- a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md @@ -1112,7 +1112,8 @@ After initialization the SDK monitors network reachability and app lifecycle. A app-state handler is compiled in wherever UIKit can be imported, which is the case in an iOS app build, so a SwiftUI-lifecycle app gets the resign-active flush too — you do not need to add your own. Queues are in-memory only — there is no durable outbox — and the offline Experience buffer is capped -at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); nothing survives process death. +at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`). At capacity, incoming events +are rejected and accepted events keep their order. Nothing survives process death. 1. Keep one `OptimizationClient` alive for the app or scene lifetime so the in-memory queue can survive transient network changes. @@ -1120,7 +1121,7 @@ at 100 events by default (tunable via `QueuePolicy.offlineMaxEvents`); nothing s network simulation. 3. Call `client.flush()` from app-owned shutdown or critical-flow checkpoints when policy requires a best-effort delivery attempt before leaving the flow. -4. Use the `QueuePolicy` callbacks when operations teams need telemetry for offline drops, flush +4. Use the active `QueuePolicy` callbacks when operations teams need telemetry for flush failures, circuit-open events, or recovery. **Follow this pattern:** diff --git a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md index 096de123a..ff64ffca3 100644 --- a/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md +++ b/documentation/guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md @@ -1315,13 +1315,17 @@ lifecycle handler flushes on `willResignActive`. 1. Add `QueuePolicy` only when production telemetry needs queue limits or lifecycle callbacks. The offline Experience queue holds up to 100 events by default (tunable via - `QueuePolicy.offlineMaxEvents`); queues are in-memory only and do not survive process death. -2. Use queue callbacks for operational diagnostics, not for resending blocked or dropped events. + `QueuePolicy.offlineMaxEvents`); queues are in-memory only and do not survive process death. At + capacity, an incoming Experience event is rejected and accepted events keep their order. +2. Use active queue callbacks for flush diagnostics. `onOfflineDrop` is deprecated and inactive. 3. Keep Contentful entry caching in the application layer — the SDK does not cache CDA responses for UIKit rendering. 4. Call `flush()` only for deliberate release, test, or lifecycle flows; the SDK already flushes on background and reconnect. +`diagnostics` is your app's operational logger. Each flush callback receives a `QueueEvent`; its +`context` dictionary contains the details to record. + **Adapt this to your use case:** ```swift @@ -1330,11 +1334,6 @@ let config = OptimizationConfig( spaceId: "your-space-id", queuePolicy: QueuePolicy( offlineMaxEvents: 500, - onOfflineDrop: { event in - // event is a QueueEvent with a type and a context dictionary; - // diagnostics is reader-owned: your own operational logging. - diagnostics.record("optimization-offline-drop", context: event.context) - }, onFlushFailure: { event in diagnostics.record("optimization-flush-failure", context: event.context) }, diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md index 8feb71d72..ffdeaee96 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md @@ -10,8 +10,9 @@ fern: # Integrate the Optimization Next.js SDK in a Next.js App Router app Use this guide to render a personalized Contentful entry on the server and keep the same result when -the browser starts. The server gives the browser a plain-data snapshot of the selected variants and -browser startup settings used for that render. This snapshot is an **Optimization handoff**. +the browser starts. The server previews the initial events to choose content, then gives the browser +a plain-data **Optimization handoff** with those prepared events, selected variants, and startup +settings. The browser commits the events for that visit. **New to personalization?** Here is the whole idea in four points: @@ -82,7 +83,8 @@ personalization path. Use the same Contentful environment for server and client binding code. The consent values below are a quick-start policy shortcut: `server.events` and `clientDefaults.consent` allow personalization events, while `server.persistence` and - `clientDefaults.persistenceConsent` allow the SDK-owned anonymous ID to persist. The + `clientDefaults.persistenceConsent` allow an Experience API-issued anonymous ID to persist in the + SDK-owned cookie. The `preserve-server` hydration mode tells the browser to keep the server-rendered result while its live runtime starts. @@ -365,32 +367,53 @@ export const clientOptimization = bindNextjsAppRouterClientOptimization({ }) ``` -As an optional client-only path, add `beforeInitialPage` when the request root must finish returned -identity or custom Experience event work before its first browser-owned page decision. Keep the -callback in this `'use client'` module. Here, **identity** means the visitor ID and traits your -application is allowed to send; the full lifecycle is covered in -[Consent, identity, profile, and reset](#consent-identity-profile-and-reset). - -The **initial page decision** is the request root's one choice to send the first browser `page` -event or skip it because the server handoff already owns that route. The server request family can -accept its page event and serialize that ownership in the handoff. The browser request root applies -the handoff; after its live owned runtime exists, it invokes the callback, makes one direct page -attempt or same-route handoff skip, marks the attempted route, and emits for later route changes. - -After that live owned runtime exists, the callback runs once during a retained root lifetime that -ends when the request root unmounts. A real remount starts another lifetime. The SDK-provided -`BeforeInitialPageClient` exposes methods that stay bound when destructured: `identify` supplies -visitor identity, `screen` records a screen-view Experience event, and `track` sends an app-named -custom Experience event. - -Return one value that represents all before-initial-page operations. A JavaScript `Promise` -represents work that finishes later; a **thenable** is a Promise-like object with a `.then()` method. -An `async` callback returns one Promise automatically, and every operation you `await` becomes part -of that returned work. - -**Adapt this to your use case:** extend the client binding above. `app-user-id` and `client_ready` -are app-owned identifiers in this example; replace them with the browser identity store and custom -event name your app owns. +For request-family routes, any identity or custom Experience events that must precede the first +page belong in the server binding's `request.initialEvents`. The SDK prepares those events in order, +followed by the page event, and previews the batch for the server render. This preview selects +content but does not commit the events. The browser receives the prepared replay through the +handoff and admits it to the Experience queue for the matching route. Use app-owned values from the +request context only when your consent policy permits them. + +Keep the prepared batch to 100 events total, including exactly one final page and at most 50 +identifies. The SDK validates it before the preview request; an invalid batch has no replay. In the +browser, the whole batch needs room in the configured Experience queue before it is admitted. + +**Adapt this to your use case:** derive per-request inputs from your app-owned consent and identity +cookies. Replace `app-consent`, `app-user-id`, and `server_render` with your policy and event name. +Apply the same consent rule in `consent.server`; checking the cookie here does not grant SDK event +consent by itself. Keep the binding and client module in separate files. + +```diff + // lib/optimization.ts + export const optimization = bindNextjsAppRouterServerOptimization({ + // your existing server config +- request: { hydration: 'preserve-server' }, ++ request: { ++ hydration: 'preserve-server', ++ initialEvents: ({ cookies }) => { ++ if (cookies.get('app-consent')?.value !== 'accepted') return [] ++ const userId = cookies.get('app-user-id')?.value ++ return [ ++ ...(userId ? [{ type: 'identify' as const, userId }] : []), ++ { type: 'track' as const, event: 'server_render' }, ++ ] ++ }, ++ }, + }) +``` + +For an optional browser-only route **without prepared replay**, the client binder accepts +`beforeInitialPage`. It waits for returned browser identity or custom Experience work before its +direct page attempt. A request-family root with prepared replay takes the normal handoff queue path +and bypasses this callback. Keep `request.initialEvents` above for inputs that must participate in +server evaluation. + +**Adapt this to your use case:** extend the existing `'use client'` binding. `app-user-id` and +`client_ready` are app-owned identifiers; replace them with your identity store and event name. +The `async` callback returns one Promise that includes both awaited operations. The SDK's +`identify`, `screen`, and `track` delegates are safe to destructure; `screen` records a screen view. +The consent defaults below let this browser-only route demonstrate accepted events; replace them +with your application consent policy before shipping. ```diff // lib/optimization-client.ts @@ -401,104 +424,87 @@ event name your app owns. + bindNextjsAppRouterClientOptimization, + type BeforeInitialPageOptions, +} from '@contentful/optimization-nextjs/app-router/client' - -+const APP_USER_ID_KEY = 'app-user-id' -+const CLIENT_READY_EVENT = 'client_ready' + +const beforeInitialPage = { + run: async ({ identify, track }) => { -+ const userId = window.localStorage.getItem(APP_USER_ID_KEY) ++ const userId = window.localStorage.getItem('app-user-id') + if (userId !== null) await identify({ userId }) -+ await track({ event: CLIENT_READY_EVENT }) ++ await track({ event: 'client_ready' }) + }, + onError: (error) => console.warn('Before-initial-page work failed.', error), +} satisfies BeforeInitialPageOptions -+ + export const clientOptimization = bindNextjsAppRouterClientOptimization({ spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', locale: 'en-US', ++ consent: { clientDefaults: { consent: true, persistenceConsent: true } }, + beforeInitialPage, }) + -+export const { -+ RequestOptimizationRoot: ClientRequestOptimizationRoot, -+} = clientOptimization ++export const { OptimizationRoot: ClientOptimizationRoot } = clientOptimization ``` -The client binding with before-initial-page work exposes two content roots for different owners: +Mount this direct client root on a route outside the `(request)` layout from the quick start, so +there is no ancestor request root or prepared handoff. The `routeKey` is the visible pathname plus +search and changes with navigation. `buildPagePayload` is lazy, so the root reads current route +values after the callback. The Next.js `useSearchParams` hook needs a `Suspense` boundary; preserve +a meaningful fallback. The surrounding route is illustrative context to adapt to your own app. -- `clientOptimization.OptimizationRoot` is for direct Client Component composition. It requires an - explicit `routeKey` and lazy `buildPagePayload`. It does not accept `initialPagePayload`, the - eager page data object computed before later route changes. -- `ClientRequestOptimizationRoot` is the `RequestOptimizationRoot` component for the server - binding's request family. It accepts only serializable request-root values: children, defaults, - the content handoff, and hydration mode. It derives the live route and lazy page payload in the - client, so callers do not pass route or page-payload props. +**Adapt this to your use case:** render a browser-only page with no separate auto-page tracker. +Replace the placeholder page body with your existing content. The development observer is defined +in [The bound root and page events](#the-bound-root-and-page-events); remove it after checking order. -Inject the second component into the existing server binding. The component reference replaces the -default browser root only for `optimization.request`; it does not wrap or create a second root. - -**Adapt this to your use case:** complete this server binding and layout continuation as one change. -Add the request-root composition argument, stop exporting and importing the normal request tracker, -and remove its JSX before you run the route with `beforeInitialPage`. Keep the existing server -config and other request-family exports. - -```diff - // lib/optimization.ts - import { bindNextjsAppRouterServerOptimization } from '@contentful/optimization-nextjs/app-router/server' -+import { ClientRequestOptimizationRoot } from './optimization-client' - import { contentfulClient } from './contentful' +```tsx +// app/browser-only/page.tsx +'use client' - export const optimization = bindNextjsAppRouterServerOptimization({ - // your existing server config --}) -+}, { -+ request: { OptimizationRoot: ClientRequestOptimizationRoot }, -+}) - - export const { -- NextAppAutoPageTracker: RequestNextAppAutoPageTracker, - OptimizationRoot: RequestOptimizationRoot, - OptimizedEntry: RequestOptimizedEntry, - } = optimization.request -``` +import { ClientOptimizationRoot } from '@/lib/optimization-client' +import { OptimizationEventDiagnostics } from '@/components/OptimizationEventDiagnostics' +import Link from 'next/link' +import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense } from 'react' -**Adapt this to your use case:** continue immediately in the request layout. The surrounding layout -remains app-owned context; remove both the tracker import and its JSX. +function BrowserOnlyRoute() { + const pathname = usePathname() + const search = useSearchParams()?.toString() ?? '' + const routeKey = `${pathname}${search ? `?${search}` : ''}` -```diff - // app/(request)/layout.tsx --import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '@/lib/optimization' -+import { RequestOptimizationRoot } from '@/lib/optimization' + return ( + ({ properties: { path: pathname, search } })} + > + +
+

Browser-only route

+ Next view +
+
+ ) +} - -- - {children} - +export default function BrowserOnlyPage() { + return ( + Loading page…

}> + +
+ ) +} ``` -The first server-binder parameter has a `beforeInitialPage?: never` boundary: TypeScript rejects a -callback value on that parameter. A `NextjsClientOptimizationConfigWithBeforeInitialPage` variable -is not assignable there; the second parameter receives the Client Component reference instead. The -server request authority still supplies only resolved handoff, hydration, defaults, and children. -The callback and lazy payload builder stay in the client module and never cross React Flight, the -Server Component payload sent to the browser, as server config or serialized request-root data. The -serialized handoff shape is unchanged. - -The watchdog timeout uses 3,000 ms when `maxWaitMs` is omitted and accepts any positive finite -value. A value of `0`, a negative number, `NaN`, `Infinity`, or `-Infinity` synchronously throws -`TypeError('beforeInitialPage.maxWaitMs must be a positive finite number.')` before the provider, -callback, page, `onError`, or watchdog runs. The client binder does not forward the callback to its -bound `OptimizationProvider` or `OptimizationAnalyticsRoot`, and a standalone -`OptimizationProvider` with an injected SDK does not accept it. +A direct root with `beforeInitialPage` requires `routeKey` and lazy `buildPagePayload`; it rejects +`initialPagePayload`, which captures page data too early. The callback stays in the client module: +the server binding rejects a client config containing `beforeInitialPage`, and neither its provider +nor analytics root receives it. The binding config separates policy from mechanism: - `consent.server` is the app-owned server policy for the current request; configure it explicitly because App Router request initialization resolves omitted request consent to `false`. -- `consent.clientDefaults` seeds the browser SDK before a persisted or explicit browser decision is - available. +- `consent.clientDefaults` supplies explicit browser defaults. The nested request family replaces + them with the current request's consent, but a top-level static or public root uses them as given. + Supply the app's current browser consent when mounting a public root. - `contentful.client` is your delivery client. The SDK may call it for managed entry IDs or content-type/slug lookups, but it does not own your Contentful Delivery API (CDA) credentials or query policy. @@ -567,9 +573,8 @@ slot. The binding supplies the app-owned client, the request root prefetches the browser handoff, and the request entry resolves that same ID for server output. `entryId` is an app-owned Contentful entry ID. Edit the existing binding and root; do not create a second binding or nest another root around an already-bound subtree. The server-only `CONTENTFUL_HERO_ENTRY_ID` name -and value in this example are app-owned. The diff shows normal tracker mode. If you already enabled -the callback request-root composition, keep `RequestNextAppAutoPageTracker` omitted while applying -the managed-entry changes. +and value in this example are app-owned. This diff belongs to the paired request-family path; keep +`RequestNextAppAutoPageTracker` mounted. The direct browser-only callback route is separate. **Adapt this to your use case:** @@ -657,12 +662,12 @@ forwarding-only path. It sanitizes SDK-owned forwarded context and supplies the the request family performs request evaluation. Keep the matcher narrow to the routes that use Optimization request context. -Trusted response-capable request persistence is an advanced opt-in for routes whose proxy or -middleware must perform the page request and persist the SDK-owned anonymous ID cookie before Server -Components render. See -[Manual server and client escape hatches](#manual-server-and-client-escape-hatches). +The forwarding handler does not evaluate events. The request family previews the initial batch +when Server Components render. If the preview fails, the server renders baseline content and keeps +valid prepared events for browser commitment. Invalid event or route inputs produce no replay. +The browser can persist a profile ID returned by preview when persistence consent permits it. -The SDK-owned anonymous ID cookie is `ctfl-opt-aid`. It stores the identifier that connects browser +The SDK-owned anonymous ID cookie is `ctfl-opt-aid`. It stores an Experience API-issued identifier that connects browser and server activity. A **profile** is the Experience API's current visitor ID, traits, audiences, and session state; the cookie is not that full profile, selected state, or consent record. Your app owns any consent cookie or account record that `consent.server` reads. Store the consent decision where @@ -675,7 +680,8 @@ record. Server Components render personalized first paint through `optimization.request.OptimizedEntry`. If no experience applies, the API has no variant, or a linked variant cannot be resolved, the render -receives the baseline entry. When policy denies the selection-producing Experience API request, no +receives the baseline entry. A recoverable preview failure also renders baseline while the valid +prepared replay remains available for the browser. When policy denies the selection-producing Experience API request, no **selected optimizations** enter the request state. Selected optimizations are the experience-and-variant choices returned by an accepted page, identify, or custom Experience event. Resolution without a selection also returns the baseline. @@ -749,15 +755,16 @@ caches. For shareable static or public-permutation routes, use the advanced rout **Integration category:** Required for first integration Use `optimization.request.OptimizationRoot` at a private request route root. It gives the browser -provider the server snapshot and browser startup mode. In normal tracker mode, the request -`NextAppAutoPageTracker` also -learns whether the server accepted the initial page-view event: it skips a duplicate when the server -owns that event, then tracks later client navigations. The request family builds the route key and -page payload, so the app does not pass either one. Keep the tracker inside the Next.js-required +provider the server snapshot, prepared replay, and browser startup mode. The request +`NextAppAutoPageTracker` passes the handoff and current route to the Web SDK. For a matching replay, +the Web SDK admits the complete batch to its existing queue and does not submit an ordinary page +for the same visit. Queue admission is reported before HTTP delivery; a later response updates live +browser state. Without matching replay, the tracker uses ordinary page tracking. The request family +builds the route key and page payload, so the app does not pass either one. Keep the tracker inside the Next.js-required `Suspense` boundary; that boundary is a platform rendering requirement, not request-initialization plumbing. -On this normal request-family path, the first page event's `page.url` comes from the SDK-forwarded +On this request-family path, the prepared page event's `page.url` comes from the SDK-forwarded request URL, and later browser page events use the tracker's current route URL. The app does not pass a page payload for either. When that URL contains supported UTM parameters, the SDK treats it as one complete campaign source and maps it into `context.campaign`: `utm_campaign` becomes `name`, @@ -773,78 +780,52 @@ fallback for the private slot so the public navigation and page context remain a loads. Keep Next.js `Link` prefetch enabled; the narrow handler matcher limits request-context work to participating routes. -For the request-family composition with before-initial-page work from -[How the SDK fits your app](#how-the-sdk-fits-your-app), the injected -`ClientRequestOptimizationRoot` replaces the default browser root. It derives the current route key -and lazy page payload in the client through the SDK's non-emitting `useNextAppAutoPageInputs` hook. -The atomic setup above removes `RequestNextAppAutoPageTracker`; the injected root is the sole page -owner. - -The root waits for the callback's returned work or watchdog, reads the latest route and lazy payload -builder, and then makes the direct page attempt described below. Only after that attempt finishes -does it start built-in route-change emission. - -A **direct page attempt** means the root calls the page-event API itself once before automatic route -tracking starts. The root's **page emitter** is its built-in route-change logic, not a tracker -component you mount. After the direct attempt finishes, its initial `skip` mark records the attempted -route as handled without sending another event. A later route change makes the emitter send its -normal page event. If the page call returns `{ accepted: false }`, the SDK finished the call but did -not admit that page event locally; the sequence still advances without an immediate same-route -retry. - -A callback throw, returned-work rejection, or watchdog expiry is reported to `onError` when you -supply it. While the root remains mounted and the same live owned runtime is current, the root still -attempts the page. The watchdog stops waiting but does not cancel the callback or a request it -already sent. Fire-and-forget work that the callback does not return can finish after the page. If -the root unmounts or its live runtime is replaced, only unsent local page and readiness continuation -is suppressed; work already started is not canceled. - -A route change after the direct page attempt starts neither cancels that attempt nor starts a -competing page attempt. The root settles and marks the captured attempted route before enabling -later page emission. A route observed only while the attempt is in flight is not emitted; a route -change after readiness emits normally. +For request-family pages, server `request.initialEvents` is the place to supply identity and +custom Experience events that must precede the page. The replay contains those events and the +page in order. A root that receives this prepared replay uses the handoff path; its +`beforeInitialPage` callback does not run. + +In the direct browser-only route above, the callback runs because there is no prepared replay. The +**initial page decision** is its first direct browser page +attempt after callback work. The callback runs after the root's live owned runtime exists and once +per retained mount; a real remount starts another lifetime. Return a Promise or **thenable** (an +object with `.then()`) for all work the root must await. It waits for that result or the watchdog, +then reads the latest route and lazy payload for one direct page attempt. After that attempt settles, +its built-in page emitter makes a non-emitting initial `skip` mark for the attempted route, then +emits for later routes. `{ accepted: false }` means the page was not admitted locally; it still +advances without an immediate same-route retry. + +The watchdog waits 3,000 ms by default; `maxWaitMs` must be a positive finite number. A callback +throw, rejected returned work, or watchdog expiry reaches `onError`, then the root continues to the +page while still mounted on the same live runtime. The watchdog does not cancel work already +started. A route change during an in-flight direct attempt neither cancels it nor starts another; +a route observed only during that interval is not emitted, and a change after readiness emits +normally. Work the callback starts without returning can finish after the page. > [!NOTE] > -> If callback and page work remain pending when an entry reaches its existing five-second fallback -> deadline, the entry can reveal baseline content. With live updates disabled, that first visible -> content stays frozen even if the before-initial-page work later selects a variant. Enable -> [Browser takeover and live updates](#browser-takeover-and-live-updates) only when a late -> replacement is intended. - -Use `OptimizationEventDiagnostics`, defined below, for a development-only ordering check. Set the -example identity first with `localStorage.setItem('app-user-id', 'guide-user')`, reload the route -with `beforeInitialPage`, and inspect `Contentful Optimization event accepted` and -`Contentful Optimization event blocked` messages. The identify and `client_ready` results must -appear, as accepted or blocked calls, before at most one initial browser `page` result. Follow a -normal Next.js `Link` once and confirm one later `page` result. An initial browser page before -callback completion or two initial page results usually means a request tracker is still mounted. -The diagnostic proves local SDK admission or blocking, not API delivery. - -This composition changes only the server binder's request family. Top-level explicit, -static/public-permutation, and analytics roots do not consult the injected request root. The normal -request path without `beforeInitialPage` keeps `RequestNextAppAutoPageTracker`. Direct Web and Node -integrations keep this ordering in application code by awaiting identity or custom-event work -before calling their existing page-event API. +> If callback and page work remain pending when an entry reaches its five-second fallback deadline, +> baseline content can become visible. With live updates off, that visible content stays frozen. +> Enable [Browser takeover and live updates](#browser-takeover-and-live-updates) if late replacement +> is intended. Advanced explicit-input routes can pass an app-created handoff and browser startup mode to the top-level `optimization.OptimizationRoot` or `optimization.OptimizationProvider`. The root can also -take an app-created route key and initial page payload. `initialPageEvent` is the handoff field that -tells a browser tracker to `skip` a server-owned first page event or `emit` a browser-owned one. See +take an app-created route key and initial page payload. A matching prepared replay is committed by +the browser. Without a matching replay, the root tracks an ordinary page when supplied with a page +payload; otherwise, mount the router tracker to provide the fallback page event. See [Manual server and client escape hatches](#manual-server-and-client-escape-hatches) before using these inputs. Use `optimization.OptimizationAnalyticsRoot` for analytics-only handoffs. If you pass `prefetchManagedEntries` without an explicit `handoff`, the App Router root creates baseline `static` handoff behavior with `hydration: 'preserve-server'`, no selected optimizations, -and `initialPageEvent: 'emit'`. Use that path for baseline managed-entry warming, not -request-personalized state. +and no replay. Use that path for baseline managed-entry warming, not request-personalized state. Mount one development-only observer inside the request root before validating events elsewhere in this guide. The accepted stream holds the most recent accepted event as its current value, not an event history. The blocked stream reports events rejected by consent or event policy. -The mounting diff below shows normal tracker mode. On a before-initial-page request root, mount the -diagnostic in the same position but keep `RequestNextAppAutoPageTracker` omitted. +Mount the diagnostic before the request tracker so it observes the initial browser admission. **Adapt this to your use case:** @@ -886,7 +867,6 @@ export function OptimizationEventDiagnostics() { + - {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} {children} @@ -900,14 +880,25 @@ that either API received an event. Use three separate checks: the raw server HTML. - **Browser admission:** Trigger a tracked action and inspect the browser console for `Contentful Optimization event accepted` or `Contentful Optimization event blocked`. -- **Duplicate page prevention:** Clear the browser console and reload the route. Hydration must not - log a browser `page` event when the server accepted the first page event. Follow a normal Next.js - `Link` to another participating route and confirm one browser `page` event appears for that - navigation. - -To verify API delivery rather than local admission, inspect your server's outbound Experience API -telemetry for the initial request and your browser Network panel for browser-owned events. The -browser observer cannot see the completed server call. +- **Prepared commitment:** Temporarily add the app-owned `server_render` track event to + `request.initialEvents` as shown above and grant event consent. Clear the browser Network panel + and reload. Inspect the Experience request after hydration: it must contain the prepared + `server_render` track and `page` in one batch, with no second ordinary first page. A page-only + request can be ordinary fallback and points to missing or mismatched replay. Follow a normal + Next.js `Link` to another participating route and confirm one later page admission. Remove the + diagnostic track event afterward. Local acceptance can precede HTTP delivery. + +The server's preview selects the raw HTML but does not commit events. Browser Network activity +shows commitment attempts, while the diagnostic shows local admission or blocking. Neither one +alone proves that a retry after a lost response was applied exactly once by the API. + +For the optional browser-only `beforeInitialPage` route, mount the same development observer as a +child of `ClientOptimizationRoot` before the page body. Omit `RequestNextAppAutoPageTracker`. Set +the example identity with `localStorage.setItem('app-user-id', 'guide-user')`, then load +`/browser-only`. The observer must show the `identify` and `client_ready` results, accepted or +blocked, before at most one initial `page` result. Click **Next view** after readiness and check +for one later page result. A page result before the callback's results or two initial pages points to another +page owner. Remove the observer after this development check. ### Browser takeover and live updates @@ -1028,6 +1019,13 @@ Replace the quick-start consent shortcut with your app policy: `setConsent(true)` or `setConsent(false)` sets both event consent and persistence consent to the same value. Use the object form, `setConsent({ events, persistence })`, when those two decisions differ. +> [!WARNING] +> The fixed `false` browser defaults below apply to the nested request path, whose handoff supplies +> the current request consent. A top-level static or public root uses those defaults as given rather +> than reading the current browser decision. Initialize public browser roots from the current +> app-owned consent decision as shown in +> [Render personalized Next.js routes with static, ISR, and edge handoffs](./rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md). + **Adapt this to your use case:** ```tsx @@ -1371,8 +1369,8 @@ export function OptimizationPreviewPanel() { Mount the panel inside the same request root as the content it previews. -The mounting diff below shows normal tracker mode. On a before-initial-page request root, add the -panel but keep `RequestNextAppAutoPageTracker` omitted. +The mounting diff below shows the paired request-family path. The direct browser-only callback +route owns its own root and does not mount `RequestNextAppAutoPageTracker`. **Adapt this to your use case:** @@ -1382,7 +1380,6 @@ panel but keep `RequestNextAppAutoPageTracker` omitted. + - {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} {children} @@ -1460,9 +1457,8 @@ export default function Page() { } ``` -This private-slot example shows normal tracker mode. If the server binding injects -`ClientRequestOptimizationRoot`, remove `RequestNextAppAutoPageTracker` from both this import and the -JSX, as in the atomic callback setup. +This private-slot example uses the paired request-family root and tracker. The direct browser-only +callback route is separate and has no request tracker. **Follow this pattern:** @@ -1526,11 +1522,9 @@ Manual flows still pass `handoff` to a React root. Do not invent a second state hydration. Keep `createRequestHandoff()` out of the normal private-request route; the nested request family owns that work. -The response-capable handler is also an advanced opt-in. Configure -`createNextjsOptimizationContextHandler(...)` with a server SDK and consent resolver, then set -`request.trustedRequestHandoff: true` on the App Router binding. That pair allows the request family -to trust compact server context forwarded by the handler. Keep the no-argument forwarding-only -handler for ordinary request-family routes. +The request handler forwards sanitized request context. It does not evaluate the initial event +batch or send committed page data to Server Components. Keep the no-argument handler for ordinary +request-family routes; the rendering request resource performs the preview once. Lower-level resolver calls keep selections as the optional second positional argument: `resolveOptimizedEntry(entry, selectedOptimizations)`. Managed fetch calls accept an ID or a @@ -1589,15 +1583,15 @@ entry-cache hit. **Integration category:** Advanced or production-only When no Optimization event may emit before explicit consent, configure a strict event policy and -return `false` from `consent.server` until your app-owned consent record is accepted. In normal -tracker mode, the request tracker receives first-page-event ownership from its handoff. In -`beforeInitialPage` mode, mount no request tracker; the root owns the direct attempt and later -routes. For top-level explicit handoff flows, use `initialPageEvent="skip"` only when a server or -edge helper already accepted the same route's first page event. Use blocked-event diagnostics to -verify denied events are dropped at the SDK boundary. +return `false` from `consent.server` until your app-owned consent record is accepted. The request +tracker passes the handoff and route to the Web SDK, which applies current browser consent before +admitting a prepared replay. A browser-only root without replay owns an ordinary first page. Use +blocked-event diagnostics to verify denied events are dropped at the SDK boundary. `allowedEventTypes` is the binding's pre-consent event allow-list. An empty list makes every event -require accepted event consent. +require accepted event consent. If you also use the separate browser-only client binding above, +set `allowedEventTypes: []` there as well; the server binding's policy does not configure that +client root. **Adapt this to your use case:** @@ -1613,14 +1607,13 @@ require accepted event consent. }) ``` -Keep `OptimizationEventDiagnostics` before the selected page owner. In normal tracker mode, that -means before `RequestNextAppAutoPageTracker`; in `beforeInitialPage` mode, the diagnostic stays -inside the root and no tracker is mounted. Clear the `app-consent` cookie, reload, and confirm the +Keep `OptimizationEventDiagnostics` before `RequestNextAppAutoPageTracker` inside the root. Clear the `app-consent` cookie, reload, and confirm the browser console reports a blocked record whose `reason` is `consent` and whose `method` is `page`. Click **Allow personalization** in the control shown earlier, then follow a normal Next.js `Link` to another participating route. Confirm that the navigation produces one locally accepted `page` -event. In normal tracker mode, the handoff tells the tracker to skip a server-owned duplicate. In -`beforeInitialPage` mode, the root's initial non-emitting mark prevents a same-route retry. +event. When replay is admitted for the first route, the Web SDK does not also enqueue an ordinary +page. If consent initially blocks admission, calling the current-page path again after consent is +granted can admit the still-uncommitted replay. Consent withdrawal has separate owners: record denial in your app or consent-management platform, call `setConsent(false)` to stop and clear SDK durable event storage, and call `resetUser()` to clear @@ -1633,11 +1626,17 @@ account record. Contentful space ID. - Confirm `consent.server`, browser consent defaults, and app-owned consent storage agree. - Confirm `ctfl-opt-aid` is browser-readable where server and browser profile continuity is needed. -- Confirm locally accepted server and browser events arrive at the intended Experience or Insights - API destination; the browser diagnostic alone is not delivery evidence. -- Confirm first-page ownership matches one mode. In normal tracker mode, the request tracker skips a - server-owned first event and emits later routes. In `beforeInitialPage` mode, no request tracker - is mounted and the root's direct attempt plus built-in emitter do not duplicate the initial route. +- Confirm server preview produces the intended raw HTML, then confirm the browser's prepared batch + reaches the intended Experience API destination. The browser diagnostic proves local admission, + not HTTP delivery. +- Temporarily include the app-owned `server_render` track event in `request.initialEvents` as shown + above, grant event consent, and reload. In the browser Network panel, confirm the prepared + `track` and `page` appear in one Experience batch without an extra ordinary first page. A + page-only request points to missing or mismatched replay. Remove the diagnostic event afterward. +- In the normal request path, confirm the request tracker admits one prepared first-route batch, + does not enqueue an additional ordinary first page, and tracks later routes. In the direct + browser-only `beforeInitialPage` route, omit the tracker and confirm callback results precede the + first direct page attempt. - Confirm baseline fallback is acceptable when no variant applies or Contentful links are unresolved. - Confirm request-personalized output is never stored in a public shared cache. @@ -1665,7 +1664,7 @@ pnpm test:e2e:nextjs-sdk_app_router | A heterogeneous render cannot read content-type-specific fields | The skeleton union omits a possible content type, or the entry was not narrowed before rendering | Include every baseline and variant skeleton in `S`, then narrow with `isEntryOfContentType` | | Variant appears in the browser but not View Source | The route is browser-owned rather than request-family or public-permutation rendered | Use `optimization.request` for private request rendering, or use a top-level public permutation handoff before rendering | | Request components report a missing forwarded request URL | The handler filename or export name does not match the Next.js version, or the handler is absent | Configure the SDK request handler; use `proxy.ts` with `proxy` on Next.js 16, or `middleware.ts` with `middleware` on Next.js 13 to 15 | -| Duplicate first page events | Normal tracker mode has conflicting tracker ownership, or `beforeInitialPage` mode still mounts a request tracker | In normal tracker mode, give the tracker the handoff's `initialPageEvent`; in `beforeInitialPage` mode, remove the request tracker and let the root own initial and later pages | +| Duplicate first page events | Multiple page owners are mounted, or app code also calls `page()` for the prepared route | Keep one request tracker in the paired request path; on the direct browser-only `beforeInitialPage` route, omit trackers. Remove duplicate manual first-page calls. | | Live entries do not change after identify or reset | The entry is locked to the handoff and live updates are off | Set `liveUpdates: true` on the App Router binding or use `/client` `LiveUpdatesProvider` for a browser subtree; for one entry, use router-neutral `/client` `OptimizedEntry liveUpdates` because bound App Router entries have no per-entry `liveUpdates` prop | | Personalized HTML is cached for the wrong visitor | Request handoff output entered a public cache | Use `private-request` for request state and public permutation handoffs only for app-owned selected permutations | diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md index 121f0ca06..53e46482d 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md @@ -112,9 +112,9 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- } ``` -4. Mount the bound root once in `_app.tsx`. When a handoff exists, the root owns the server's - page-event decision. When no handoff exists, the separate route tracker emits the first browser - page event and tracks later navigations. +4. Mount the bound root once in `_app.tsx` and pass the same handoff to the route tracker. The + tracker gives the Web SDK the current route and prepared events together. A matching replay is + admitted as one batch; without replay, the tracker emits an ordinary page event. **Adapt this to your use case:** @@ -136,7 +136,7 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- + handoff={handoff} + routeKey={routeKey} + > - + + + + + ) @@ -408,8 +408,14 @@ the slug. **Integration category:** Required for first integration `createRequestHandoff(context, options)` reads the Pages Router request, evaluates -`consent.server`, calls the request page event, persists the SDK-owned anonymous profile cookie on -the response when appropriate, and returns a serializable browser `handoff`. +`consent.server`, prepares the initial page event, and previews it through the Experience API. The +preview supplies selections for server rendering without committing the event. The helper returns a +serializable browser `handoff` containing the prepared replay. The browser admits the matching +replay to the existing Experience queue; its `accepted` result means queue admission, not completed +HTTP delivery. A recoverable Experience preview failure leaves baseline selections and retains +valid prepared events for browser commitment. If the helper, entry prefetch, or rendering throws for +another reason, your `getServerSideProps` error handling decides whether to return baseline or a +500 response. A page blocked by the effective event gate or invalid input produces no replay. Configure `consent.server` explicitly. If it is omitted, Pages Router request consent resolves to `false`. @@ -418,8 +424,11 @@ The returned handoff also carries browser defaults derived from the resolved ser `consent.clientDefaults` axes for the first browser runtime; `clientDefaults` remains the fallback for routes without a request handoff. -The SDK-owned anonymous profile cookie is `ctfl-opt-aid`. Your app owns any consent cookie or account -record that `consent.server` reads. Pages Router server work happens in `getServerSideProps`; there +The SDK-owned anonymous profile cookie is `ctfl-opt-aid`. It stores an Experience API-issued ID, not +an ID invented by the SDK. When persistence consent permits it and an existing or preview-issued ID +is known, the helper appends a browser-readable cookie to the response. An ID can be absent after a +failed first preview; the browser can establish one when it commits the replay. Your app owns any +consent cookie or account record that `consent.server` reads. Pages Router server work happens in `getServerSideProps`; there is no middleware or proxy requirement for the Pages Router path. ### The bound root and page events @@ -427,14 +436,15 @@ is no middleware or proxy requirement for the Pages Router path. **Integration category:** Required for first integration The bound `OptimizationProvider` handles the content SDK context, handoff, hydration mode, and -managed-entry prefetch for a subtree. Use the bound `OptimizationRoot` in `_app.tsx` because it adds -initial page-event wiring. Pass `routeKey` and `buildPagePayload` so the root can follow the -handoff's `initialPageEvent` instruction; those props do not belong on `OptimizationProvider`. The -separate `NextPagesAutoPageTracker` should emit the initial event when no handoff exists and skip it -when a handoff lets the root own that first route, then track later client navigations. +managed-entry prefetch for a subtree. Use the bound `OptimizationRoot` in `_app.tsx` for content and +the `NextPagesAutoPageTracker` for route events. Pass the same `handoff` to both. The tracker sends +the handoff with the current route to the Web SDK, which admits a matching prepared replay instead +of an ordinary first page. A missing or mismatched replay uses ordinary page tracking. Keep +`routeKey` and a lazy `buildPagePayload` on the root when its binding requires them; those props do +not belong on `OptimizationProvider`. Campaign inputs follow page-event ownership. The `pagePayload` passed to `createRequestHandoff` -shapes the first server page event. The root's `buildPagePayload` shapes a browser event that the root +shapes the prepared first page event. The root's `buildPagePayload` shapes a browser event that the root owns; in `beforeInitialPage` mode, it supplies both the direct attempt and later route emissions. In normal tracker mode, `NextPagesAutoPageTracker` instead derives `page.url` from the current router URL for later navigations. For either payload seam, `campaign` is an optional top-level object with @@ -450,34 +460,40 @@ missing fields are not filled from a lower-priority URL. The chosen URL maps int becomes `medium`, `utm_term` becomes `term`, and `utm_content` becomes `content`. `page.referrer` remains page metadata, but it is not a campaign source. -As an optional alternative, the browser binder accepts `beforeInitialPage` for an owned content -root that must finish returned identity or custom Experience event work before its initial page -decision. The **initial page decision** is the root's one choice to send the first browser `page` -event or skip it because an applied handoff already owns that route. During `getServerSideProps`, -the server helper can accept the page event and record that ownership in the handoff. `_app.tsx` -passes the handoff to the browser root; after its live owned runtime exists, the root invokes the -callback, makes one direct page attempt or same-route handoff skip, marks the attempted route, and -emits for later route changes. - -The binder captures the callback only for its bound `OptimizationRoot`; its bound -`OptimizationProvider` and `OptimizationAnalyticsRoot` do not receive it. Here, **identity** means -the visitor ID and traits your application is allowed to send; the full lifecycle is covered in -[Consent, identity, profile, and reset](#consent-identity-profile-and-reset). The callback runs once -after that live owned runtime exists, during a retained root lifetime that ends when the root -unmounts. A real remount starts another lifetime. Its SDK-provided -`BeforeInitialPageClient` exposes methods that stay bound when destructured: `identify` supplies -visitor identity, `screen` records a screen-view Experience event, and `track` sends an app-named -custom Experience event. - -Return one value that represents all before-initial-page operations. A JavaScript `Promise` -represents work that finishes later; a **thenable** is a Promise-like object with a `.then()` method. -An `async` callback returns one Promise automatically, and every operation you `await` becomes part -of that returned work. A standalone `OptimizationProvider` with an injected SDK does not accept -this option. - -**Adapt this to your use case:** add the callback to the existing browser binding and stop exporting -the separate tracker for this path. `app-user-id` and `client_ready` are app-owned identifiers in -this example; replace them with the browser identity store and custom event name your app owns. +For a paired server route, put identity and custom Experience inputs that must precede the page +in the server helper's `initialEvents` option. The SDK prepares them in order, followed by the page, +and previews the batch for the server render. The browser commits the prepared replay through the +handoff. Use only identity and event values your app's consent policy permits on the server. + +Keep the prepared batch to 100 events total, including exactly one final page and at most 50 +identifies. The SDK validates it before the preview request; an invalid batch has no replay. In the +browser, the whole batch needs room in the configured Experience queue before it is admitted. + +**Adapt this to your use case:** if your request has an app-owned event to evaluate before the page, +add it to the existing `getContentfulOptimization()` helper. `server_render` is an app-owned event +name; replace it with one your application actually uses. + +```diff + // lib/optimization-server.ts + handoff: await createRequestHandoff(context, { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', ++ initialEvents: [{ type: 'track', event: 'server_render' }], + pagePayload: { properties: { path: routeKey } }, + }), +``` + +The browser binder also accepts `beforeInitialPage` for an owned content root. Use this on a +browser-only route with no `getServerSideProps` handoff, such as `/browser-only` below. The +**initial page decision** is the root's first direct browser page attempt after callback work. A +root receiving a prepared replay follows the normal handoff queue path and does not run the +callback. Keep `initialEvents` above for work that must participate in server evaluation. + +**Adapt this to your use case:** add the callback to the existing browser binding. `app-user-id` +and `client_ready` are app-owned; replace them with your identity store and custom event name. The +`async` callback returns one Promise that includes both awaited operations. The SDK's +`identify`, `screen`, and `track` delegates are safe to destructure; `screen` records a screen view. +The development observer logs local accepted or blocked results and cleans up both subscriptions. ```diff // lib/optimization.ts @@ -488,14 +504,11 @@ this example; replace them with the browser identity store and custom event name +} from '@contentful/optimization-nextjs/pages-router' -export const { NextPagesAutoPageTracker, OptimizationRoot, OptimizedEntry } = -+const APP_USER_ID_KEY = 'app-user-id' -+const CLIENT_READY_EVENT = 'client_ready' -+ +const beforeInitialPage = { + run: async ({ identify, track }) => { -+ const userId = window.localStorage.getItem(APP_USER_ID_KEY) ++ const userId = window.localStorage.getItem('app-user-id') + if (userId !== null) await identify({ userId }) -+ await track({ event: CLIENT_READY_EVENT }) ++ await track({ event: 'client_ready' }) + }, + onError: (error) => console.warn('Before-initial-page work failed.', error), +} satisfies BeforeInitialPageOptions @@ -506,17 +519,28 @@ this example; replace them with the browser identity store and custom event name environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', locale: 'en-US', + beforeInitialPage, - // your existing browser config ++ onStatesReady: (states) => { ++ if (process.env.NODE_ENV !== 'development') return ++ const accepted = states.eventStream.subscribe((event) => { ++ if (event) console.debug('Contentful Optimization event accepted', event) ++ }) ++ const blocked = states.blockedEventStream.subscribe((event) => { ++ if (event) console.debug('Contentful Optimization event blocked', event) ++ }) ++ return () => { ++ accepted.unsubscribe() ++ blocked.unsubscribe() ++ } ++ }, }) ``` -The before-initial-page root requires `routeKey` and lazy `buildPagePayload`. It does not accept -`initialPagePayload`, the eager page data object computed before later route changes. The `_app.tsx` -root in the quick start already supplies the two required values. Remove only the separate tracker -from this before-initial-page subtree; leaving it mounted creates a second page owner. +The bound root requires `routeKey` and lazy `buildPagePayload`; the quick-start `_app.tsx` already +supplies both. It rejects eager `initialPagePayload`. Remove the separate tracker from `_app.tsx` in +the same edit, because this root owns the first and later browser pages. Routes with a prepared +handoff still commit replay through the root's normal handoff path and bypass the callback. -**Adapt this to your use case:** keep the handoff, current route key, lazy payload builder, and page -component from your existing `_app.tsx`. +**Adapt this to your use case:** keep the same root and page component in `_app.tsx`. ```diff // pages/_app.tsx @@ -528,84 +552,57 @@ component from your existing `_app.tsx`. handoff={handoff} routeKey={routeKey} > -- +- ``` -A **direct page attempt** means the root calls the page-event API itself once before automatic route -tracking starts. The root's **page emitter** is its built-in route-change logic, not a tracker -component you mount. After the direct attempt finishes, its initial `skip` mark records the attempted -route as handled without sending another event. A later route change makes the emitter send its -normal page event. If the page call returns `{ accepted: false }`, the SDK finished the call but did -not admit that page event locally; the sequence still advances without an immediate same-route -retry. - -The watchdog uses 3,000 ms when `maxWaitMs` is omitted and accepts any positive finite value. A -value of `0`, a negative number, `NaN`, `Infinity`, or `-Infinity` synchronously throws -`TypeError('beforeInitialPage.maxWaitMs must be a positive finite number.')` before the provider, -callback, page, `onError`, or watchdog runs. A callback throw, returned-work rejection, or watchdog -expiry is reported to `onError` when supplied. While the root remains mounted and the same live -owned runtime is current, the root still attempts the page. - -The watchdog stops waiting but does not cancel callback code or a request it already sent. -Fire-and-forget work that the callback does not return can finish after the page. If the root -unmounts or its live runtime is replaced, only unsent local page and readiness continuation is -suppressed; work already started is not canceled. - -A route change after the direct page attempt starts neither cancels that attempt nor starts a -competing page attempt. The root settles and marks the captured attempted route before enabling -later page emission. A route observed only while the attempt is in flight is not emitted; a route -change after readiness emits normally. - -> [!NOTE] -> -> If callback and page work remain pending when an entry reaches its existing five-second fallback -> deadline, the entry can reveal baseline content. With live updates disabled, that first visible -> content stays frozen even if the before-initial-page work later selects a variant. Enable -> [Browser takeover and live updates](#browser-takeover-and-live-updates) only when a late -> replacement is intended. - -Use the accepted and blocked event streams introduced in -[Analytics forwarding](#analytics-forwarding) for a development-only ordering check. +**Adapt this to your use case:** add a browser-only page outside your `getServerSideProps` route. +It receives no request handoff through page props, so the callback runs on first load. -**Adapt this to your use case:** temporarily add this observer to the same browser binding. It logs -complete event records and removes both subscriptions when the root tears down. +```tsx +// pages/browser-only.tsx +import Link from 'next/link' -```diff - bindNextjsPagesRouterOptimization({ - // your existing browser config and beforeInitialPage -+ onStatesReady: (states) => { -+ if (process.env.NODE_ENV !== 'development') return -+ -+ const accepted = states.eventStream.subscribe((event) => { -+ if (event) console.debug('Contentful Optimization event accepted', event) -+ }) -+ const blocked = states.blockedEventStream.subscribe((event) => { -+ if (event) console.debug('Contentful Optimization event blocked', event) -+ }) -+ -+ return () => { -+ accepted.unsubscribe() -+ blocked.unsubscribe() -+ } -+ }, - }) +export default function BrowserOnlyPage() { + return ( +
+

Browser-only route

+ Next view +
+ ) +} ``` -Set the example identity first with -`localStorage.setItem('app-user-id', 'guide-user')`, then reload the page that uses -`beforeInitialPage`. The identify and `client_ready` results must appear, as accepted or blocked -calls, before at most one initial `page` result. Navigate once and confirm one later `page` result. -An initial `page` before -callback completion or two initial page results usually means the separate tracker is still -mounted. These streams prove local SDK admission or blocking, not API delivery. +The callback runs after the live owned runtime exists and once per retained `_app` root lifetime; a +real remount starts another lifetime. Return a Promise or **thenable** (an object with `.then()`) +for all work the root must await. It waits for that work or its watchdog, then reads the latest +route and lazy payload for one direct page attempt. After the attempt settles, its built-in page +emitter records an initial non-emitting `skip` mark for the attempted route, then emits for later +routes. `{ accepted: false }` means the page was not admitted locally; the root still advances +without an immediate same-route retry. + +The watchdog waits 3,000 ms by default; `maxWaitMs` must be positive and finite. A callback throw, +rejected returned work, or watchdog expiry reaches `onError`, then the root continues to the page +while mounted on the same live runtime. The watchdog does not cancel work already started. A route +change during an in-flight direct attempt neither cancels it nor starts another; a route observed +only during that interval is not emitted, and a change after readiness emits normally. Work the +callback starts without returning can finish after the page. -The Pages server binder deliberately rejects `NextjsClientOptimizationConfigWithBeforeInitialPage` -through its `beforeInitialPage?: never` parameter boundary. The callback remains browser-only: -the server binder does not run it or serialize it into the handoff that the app passes through page -props. Direct Web and Node integrations keep this ordering in application code by awaiting their -identity or custom-event work before calling their existing page-event API. +> [!NOTE] +> +> If callback and page work remain pending when an entry reaches its five-second fallback deadline, +> baseline content can become visible. With live updates off, that visible content stays frozen. +> Enable [Browser takeover and live updates](#browser-takeover-and-live-updates) if late replacement +> is intended. + +For a development check, run `/browser-only` and set the example identity with +`localStorage.setItem('app-user-id', 'guide-user')`, then reload. The accepted or blocked `identify` +and `client_ready` results must appear before at most one initial `page` result. Click **Next view** +after readiness and confirm one later page result. The observer proves local admission or blocking, not +HTTP delivery. Two initial page results usually mean a separate tracker remains mounted. Remove +the observer after checking. The Pages server binder does not accept, run, or serialize this +browser-only callback into page props; its provider and analytics root also omit it. ### Personalizing entries @@ -614,7 +611,7 @@ identity or custom-event work before calling their existing page-event API. `OptimizedEntry` receives a `baselineEntry` fetched by your page, a managed `entryId` plus optional `entryQuery`, or a content-type/slug descriptor under `managedEntry`. The descriptor can also set `slugField` and `entryQuery`. Its render prop receives the resolved entry. If no -experience applies, consent is denied, the API has no variant, or a linked variant cannot be +experience applies, the selection-producing event is blocked by the effective event gate, the API has no variant, or a linked variant cannot be resolved, the render receives the baseline entry. `isEmptyVariant === true` marks the SDK renderer's no-content state. It differs from the fallback @@ -844,7 +841,6 @@ export async function getStaticProps({ params }) { selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }), }, hero: resolvedHero.isEmptyVariant ? null : resolvedHero.entry, @@ -889,8 +885,9 @@ entry and selection context for tracking even when consumer output is empty. **Integration category:** Advanced or production-only `private-request` handoffs include request-specific state and must not be stored in a shared public -cache. Catch Experience API failures according to your app's policy; many apps return baseline props -when personalization is unavailable. +cache. A recoverable Experience preview failure already returns a handoff with baseline selections +and valid prepared replay. Catch other thrown handoff, entry-prefetch, or render failures according +to your app's policy; many apps return baseline props for those errors. Use the handoff concept to review why request profile state must stay out of public caches, and use the supplemental rendering guide for static and ISR public permutation handoff patterns. @@ -918,10 +915,13 @@ export async function getServerSideProps(context) { **Integration category:** Advanced or production-only -When no Optimization event may emit before explicit consent, configure a strict event policy and -return `false` from `consent.server` until your app-owned consent record is accepted. Use -`initialPageEvent="skip"` only when a handoff lets the root own the same route's first page event. -Use blocked-event diagnostics to verify denied events are dropped at the SDK boundary. +When no Optimization event may emit before explicit consent, configure +`allowedEventTypes: []` in both the server and browser bindings, and return `false` from +`consent.server` until your app-owned consent record is accepted. The default allow-list can admit a +page before consent. In +normal mode, pass the same handoff to the root and tracker. In `beforeInitialPage` mode, mount no +tracker; the root handles replay or a direct browser page. Use blocked-event diagnostics to verify +denied events are dropped at the SDK boundary. ## Production checks @@ -930,9 +930,15 @@ Use blocked-event diagnostics to verify denied events are dropped at the SDK bou - Confirm `consent.server`, request handoff defaults, browser consent defaults, and app-owned consent storage agree. - Confirm `ctfl-opt-aid` is browser-readable where server and browser profile continuity is needed. -- Confirm first-page ownership matches one mode. In normal tracker mode, the separate tracker skips - a server-owned first event and emits later routes. In `beforeInitialPage` mode, no tracker is - mounted and the root's direct attempt plus built-in emitter do not duplicate the initial route. +- Confirm raw server HTML reflects the preview result, and check the browser Network panel for the + prepared Experience batch after hydration. Local acceptance does not prove HTTP delivery. +- Temporarily add the app-owned `server_render` track event to `initialEvents` as shown above, grant + event consent, and reload. In the browser Network panel, the prepared `track` and `page` must + appear in one Experience batch with no additional ordinary first page. A page-only request points + to missing or mismatched replay. Remove the diagnostic event afterward. +- In normal mode, confirm the tracker admits one prepared first-route batch without a second + ordinary page, then tracks later routes. In `beforeInitialPage` mode, confirm a paired replay + bypasses the callback; on `/browser-only`, confirm callback results precede one direct page. - Confirm baseline fallback is acceptable when no variant applies or Contentful links are unresolved. - Confirm request-personalized output is never stored in a public shared cache. @@ -941,14 +947,14 @@ Use blocked-event diagnostics to verify denied events are dropped at the SDK bou ## Troubleshooting -| Symptom | Likely cause | Check | -| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Entries stay on baseline | Missing handoff props, no matching variant, denied consent, unresolved variant links, or all-locale CDA payload | Target all visitors for the first test, pass `contentfulOptimization.handoff` into `_app.tsx`, and fetch one locale with enough `include` depth | -| A heterogeneous render cannot read content-type-specific fields | The skeleton union omits a possible content type, or the entry was not narrowed before rendering | Include every baseline and variant skeleton in `S`, then narrow with `isEntryOfContentType` | -| Page returns 500 instead of baseline | The request handoff call threw and the page did not catch it | Wrap the personalization helper according to your fallback policy | -| Duplicate first page events | Normal tracker mode gave both root and tracker the initial event, or `beforeInitialPage` mode still mounts the tracker | In normal tracker mode, set the tracker from the handoff's `initialPageEvent`; in `beforeInitialPage` mode, remove the tracker and let the root own initial and later pages | -| Live entries do not change after identify or reset | The entry is locked to the handoff and live updates are off | Enable live updates for the route or entry, or open the preview panel in an allowed environment | -| Personalized HTML is cached for the wrong visitor | Request handoff output entered a public cache | Keep request handoff pages private and use public permutation handoff only for explicit static or ISR permutations | +| Symptom | Likely cause | Check | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Entries stay on baseline | Missing handoff props, no matching variant, a selection-producing event blocked by effective policy, recoverable preview failure, unresolved variant links, or all-locale CDA payload | Target all visitors, pass the handoff into `_app.tsx`, inspect server preview diagnostics, and fetch one locale with enough `include` depth | +| A heterogeneous render cannot read content-type-specific fields | The skeleton union omits a possible content type, or the entry was not narrowed before rendering | Include every baseline and variant skeleton in `S`, then narrow with `isEntryOfContentType` | +| Page returns 500 instead of baseline | The request handoff call threw and the page did not catch it | Wrap the personalization helper according to your fallback policy | +| Duplicate first page events | Normal mode lacks the same handoff on root and tracker, a `beforeInitialPage` root also mounts a tracker, or app code emits an extra page | Pass one handoff to root and tracker in normal mode; remove the tracker in `beforeInitialPage` mode and avoid manual first-page calls | +| Live entries do not change after identify or reset | The entry is locked to the handoff and live updates are off | Enable live updates for the route or entry, or open the preview panel in an allowed environment | +| Personalized HTML is cached for the wrong visitor | Request handoff output entered a public cache | Keep request handoff pages private and use public permutation handoff only for explicit static or ISR permutations | ## Reference implementations to compare against diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md index 0bb0222ff..24a700ac1 100644 --- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md +++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md @@ -1083,8 +1083,9 @@ behavior beyond the default React Native settings. before any screen or identify path can run, or accept that pre-consent screen and identify events will be dropped. 2. Use `onEventBlocked` to surface consent or guard failures in diagnostics. -3. Configure `queuePolicy.offlineMaxEvents` and `queuePolicy.onOfflineDrop` when the offline - Experience buffer must have explicit bounds and observability. +3. Configure `queuePolicy.offlineMaxEvents` when the offline Experience buffer needs an explicit + bound. At capacity, incoming Experience events return `{ accepted: false }`, while accepted events + keep their order. The deprecated `onOfflineDrop` callback is inactive. 4. Configure `queuePolicy.flush` only when flush behavior must differ from SDK defaults across shared Insights and Experience delivery. 5. Use `api` endpoint overrides and `fetchOptions` for staging, mocks, and request-level options such @@ -1104,10 +1105,6 @@ behavior beyond the default React Native settings. queuePolicy={{ // Bound the offline Experience buffer for constrained mobile networks. offlineMaxEvents: 50, - onOfflineDrop: ({ droppedCount }) => { - // The callback receives a context object; droppedCount is how many events were dropped. - diagnostics.log('Dropped offline Experience events', { droppedCount }) - }, }} fetchOptions={{ requestTimeout: 3000, // ms before a request times out (default 3000). diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md index a450f978c..153335488 100644 --- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md +++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md @@ -265,7 +265,7 @@ The props you pass break down like this: 2. `locale` is the one locale the SDK uses for Experience and event context. Use the same locale you pass to Contentful. 3. `defaults` is the browser SDK's starting state: `consent` (may personalize and send events) and - `persistenceConsent` (may store the profile-id cookie — the anonymous identifier the SDK assigns + `persistenceConsent` (may store the profile-id cookie — the anonymous identifier the Experience API issues each visitor to keep their variant assignments consistent across visits). 4. `api` overrides the Experience and Insights endpoints (`experienceBaseUrl`, `insightsBaseUrl`). Set these only for a mock, a proxy, or non-default hosts; omit them otherwise. @@ -662,10 +662,11 @@ change. React Web ships auto page trackers for common routers; each dedupes cons including React Strict Mode's double effects. Choose one page-ownership mode for each root. The steps below use the normal tracker mode. In -`beforeInitialPage` mode, the **initial page decision** is the root's one choice to send the first -browser `page` event or skip it because an applied handoff already owns that route. The root runs -the callback after its live owned runtime exists, makes that direct page attempt or skip, marks the -attempted route without emitting it again, and emits for later route changes. Replace the tracker +`beforeInitialPage` mode, the **initial page decision** is the root's first page attempt after its +browser work completes. A root receiving a prepared server replay commits that replay through the +normal handoff path instead of running the callback. Without prepared replay, the root runs the +callback after its live owned runtime exists, makes a direct page attempt, marks the attempted route +without emitting it again, and emits for later route changes. Replace the tracker with the [before-initial-page root](#run-work-before-the-initial-page-decision). @@ -749,10 +750,97 @@ In normal tracker mode, attach route-aware properties with `getPagePayload`: /> ``` -In normal tracker mode, the `next-pages` and `next-app` trackers also accept -`initialPageEvent="skip"` for setups where an SSR handoff root owns the first route. In a -browser-only React SPA you emit the first page event yourself, so leave it at the default -(`"emit"`). +In a server-rendered React app, pass the same prepared handoff to the root and the tracker for your router, including +`ReactRouterAutoPageTracker`, `NextPagesAutoPageTracker`, or `NextAppAutoPageTracker`. It gives the +Web SDK the current route and handoff together. The Web SDK admits +a matching replay as one batch; an absent or mismatched replay falls back to an ordinary page. In a +browser-only React SPA, the tracker emits the initial page event itself. + +After the handoff is admitted, the mounted tracker emits fresh page events for later navigation, +including a return to the initial route, using its current page payload settings. A distinct +prepared handoff can initialize its own replay. + +The [Node guide's paired-route example](./integrating-the-node-sdk-in-a-node-app.md#share-continuity-with-the-web-sdk) +shows where the server's `{ data, handoff }` comes from. A React content root needs a browser +hydration mode and preview selection state in addition to the Node preparation handoff. Compose +`createRequestHandoffFromData({ data })` over the prepared handoff so the browser receives the +selections used for server HTML while retaining the prepared replay and API-issued profile ID. Add +`hydration: 'preserve-server'` once on the server, then pass that same object to the server render +and serialize it for browser hydration. The mode keeps server-rendered content visible during +browser startup. If preview has no `data`, the composition retains any valid replay and baseline +presentation. + +**Adapt this to your use case:** define one root layout that receives the handoff as a prop. It +reads no browser globals during server rendering. Give the same prop to the root and tracker so +replay and route identity reach the Web SDK together. + +```tsx +import { OptimizationRoot } from '@contentful/optimization-react-web' +import type { ContentOptimizationHandoff } from '@contentful/optimization-react-web/handoff' +import { ReactRouterAutoPageTracker } from '@contentful/optimization-react-web/router/react-router' +import { Outlet } from 'react-router-dom' + +export function RootLayout({ handoff }: { handoff?: ContentOptimizationHandoff }) { + return ( + + + + + ) +} +``` + +**Adapt this to your use case:** in the Node request handler, continue after +`prepareRequestHandoff()` returns. `renderExistingReactRoute` stands for your app's existing SSR +renderer with its server router and entry components; pass the shown root into that renderer. The +`handoffJson` value goes into the `optimization-handoff` JSON script from the Node guide's EJS +pattern. Keep the same route and entry tree for browser hydration. + +```tsx +import { createRequestHandoffFromData } from '@contentful/optimization-node' +import type { ContentOptimizationHandoff } from '@contentful/optimization-react-web/handoff' +import { RootLayout } from './RootLayout' + +const { data, handoff } = await requestOptimization.prepareRequestHandoff({ + routeKey: req.originalUrl, + page: { properties: { path: req.path } }, +}) +const reactHandoff = { + ...handoff, + ...createRequestHandoffFromData({ data }), + hydration: 'preserve-server', +} satisfies ContentOptimizationHandoff +const html = await renderExistingReactRoute(req, , data) +res.render('page', { + html, + handoffJson: JSON.stringify(reactHandoff).replace(/, + children: [{ index: true, element: }], + }, +]) + +hydrateRoot(document.getElementById('root')!, ) +``` ### Consent and privacy handoff @@ -933,6 +1021,12 @@ Experience event work before that root makes its initial page decision. This is the router tracker in [Page events and route tracking](#page-events-and-route-tracking), not an addition to it. The before-initial-page root is the sole page owner for its subtree. +If this root receives a handoff with a prepared replay, it uses the handoff path for the first +route. The callback does not run for that replay, and the SDK commits the server-prepared events as +one browser batch. Supply initial identity and custom events to the server's +`prepareRequestHandoff()` instead. The callback sequence below applies when there is no prepared +replay. + After the root's owned runtime is live, the callback runs once during a retained root lifetime, which starts when that `OptimizationRoot` mounts and ends when it unmounts. A real remount starts a new lifetime and runs the callback again. The SDK-provided `BeforeInitialPageClient` exposes three @@ -1025,7 +1119,7 @@ The watchdog timeout bounds how long the root waits for returned callback work: A **direct page attempt** means the root calls the page-event API itself once before automatic route tracking starts. The root's **page emitter** is its built-in route-change logic, not a tracker -component you mount. After the direct attempt finishes, its initial `skip` mark records the attempted +component you mount. After the direct attempt finishes, its internal non-emitting mark records the attempted route as handled without sending another event. A later route change makes the emitter send its normal page event. If the page call returns `{ accepted: false }`, the SDK finished the call but did not admit that page event locally; the sequence still advances and does not retry the same route diff --git a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md index 9fb1db5e6..dab83c283 100644 --- a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md +++ b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md @@ -231,7 +231,7 @@ The config you pass to `new ContentfulOptimization(...)` breaks down like this: 3. `api` overrides the Experience and Insights endpoints (`experienceBaseUrl`, `insightsBaseUrl`). Set these only for a mock, a proxy, or non-default hosts; omit them otherwise. 4. `defaults` is the SDK's starting state: `consent` (may personalize and send events) and - `persistenceConsent` (may store the profile-id cookie — the anonymous identifier the SDK assigns + `persistenceConsent` (may store the profile-id cookie — the anonymous identifier the Experience API issues each visitor to keep their variant assignments consistent across visits). If you set `consent` but omit `persistenceConsent`, `persistenceConsent` defaults to your `consent` value. 5. `app` is your app's name and version, sent as event metadata. @@ -579,9 +579,10 @@ first load and on every route change. consecutive identical route keys (a manual `page()` always emits when consent permits it). 3. Include stable page properties — url, path, search, referrer, title — when your router or analytics taxonomy needs them. -4. In hybrid apps where the server already emitted the first page event, pass - `initialPageEvent: 'skip'` to `trackCurrentPage` for the first browser route so the browser does - not report a duplicate (see +4. In a paired server and browser route, pass the server's prepared handoff to + `hydrateAndTrackCurrentPage()` with the current `routeKey`. It admits the matching prepared + events, including the page, as one browser queue batch. Without a matching replay it uses an + ordinary page event (see [Hybrid Node SSR and browser continuity](#hybrid-node-ssr-and-browser-continuity)). Both `page()` and the page event emitted by `trackCurrentPage()` inherit the Web SDK's default page @@ -1195,18 +1196,63 @@ Use this integration when the same app uses `@contentful/optimization-node` on t `@contentful/optimization-web` in the browser, and you want the same visitor's profile to carry across the boundary. -1. Decide whether the server or browser owns the first personalization decision for each route. +1. Prepare the first route's identify, track, and page inputs on the server with the Node SDK's + `prepareRequestHandoff()`. Its preview supplies rendering selections without committing events; + pass the returned handoff to the browser. Use the same visible pathname plus search as `routeKey` + on both sides. 2. Share the anonymous profile identifier through the SDK's `ANONYMOUS_ID_COOKIE` value (`ctfl-opt-aid`) when consent permits durable profile continuity. This cookie is **SDK-owned** — match the exact name; do not invent your own. 3. Write the cookie from the server with `Path=/` and a same-site policy that matches your app, and do **not** mark it `HttpOnly` — the browser SDK must read it to keep the same profile after takeover. -4. Use `trackCurrentPage({ initialPageEvent: 'skip', ... })` for the first browser route when the - server already emitted the same initial page event, so the browser does not duplicate it. +4. Use `hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload })` for the first browser route. + An admitted matching replay enters the browser queue as one batch, including its page event. A handoff + without a matching replay uses ordinary page tracking. For an admitted replay, `accepted` means + queue admission, not completed HTTP delivery. An ordinary page attempt returns its own tracking + result. Continue calling `trackCurrentPage()` from your app's route-change handler for later routes. 5. On consent denial or revocation, clear the shared cookie and avoid persisting a returned profile id. Treat server-rendered personalized HTML as personalized output for cache policy. +The [Node guide's paired-route example](./integrating-the-node-sdk-in-a-node-app.md#share-continuity-with-the-web-sdk) +shows how a request handler prepares `{ data, handoff }` and serializes the handoff into a JSON +script. `data` supplies server-rendered selections; the browser reads only the handoff. The +`optimization` variable below is the Web SDK instance created earlier in this guide. + +**Adapt this to your use case:** read that script at browser startup and make one current-page call. +`buildPayload` is a lazy ordinary-page fallback when the handoff has no replay for the visible +pathname plus search. Keep your app's route-change handler calling `trackCurrentPage()` for later +routes. + +```ts +import type { OptimizationHandoff } from '@contentful/optimization-web/core-sdk' + +const routeKey = `${window.location.pathname}${window.location.search}` +const handoffJson = document.getElementById('optimization-handoff')?.textContent + +if (handoffJson && handoffJson !== 'null') { + const handoff = JSON.parse(handoffJson) as OptimizationHandoff + const result = await optimization.hydrateAndTrackCurrentPage(handoff, { + routeKey, + buildPayload: () => ({ properties: { path: window.location.pathname } }), + }) + if (!result.accepted) console.warn('Optimization first page was not admitted locally') +} else { + await optimization.trackCurrentPage({ + routeKey, + buildPayload: () => ({ properties: { path: window.location.pathname } }), + }) +} +``` + +If replay validation or admission fails, this call attempts ordinary current-page tracking with +`buildPayload`; its result reflects that page attempt. Consent, queue capacity, the current route, +and deduplication can still prevent it. If the ordinary attempt also returns `accepted: false`, call +the same current-page path again after your app records consent on that route to retry the prepared +sequence. An accepted ordinary page completes this initialization, so repeating the call shares its +result instead of retrying the replay. An admitted replay stays in the queue for delivery and does +not trigger an ordinary page attempt if HTTP delivery fails. + **Follow this pattern:** build the shared anonymous-id `Set-Cookie` on the server. ```ts @@ -1219,7 +1265,9 @@ function buildAnonymousIdSetCookie(id: string | undefined): string { } ``` -`ANONYMOUS_ID_COOKIE` re-exports the core constant and equals `'ctfl-opt-aid'`. For the lower-level +`ANONYMOUS_ID_COOKIE` re-exports the core constant and equals `'ctfl-opt-aid'`. Store only a known +Experience API-issued ID in it; the SDK does not invent a profile ID when server preview fails. +For the lower-level mechanics, see [Profile synchronization between client and server](../concepts/profile-synchronization-between-client-and-server.md). diff --git a/documentation/guides/migrating-experience-js-next-to-nextjs-app-router.md b/documentation/guides/migrating-experience-js-next-to-nextjs-app-router.md index 2e58653c7..bf10135ae 100644 --- a/documentation/guides/migrating-experience-js-next-to-nextjs-app-router.md +++ b/documentation/guides/migrating-experience-js-next-to-nextjs-app-router.md @@ -19,7 +19,7 @@ experience.js wiring and you want to move server rendering to ## What changes The App Router server binding provides a nested `optimization.request` component family for request -context, server first paint, route tracking, entry resolution, and browser handoff. A separate client +context, server preview, first paint, entry resolution, and browser handoff. A separate client binding supports bound Client Components. Legacy Next provider, tracker, SSR plugin, ESR helper, React component, and plugin behavior should be replaced by these App Router surfaces plus the shared migration guides. @@ -90,10 +90,11 @@ profile cookie is `ctfl-opt-aid`; it must be browser-readable so browser takeove same visitor. The app still owns the consent record and the server consent resolver. Every `optimization.request` wrapper shares one SDK-owned initializer for the active request. It -derives the request URL, route key, page payload, hydration mode, and handoff once. Mount +derives the request URL, route key, page payload, hydration mode, and handoff once. The server +prepares the initial events and previews their selections without committing them. Mount `optimization.request.NextAppAutoPageTracker` inside `optimization.request.OptimizationRoot`; the -tracker receives first-page-event ownership from that shared handoff automatically. Do not create or -pass app-owned handoff, route-key, page-payload, or `initialPageEvent` plumbing for the ordinary +browser applies the shared handoff and admits matching prepared events through its SDK queue. Do +not create or pass app-owned handoff, route-key, or page-payload plumbing for the ordinary request-family path. ### Replace server-rendered personalization @@ -129,7 +130,7 @@ Verify server HTML, hydration, and browser takeover together: - Server rendering uses the nested `optimization.request` root, entry, and tracker components. - Server-rendered content uses the expected variant or baseline. - Browser hydration does not briefly revert to empty optimization state. -- The request tracker receives first-page-event ownership without app-owned handoff or tracker props. +- The browser admits the prepared first-route events without app-owned handoff or tracker props. - Personalized HTML and resolved outputs are not shared across visitors through caching. ## Validate the migration @@ -142,12 +143,12 @@ Verify server HTML, hydration, and browser takeover together: ## Troubleshooting -| Symptom | Check | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Request components report a missing request URL | Confirm the request handler file, export, and route matcher for your Next.js version. | -| The route conflicts with static generation | Request-family personalization is dynamic; use a public-permutation, static, or browser-only path when required. | -| Hydration changes a managed entry | Prefetch the matching descriptor through the request root and keep the browser on the same component path. | -| Duplicate page events appear on a request-family path | Mount the request-family root and tracker together; remove app-owned handoff and `initialPageEvent` tracker props. | +| Symptom | Check | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Request components report a missing request URL | Confirm the request handler file, export, and route matcher for your Next.js version. | +| The route conflicts with static generation | Request-family personalization is dynamic; use a public-permutation, static, or browser-only path when required. | +| Hydration changes a managed entry | Prefetch the matching descriptor through the request root and keep the browser on the same component path. | +| Duplicate page events appear on a request-family path | Mount the request-family root and tracker together; remove legacy page calls and emit/skip tracker props. | ## Related guides diff --git a/documentation/guides/migrating-experience-js-next-to-nextjs-pages-router.md b/documentation/guides/migrating-experience-js-next-to-nextjs-pages-router.md index 73781855a..d2ad94b11 100644 --- a/documentation/guides/migrating-experience-js-next-to-nextjs-pages-router.md +++ b/documentation/guides/migrating-experience-js-next-to-nextjs-pages-router.md @@ -16,8 +16,8 @@ or legacy React surfaces and you want to move to the Optimization Pages Router S The Pages Router target uses `@contentful/optimization-nextjs/pages-router` for browser components and `@contentful/optimization-nextjs/pages-router/server` for `getServerSideProps`. Server props -own request evaluation and profile continuity; the browser root receives a request handoff and -continues with React Web behavior. +prepare events and preview request state for rendering. The browser root receives the handoff and +commits the prepared events through its SDK queue. Start with the [Next.js Pages Router integration guide](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md). @@ -62,12 +62,12 @@ Use the target guide to create both bindings: Mount the target `OptimizationRoot` and `NextPagesAutoPageTracker` in `_app.tsx`, passing `pageProps.contentfulOptimization.handoff` to the root. `contentfulOptimization` is an app-owned page props wrapper; its `handoff` field is the SDK `BrowserOptimizationHandoff` returned by the server -binding's `createRequestHandoff(context, options)` helper. The handoff can contain browser consent defaults, -request-scoped optimization state, managed entries, and the required `initialPageEvent` value. +binding's `createRequestHandoff(context, options)` helper. The handoff can contain browser consent +defaults, request-scoped optimization state, managed entries, and prepared events for the browser. -The root consumes the handoff's initial-page instruction. Keep the separate tracker mounted with -`initialPageEvent={handoff ? 'skip' : 'emit'}` so it skips the first route whenever the root has a -handoff and emits only when no handoff exists. +The root hydrates the handoff and sends matching prepared events through the browser SDK queue. +Pass the same handoff to the separate tracker so its first-route work shares the root's replay +admission. The tracker handles routes without a handoff and later navigation. If migrated components will use ``, configure the server binding with the app's `contentful` client. Pass `prefetchManagedEntries` descriptors—entry IDs or objects containing @@ -84,15 +84,19 @@ destructure its returned `createRequestHandoff` helper. Call handoff to the app-owned `contentfulOptimization.handoff` prop. If your app wraps this sequence in a helper, define that helper in the server module before importing it into a page. -The server binding resolves request consent, emits the first page event when allowed, writes the -anonymous-id cookie when profile persistence permits it, and returns the request handoff. Observe -accepted server evaluation by checking -`contentfulOptimization.handoff.initialPageEvent === 'skip'`; observe denied consent by checking -that no Experience API call is made and the value is `'emit'`. +The server binding resolves request consent, prepares the first page event, and previews it through +the Experience API with per-request `preflight: true` when allowed. It writes the browser-readable +anonymous-ID cookie when profile persistence permits it and an API-issued or existing profile ID is +available. A successful preview supplies selections for server rendering without committing the +event. If preview fails, the handoff retains prepared events for the browser and the server can +render baseline content. When the effective page event gate blocks preparation, no replay is +prepared. For a strict opt-in policy, set `allowedEventTypes: []` as well as returning `false` from +`consent.server` until consent is granted; the Node default otherwise permits pre-consent page and +identify events. -Pass the handoff to `OptimizationRoot`. The root follows its `initialPageEvent` value, while -`NextPagesAutoPageTracker` uses `initialPageEvent={handoff ? 'skip' : 'emit'}` to avoid duplicating -the root's first-route decision. Keep legacy route-change code removed. +Pass the same handoff to `OptimizationRoot` and `NextPagesAutoPageTracker`. On the matching route, +the browser admits prepared events as one SDK queue batch and then uses normal tracking for later +navigation. Keep legacy route-change code removed. ### Replace personalized rendering @@ -124,8 +128,8 @@ Verify the server and browser handoff: - The server binding's `createRequestHandoff(context, options)` runs in `getServerSideProps` on the personalized page. - The app-owned `pageProps.contentfulOptimization.handoff` reaches `OptimizationRoot` in `_app.tsx`. -- The handoff records accepted server evaluation with `initialPageEvent: 'skip'`, and the separate - tracker skips whenever that handoff is present. +- The handoff carries a matching prepared replay when the page event is admitted, and browser event + diagnostics show one first-route admission through the SDK queue. - A target `OptimizedEntry` renders a variant or baseline. - Personalized results are not cached outside the request boundary. @@ -133,18 +137,19 @@ Verify the server and browser handoff: - Search for `@ninetailed/experience.js-next`, SSR plugin imports, `ntaid`, and legacy React surfaces. -- Verify accepted server evaluation and denied-consent behavior. +- Verify successful server preview, browser event delivery, and strict denied-consent behavior + separately. - Verify all-locale Contentful payloads are not used for optimized entries. - Verify client-side plugin replacements only after the route and rendering work. ## Troubleshooting -| Symptom | Check | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| First page events duplicate | Pass the handoff to `OptimizationRoot`; set `NextPagesAutoPageTracker` to `initialPageEvent={handoff ? 'skip' : 'emit'}`. | -| `getServerSideProps` returns a 500 on API failure | Wrap the server helper and render baseline on failure when your app needs graceful fallback. | -| Browser render cannot find managed entries | Pass `prefetchManagedEntries` descriptors in the `options` argument to `createRequestHandoff(context, options)`. | -| Hooks import fails | Import React Web hooks from `@contentful/optimization-nextjs/client`, not `/pages-router`. | +| Symptom | Check | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| First page events duplicate | Pass the same handoff to the root and tracker; remove legacy page calls and tracker emit/skip props. | +| `getServerSideProps` returns a 500 on API failure | Wrap the server helper and render baseline on failure when your app needs graceful fallback. | +| Browser render cannot find managed entries | Pass `prefetchManagedEntries` descriptors in the `options` argument to `createRequestHandoff(context, options)`. | +| Hooks import fails | Import React Web hooks from `@contentful/optimization-nextjs/client`, not `/pages-router`. | ## Related guides diff --git a/documentation/guides/migrating-experience-js-node-ssr-and-esr.md b/documentation/guides/migrating-experience-js-node-ssr-and-esr.md index 9e85af5fe..df4e89f91 100644 --- a/documentation/guides/migrating-experience-js-node-ssr-and-esr.md +++ b/documentation/guides/migrating-experience-js-node-ssr-and-esr.md @@ -17,7 +17,8 @@ helpers, or a manual server-to-browser handoff. Legacy server code commonly uses `NinetailedAPIClient`, SSR plugin continuity, `ntaid`, or ESR preflight helpers. The Optimization Node SDK is stateless: create one process-level SDK, bind each incoming request with `forRequest()`, and let the app own cookies, consent, profile persistence, -request context, and caching. +request context, and caching. When the browser continues a server-rendered page, the server +previews prepared events and the browser commits them through its SDK queue. Follow the [Node SDK integration guide](./integrating-the-node-sdk-in-a-node-app.md) unless a Next.js adapter owns the route. @@ -53,8 +54,8 @@ Identify which server code owns each responsibility: - Resolves Contentful entries before rendering. - Hands state to the browser. -This inventory prevents one request from being evaluated twice or from losing the profile before -browser takeover. +This inventory identifies legacy first-page calls to remove before the browser commits a prepared +handoff. ### Replace Node API client calls @@ -77,9 +78,19 @@ guide so the adapter owns request state, provider handoff, page-event dedupe, an For a manual Node/Web hybrid, the app owns the profile cookie. The Node SDK exports `ANONYMOUS_ID_COOKIE` from `@contentful/optimization-node/constants`, and its value is `ctfl-opt-aid`, but Node does not read, write, or clear cookies for you. Read the cookie from the -incoming request, pass it as `forRequest({ profile: { id } })`, write the returned profile ID only -when persistence consent allows it, and keep the cookie browser-readable if the Web SDK must -continue the same visitor. +incoming request and pass it as `forRequest({ profile: { id } })`. On a paired page, call +`prepareRequestHandoff({ routeKey, initialEvents, page })` with the pathname and search used by the +browser. The request previews those events with per-request `preflight: true`; its returned `data` +can select useful server-rendered content, and its `handoff` carries the prepared replay. Serialize +the handoff to the browser and call `hydrateAndTrackCurrentPage(handoff, { routeKey })` there. The +browser admits a matching replay to the SDK queue, subject to current consent and queue capacity; +preview success does not establish backend event commitment. A preview failure retains the replay +while the server can render baseline content. + +Write only an existing or API-issued profile ID when persistence consent allows it, and keep the +cookie browser-readable if the Web SDK must continue the same visitor. Do not invent a profile ID +on the server. A standalone Node event path that has no browser handoff can continue to use +request-bound `page()` directly. ### Replace server content resolution @@ -101,12 +112,13 @@ Verify the request boundary: - Blocked events do not throw and surface diagnostics. - The app persists the profile only when persistence consent allows it. - Entry resolution uses request selections. -- Browser takeover uses a compatible target SDK path. +- Browser takeover uses the same pathname and search route key and admits prepared events through + the Web SDK queue. ## Validate the migration - Search for `@ninetailed/experience.js-node`, SSR plugin imports, ESR helper imports, and `ntaid`. -- Verify one accepted request and one denied-consent request. +- Verify server preview, browser event delivery, and denied consent as separate outcomes. - Verify a resolved server entry falls back to baseline with no selection. - Verify cache keys do not share personalized output across visitors. diff --git a/documentation/guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md b/documentation/guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md index 2a4246ad8..271a073b8 100644 --- a/documentation/guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md +++ b/documentation/guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md @@ -36,7 +36,7 @@ Vocabulary used below: - **Customer-owned** means owned by your application team. It does not mean a site visitor owns the selection. - Cache scope and hydration strings such as `public-permutation`, `static`, `private-request`, - `preserve-server`, `client-only-hidden-until-ready`, `analytics-only`, `emit`, and `skip` are + `preserve-server`, `client-only-hidden-until-ready`, and `analytics-only` are SDK-owned exact values. Route keys, payload `properties`, environment variable names, and helper names are application-owned. `permutationKey`, `cacheVersion`, and Next.js tags are application-owned cache inputs; `handoff.cache.key` and `ctfl-opt-cache-key` are SDK-generated @@ -70,9 +70,10 @@ Start with one App Router Cache Components route whose permutation is owned by y The example expects these app-owned helpers: - `@/lib/optimization` exports the `optimization` server binding from - `@contentful/optimization-nextjs/app-router/server`. The examples alias its top-level root as - `ExplicitOptimizationRoot`. These explicit-input exports are separate from the nested private - request family. + `@contentful/optimization-nextjs/app-router/server` for public handoff creation and server entry + resolution. `@/components/PublicHandoffRoots` owns the browser roots and initializes them from the + current app consent decision. These public inputs remain separate from the nested private request + family. - `getHeroEntry()` fetches the baseline Contentful entry for the route. - `getPublicSegments()` returns the public segment slugs that Next.js can pre-render. - `getPublicSegment(slug)` returns @@ -112,6 +113,73 @@ The SDK hydrates the selected state your app supplies; it does not discover the selected optimizations. Start with one approved segment whose selected variant has distinctive text; the route below fetches the baseline and calls `resolveEntriesForSelections()` with that segment. +The public route's browser runtime needs the app's current consent decision when it starts. A fixed +`consent.clientDefaults` value from a request-route binding is not a fresh browser consent read. +Keep the server-rendered permutation independent of visitor cookies. This app-owned client boundary +reads the browser-visible `app-consent=accepted` cookie written by the app-owned +[consent controls in the App Router integration guide](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#consent-identity-profile-and-reset) +when each root mounts. Match the cookie name and accepted value to your app's writer; an absent cookie +resolves to `false` here. Replace the reader with your consent manager when it owns the decision. If +the decision is asynchronous, wait for it before mounting the SDK root. + +**Adapt this to your use case:** + +```tsx +// components/PublicHandoffRoots.tsx +'use client' + +import { + OptimizationAnalyticsRoot, + OptimizationRoot, + type AutoPagePayload, + type OptimizationAnalyticsRootProps, + type OptimizationRootProps, +} from '@contentful/optimization-nextjs/client' +import type { ReactNode } from 'react' + +const browserConfig = { + spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, + environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', +} as const + +type ContentProps = { + children: ReactNode + handoff: NonNullable + initialPagePayload: AutoPagePayload + locale: string + routeKey: string +} + +type AnalyticsProps = { + children: ReactNode + handoff: OptimizationAnalyticsRootProps['handoff'] + initialPagePayload: AutoPagePayload + locale: string + routeKey: string +} + +function browserDefaults() { + const consent = + typeof document !== 'undefined' && document.cookie.split('; ').includes('app-consent=accepted') + return { consent, persistenceConsent: consent } +} + +export function PublicContentRoot(props: ContentProps) { + return +} + +export function PublicAnalyticsRoot(props: AnalyticsProps) { + return +} +``` + +If your app decides event and persistence consent separately, pass both decisions to `defaults` +instead of deriving them from one cookie. Keep any consent-dependent controls out of the cached +server markup so the first browser render can hydrate it consistently. Defaults apply when a root +initializes. If consent changes while `PublicContentRoot` stays mounted, update the app-owned record +and call `setConsent` from `useOptimizationActions()`. `PublicAnalyticsRoot` exposes no consent action; +remount it with the new decision. Changing only the cookie does not update either active runtime. + Each resolved item also carries optional `isEmptyVariant`. When it is `true`, `entry` retains the baseline for tracking context, but direct route markup must omit consumer content. An absent flag renders normally. @@ -121,12 +189,13 @@ renders normally. ```tsx // app/segments/[segment]/page.tsx import { Hero } from '@/components/Hero' +import { PublicContentRoot } from '@/components/PublicHandoffRoots' import { optimization } from '@/lib/optimization' import { getHeroEntry, getPublicSegment, getPublicSegments } from '@/lib/segments' import { createPublicPermutationHandoff } from '@contentful/optimization-nextjs/app-router/server' import { cacheLife, cacheTag } from 'next/cache' -const { OptimizationRoot: ExplicitOptimizationRoot, resolveEntriesForSelections } = optimization +const { resolveEntriesForSelections } = optimization async function getSegmentData(segmentSlug: string) { 'use cache' @@ -161,17 +230,17 @@ export default async function SegmentPage({ params }: { params: Promise<{ segmen selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) return ( - ({ properties: { locale: segment.locale, segment: segment.slug } })} + {resolvedHero.isEmptyVariant ? null : } - + ) } ``` @@ -184,6 +253,11 @@ Verify that first segment before adding more: 4. During first validation, log `handoff.cache.key` next to the handoff creation, change `segment.cacheVersion`, request the route again, and verify the logged key changes. Remove the temporary log after the route is validated. +5. In a browser that accepted app persistence consent and already has an SDK-owned `ctfl-opt-aid` + profile ID cookie, record that ID in browser devtools. Load and reload the public route, then + verify that the cookie keeps the same ID. +6. In a fresh browser profile without app persistence consent, load the same route and verify that + no `ctfl-opt-aid` cookie is written. This quick-start proof does not validate tag invalidation. `cacheTag()` and `revalidateTag()` are application-owned Next.js invalidation paths; validate them with your production webhook, Server @@ -215,7 +289,8 @@ Action, or Route Handler outside the first route proof. Use the same ownership test for every route: the cache owner must match the Optimization state that produced the markup. A **baseline entry** is the Contentful entry before Optimization resolution. Resolving entries means applying selected optimizations to those baseline entries before rendering. -`initialPageEvent` tells the browser whether to emit or skip the first page event for the route. +Public and static handoffs contain selected rendering state without a prepared request replay. The +browser root tracks the current page after hydration when event consent allows it. A campaign can have two independent meanings in these recipes. A public `permutationKey` can name an app-owned campaign and contributes to public cache identity. A page event's `context.campaign` is @@ -338,13 +413,10 @@ Resolve and assemble each usable permutation in the route that renders it: 3. Call `resolveEntriesForSelections()` with those baseline entries and the record's `selectedOptimizations`. 4. Call `createPublicPermutationHandoff()` with the same public key, `cacheVersion`, locale, entry - IDs, `selectedOptimizations`, optional `changes`, hydration mode, and initial page-event - ownership used by the route. + IDs, `selectedOptimizations`, optional `changes`, and hydration mode used by the route. 5. Use `cache: { scope: 'static' }` with `createHandoffFromSelections()` for one build-time static output. Use `createPublicPermutationHandoff()` for Cache Components, Pages Router ISR, Edge runtime, or CDN-cached public outputs. -6. Use `initialPageEvent: 'emit'` unless a request or edge helper already accepted the first page - event for the same route. **Follow this pattern:** @@ -366,7 +438,6 @@ const handoff = createPublicPermutationHandoff({ selectedOptimizations: permutation.selectedOptimizations, changes: permutation.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) ``` @@ -438,7 +509,7 @@ export function BrowserOwnedHero({ hero }) { buildPagePayload={() => ({ properties: { path: '/landing' } })} > - + {(resolvedHero) => } @@ -466,14 +537,11 @@ uses `static` because there is no request profile and no ISR or CDN permutation ```tsx // app/static-segment/page.tsx import { Hero } from '@/components/Hero' +import { PublicContentRoot } from '@/components/PublicHandoffRoots' import { optimization } from '@/lib/optimization' import { getBuildSelection, getHeroEntry } from '@/lib/static-segment' -const { - OptimizationRoot: ExplicitOptimizationRoot, - createHandoffFromSelections, - resolveEntriesForSelections, -} = optimization +const { createHandoffFromSelections, resolveEntriesForSelections } = optimization export default async function StaticSegmentPage() { const selection = await getBuildSelection() @@ -487,17 +555,17 @@ export default async function StaticSegmentPage() { changes: selection.changes, cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', }) return ( - ({ properties: { path: '/static-segment' } })} + {resolvedHero.isEmptyVariant ? null : } - + ) } ``` @@ -532,7 +600,6 @@ const handoff = createPublicPermutationHandoff({ selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) ``` @@ -641,7 +708,6 @@ export async function GET(_request: Request, { params }: { params: Promise<{ seg selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) const response = await renderEdgeSegmentResponse({ handoff, hero, segment }) @@ -661,10 +727,13 @@ reads visitor state, use a `private-request` handoff. Use this when an Edge runtime route owns a `Response` and renders for the current request. The route must export `runtime = 'edge'` and avoid Node-only APIs. This is a reference excerpt for custom route handlers that already turn application HTML into a `Response`; it is not an App Router page -recipe. The helper reads request cookies and headers, emits the page event, returns a browser -handoff, and gives the route a `persist(response)` callback for the SDK-owned anonymous ID cookie. +recipe. The helper reads request cookies and headers, previews the page event, returns a browser +handoff with the prepared events, and gives the route a `persist(response)` callback for the +SDK-owned anonymous ID cookie. The browser commits matching prepared events through its SDK queue. `app-consent` is a reader-owned consent cookie name. Configure `consent.server` explicitly; if it is -omitted, Edge request consent resolves to `false`. +omitted, Edge request consent resolves to `false`. The inherited Node pre-consent allow-list still +permits page and identify events by default; set `allowedEventTypes: []` when your policy must block +all events until consent. **Reference excerpt:** @@ -687,7 +756,8 @@ const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ }) export async function GET(request: Request) { - const routeKey = new URL(request.url).pathname + const url = new URL(request.url) + const routeKey = `${url.pathname}${url.search}` const { handoff, persist } = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', @@ -707,7 +777,7 @@ export async function GET(request: Request) { response private because the handoff can include request profile state. In this example, `createEdgeRequestHandoff()` builds `page.url` from the full `request.url`; the -pathname-only `routeKey` identifies the route for duplicate-event control. Because `pagePayload` +pathname and search in `routeKey` must match the browser route for prepared-event delivery. Because `pagePayload` supplies only `properties.path`, the request-backed `page.url` is the campaign source when it has a supported UTM parameter. If you customize that payload, the SDK chooses one whole source in order: top-level `campaign`, then a UTM-bearing `properties.url`, then `page.url`. An explicit empty @@ -735,12 +805,13 @@ public-cache identity input. These three inputs do not populate one another. ```tsx import { Hero } from '@/components/Hero' +import { PublicAnalyticsRoot } from '@/components/PublicHandoffRoots' import { optimization } from '@/lib/optimization' import { getAnalyticsSegment, getHeroEntry } from '@/lib/analytics-segments' import { createPublicPermutationHandoff } from '@contentful/optimization-nextjs/app-router/server' import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' -const { OptimizationAnalyticsRoot, resolveEntriesForSelections } = optimization +const { resolveEntriesForSelections } = optimization export default async function AnalyticsOnlyPage() { const segment = await getAnalyticsSegment('campaign-a') @@ -757,20 +828,20 @@ export default async function AnalyticsOnlyPage() { selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'analytics-only', - initialPageEvent: 'emit', }) const trackingAttributes = getServerTrackingAttributes(hero, resolvedHero) return ( - ({ properties: { pageCategory: 'campaign-landing' } })} +
{resolvedHero.isEmptyVariant ? null : }
-
+ ) } ``` diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md index 74422a0c8..059178846 100644 --- a/documentation/internal/sdk-knowledge/native/android.md +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -401,7 +401,9 @@ fallback)`'s own fail-soft `catch` only fires on a cyclic self-referential map o (`ProcessLifecycleOwner` observer) calls `flush()` on `onStop` (app moving to background) for a best-effort drain. Both are created after bridge init. Queues are in-memory only (no durable outbox), capped at 100 offline Experience events by default (`OFFLINE_QUEUE_MAX_EVENTS`, tunable via - `QueuePolicy.offlineMaxEvents`); they do not survive process death. source: extern:NetworkMonitor + AppLifecycleHandler background flush — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt#NetworkMonitor; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt#AppLifecycleHandler; core-sdk#CoreStateful.ts#QueuePolicy + `QueuePolicy.offlineMaxEvents`); they do not survive process death. At capacity, incoming + Experience events are rejected and accepted events retain FIFO order. The deprecated + `onOfflineDrop` callback is inactive. source: extern:NetworkMonitor + AppLifecycleHandler background flush — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/NetworkMonitor.kt#NetworkMonitor; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/handlers/AppLifecycleHandler.kt#AppLifecycleHandler; core-sdk#CoreStateful.ts#QueuePolicy; core-sdk#queues/ExperienceQueue.ts#send - Android emulator localhost: the SDK has **no** localhost rewrite (contrast the React Native SDK, whose reference app rewrites `localhost`→`10.0.2.2` at runtime). The Android reference app instead hardcodes the emulator's stable host-loopback alias `http://10.0.2.2:8000` as its base host, avoiding diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md index 6352660b0..31beb89b4 100644 --- a/documentation/internal/sdk-knowledge/native/ios.md +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -331,7 +331,9 @@ viewportHeight:)` from its own scroll/layout callbacks and the controller applie connectivity change and `flush()` on reconnect; `AppStateHandler` calls `flush()` on `willResignActive` (UIKit-only) for a best-effort background drain. Queues are in-memory only (no durable outbox), capped at 100 offline Experience events by default (`OFFLINE_QUEUE_MAX_EVENTS`, - tunable via `QueuePolicy.offlineMaxEvents`); they do not survive process death. source: extern:NetworkMonitor NWPathMonitor + AppStateHandler background flush — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/NetworkMonitor.swift#NetworkMonitor; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/AppStateHandler.swift#AppStateHandler; core-sdk#CoreStateful.ts#CoreStatefulConfig + tunable via `QueuePolicy.offlineMaxEvents`); they do not survive process death. At capacity, + incoming Experience events are rejected and accepted events retain FIFO order. The deprecated + `onOfflineDrop` callback is inactive. source: extern:NetworkMonitor NWPathMonitor + AppStateHandler background flush — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/NetworkMonitor.swift#NetworkMonitor; extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Handlers/AppStateHandler.swift#AppStateHandler; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#queues/ExperienceQueue.ts#send - iOS Simulator reaches host `localhost` directly — there is no localhost rewrite in the SDK, and the reference app points base URLs straight at `http://localhost:8000` (contrast React Native, which rewrites `localhost` to `10.0.2.2` on the Android emulator). source: impl:ios-sdk#shared/Config.swift; extern:the iOS Simulator shares the host network so host localhost resolves without a rewrite — packages/ios/README.md diff --git a/documentation/internal/sdk-knowledge/native/react-native.md b/documentation/internal/sdk-knowledge/native/react-native.md index 0cb5b0c32..a96503b3b 100644 --- a/documentation/internal/sdk-knowledge/native/react-native.md +++ b/documentation/internal/sdk-knowledge/native/react-native.md @@ -49,9 +49,8 @@ source root: `packages/react-native-sdk/src`; shared core: `packages/universal/c 'apiName'>`): `requestTimeout?` (ms, default `3000`), `retries?` (max retry attempts, default `1`), `intervalTimeout?` (delay between retries in ms, default `0`), `fetchMethod?` (custom fetch), `onRequestTimeout?`, `onFailedAttempt?`. source: api-client#ApiClientBase.ts#ApiConfig; api-client#fetch/createProtectedFetchMethod.ts#ProtectedFetchMethodOptions; api-client#fetch/createTimeoutFetchMethod.ts#TimeoutFetchMethodOptions; api-client#fetch/createRetryFetchMethod.ts#RetryFetchMethodOptions -- Queue/blocked callback argument shapes: `queuePolicy.onOfflineDrop(context)` receives an - `ExperienceQueueDropContext` object — `{ droppedCount, droppedEvents (oldest-first), maxEvents, -queuedEvents }` — not a single dropped event. `onEventBlocked(event)` receives a `BlockedEvent` — +- `queuePolicy.onOfflineDrop` and `ExperienceQueueDropContext` are deprecated compatibility + interfaces; offline admission does not invoke the callback. `onEventBlocked(event)` receives a `BlockedEvent` — `{ reason: 'consent', method: string, args: readonly unknown[] }` (the blocked method name and its original call arguments). source: core-sdk#CoreStateful.ts#QueuePolicy; core-sdk#queues/ExperienceQueue.ts#ExperienceQueueDropContext; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#events/BlockedEvent.ts#BlockedEvent - `OptimizationRootProps extends CoreStatefulConfig` and adds `liveUpdates?: boolean` (default @@ -305,6 +304,9 @@ selectedOptimizations, changes }` payload, and this stateful SDK applies it to i - Background flush: `createAppStateChangeListener` calls the SDK's `flush()` then drains pending AsyncStorage writes on `AppState` `background`/`inactive`, before the OS can suspend the process. source: react-native-sdk#handlers/createAppStateChangeListener.ts#createAppStateChangeListener; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization +- Offline Experience admission rejects an incoming event at the configured cap (100 by default) + and retains accepted events in FIFO order; see [`../shared/concepts.md`](../shared/concepts.md#experience-response-payload). + source: core-sdk#CoreStateful.ts#QueuePolicy; core-sdk#queues/ExperienceQueue.ts#send - Polyfills: importing the package entry runs side-effect imports for `crypto.randomUUID` (`react-native-get-random-values` + `react-native-uuid`) and ES2025 iterator helpers, plus a `*.png` module declaration. source: react-native-sdk#index.ts#OptimizationConfig; react-native-sdk#polyfills/crypto.ts diff --git a/documentation/internal/sdk-knowledge/node/node.md b/documentation/internal/sdk-knowledge/node/node.md index 2a8e4f333..dd32ef563 100644 --- a/documentation/internal/sdk-knowledge/node/node.md +++ b/documentation/internal/sdk-knowledge/node/node.md @@ -106,6 +106,12 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core receives the handoff. Request handoffs must use `private-request` cache metadata; the helper throws a `TypeError` for `public-permutation` or `static` cache metadata before returning a handoff. source: node-sdk#handoff.ts#createRequestHandoffFromData; core-sdk#handoff.ts#assertOptimizationCacheSafety; core-sdk#handoff.ts#OptimizationHandoff +- The request-bound `prepareRequestHandoff()` prepares the initial identify/track and page events, + then previews them through a per-request Experience API mutation with `preflight: true`. Preview + failure retains a replay without selected state; blocked page consent or invalid inputs produce + no replay. The browser must commit a prepared replay separately. The preview returns an API-issued + profile ID even for a first request; no SDK-created ID is reserved. + source: core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff; core-sdk#CoreStatelessRequest.ts#buildHandoffEvents ## Identifier ownership @@ -123,8 +129,9 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core `track({ event, properties? })` (`event` required) → return `EventEmissionResult` `{ accepted, data? }` where `data` is the accepted Experience-response `OptimizationData` payload (see [`../shared/concepts.md`](../shared/concepts.md#experience-response-payload)). Being stateless, - the request client does not apply it to any signals; instead accepted `page()`/`identify()` update - this request client's `profile` getter and cached `selectedOptimizations` for the rest of the request. + the request client does not apply it to any signals. Experience responses update this request + client's `profile` getter and cached `selectedOptimizations` only if its profile revision has not + advanced while the call was pending; superseded responses still return their accepted data. source: core-sdk#CoreStatelessRequest.ts#page; core-sdk#CoreStatelessRequest.ts#identify; core-sdk#CoreStatelessRequest.ts#screen; core-sdk#CoreStatelessRequest.ts#track; core-sdk#events/EventEmissionResult.ts#EventEmissionResult; api-client#schemas/experience/ExperienceResponse.ts#OptimizationData - Insights methods: `trackView(payload)` returns `EventEmissionResult`; `trackClick`, `trackHover`, `trackFlagView` return `void`. `trackView({ componentId, viewId, viewDurationMs, experienceId?, variantIndex?, sticky? })`. @@ -145,7 +152,7 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core `allowedEventTypes: []` blocks them until request consent is `true`. Event-type wire strings: `identify`, `page`, `screen`, `track`, `component` (view/flag view), `component_click`, `component_hover`; `flag` is a consent selector only. - source: core-sdk#CoreStatelessRequest.ts#hasConsent; core-sdk#CoreStatelessRequest.ts#withRequestEventConsent; core-sdk#events/EventType.ts#AllowedEventType; node-sdk#ContentfulOptimization.ts#DEFAULT_NODE_ALLOWED_EVENT_TYPES + source: core-sdk#CoreStatelessRequest.ts#hasConsent; core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent; core-sdk#events/EventType.ts#AllowedEventType; node-sdk#ContentfulOptimization.ts#DEFAULT_NODE_ALLOWED_EVENT_TYPES - Blocked events do NOT throw: Experience methods return `{ accepted: false }`, Insights methods return without sending; `onEventBlocked({ reason: 'consent', method, args })` fires for diagnostics. source: core-sdk#CoreStatelessRequest.ts#sendExperienceEvent; core-sdk#CoreStatelessRequest.ts#reportBlockedEvent; core-sdk#events/BlockedEvent.ts#BlockedEvent @@ -172,9 +179,10 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core ## Version / runtime quirks - Stateless per request: construct the SDK once, call `forRequest()` per incoming request, then call - event methods / entry fetches on that request client. The request client's `profile` getter is the - only mutable request-local state and updates only from accepted Experience responses. - source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest; core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent + event methods / entry fetches on that request client. Its cached profile and selections update + from ordinary Experience responses and handoff previews under the shared + [response publication contract](../shared/concepts.md#experience-response-payload). + source: core-sdk#CoreStatelessRequest.ts#CoreStatelessRequest; core-sdk#CoreStatelessRequest.ts#publishExperienceData; kb:shared/concepts.md - Locale precedence: when `forRequest()` receives both a top-level `locale` and `experienceOptions.locale`, the top-level request `locale` wins — the constructor rewrites `experienceOptions.locale` (and `eventContext.locale`) to the normalized request locale. diff --git a/documentation/internal/sdk-knowledge/shared/concepts.md b/documentation/internal/sdk-knowledge/shared/concepts.md index 771cfeea0..aef0debd3 100644 --- a/documentation/internal/sdk-knowledge/shared/concepts.md +++ b/documentation/internal/sdk-knowledge/shared/concepts.md @@ -184,10 +184,11 @@ source: react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider; reac ## Page events A page event signals a page/route view. Auto-page trackers emit them on navigation and dedupe -consecutive route keys. When the server already reported a consented page view, the browser must -skip the duplicate (per-SDK `initialPageEvent` / tracker prop). Interaction events +consecutive route keys. A server preview supplies rendered selections without committing page +effects; the browser admits the prepared replay for that route, or emits an ordinary page when no +matching replay exists. Interaction events (view/click/hover) are consent-gated browser activity and use the resolved entry id. -source: react-web-sdk#auto-page/useAutoPageEmitter.ts; react-web-sdk#router/next-app.tsx +source: react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage ## Campaign attribution @@ -246,19 +247,43 @@ blocked attempts as accepted, so a later caller/effect can retry the same curren fresh payload under the current consent state. source: core-sdk#CoreStatefulEventEmitter.ts#sendExperienceEventWithResult; core-sdk#CoreStatefulEventEmitter.ts#sendInsightsEvent; core-sdk#CoreStatefulEventEmitter.ts#reportBlockedEvent; core-sdk#CoreStateful.ts#consent; core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker; web-sdk#ContentfulOptimization.ts#trackCurrentPage; optimization-js-bridge#index.ts#Bridge +Current-page/screen trackers compare keys to skip accepted or in-flight duplicates, but each emission +owns its completion bookkeeping by attempt identity. A superseded attempt cannot mark its key +accepted or clear the active attempt, even when a later attempt uses the same key or follows a +tracker reset. Its own caller still receives its emission result. +source: core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker + ## Experience response payload An accepted Experience API request (page/identify/screen/track) returns `OptimizationData` `{ profile, selectedOptimizations, changes }` — this response is the origin of the profile, the selected optimizations, and the computed flag `changes` the rest of the SDK consumes. `OptimizationData` mirrors the wire `ExperienceData` but renames its `experiences` field to `selectedOptimizations`. A -stateful SDK applies the payload to its personalization signals (`profile`, `selectedOptimizations`, +stateful SDK applies a publishable payload to its personalization signals (`profile`, `selectedOptimizations`, `changes`) in one batch, transitioning the Experience-request state to `success` atomically with the selections so consumers never see `!pending` while optimization is still unavailable. Stateful Core state interceptors are field-presence aware: omitted interceptor fields keep the original payload field, while an own present `undefined` field is applied intentionally. A stateless SDK returns the -same payload per request instead of holding it. -source: api-client#schemas/experience/ExperienceResponse.ts#OptimizationData; api-client#schemas/experience/ExperienceResponse.ts#ExperienceData; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals +same payload per call and caches profile and selections within its request-bound client. +source: api-client#schemas/experience/ExperienceResponse.ts#OptimizationData; api-client#schemas/experience/ExperienceResponse.ts#ExperienceData; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals; core-sdk#CoreStatelessRequest.ts#publishExperienceData + +Online stateful Experience calls capture the shared profile revision before event interception; +queued batches capture it when delivery starts. After state +interceptors run, the response can publish only if its lifecycle is still live and the profile +revision is unchanged. An intervening profile write, including hydration or preview state, prevents +the entire response from replacing profile, selections, or changes, even if the profile ID is +unchanged. Every response publication also advances the revision, including publications whose +profile is deeply equal and therefore does not trigger a profile signal update. This publication +check preserves accepted delivery and returned response data; a live +successful request still settles the Experience-request state to `success`. +source: core-sdk#queues/ExperienceQueue.ts#send; core-sdk#queues/ExperienceQueue.ts#upsertProfile; core-sdk#signals/profileRevision.ts#getProfileRevision; core-sdk#signals/profileRevision.ts#advanceProfileRevision; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals + +Stateless ordinary Experience calls capture the request client's profile revision before event +interception; handoff previews capture it before +asynchronous event preparation. They publish profile and selections to that client only if no other +response has published since capture; each successful publication advances the revision. +A superseded response still returns its own data and, for a preview, its prepared handoff. +source: core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent; core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff; core-sdk#CoreStatelessRequest.ts#publishExperienceData Event-method acceptance and response data are separate: `EventEmissionResult` is `{ accepted: false } | { accepted: true, data?: OptimizationData }`. An accepted queued/offline @@ -266,6 +291,15 @@ event can therefore have no `data` yet; only a returned `data` value contains th selections, and changes described above. source: core-sdk#events/EventEmissionResult.ts#EventEmissionResult; core-sdk#CoreStatefulEventEmitter.ts#sendExperienceEventWithResult +The offline Experience queue admits at most 100 events by default, configurable with +`QueuePolicy.offlineMaxEvents`. At capacity it rejects the entire incoming ordinary event or +prepared batch with `{ accepted: false }`, retaining previously accepted events in FIFO order. +`QueuePolicy.onOfflineDrop` and `ExperienceQueueDropContext` are deprecated compatibility surfaces +and do not report these rejections. An active `flush()`, including a forced call, joins the running +attempt. Completion means the attempt settled; it does not guarantee delivery or wait for a +scheduled retry. +source: core-sdk#CoreStateful.ts#QueuePolicy; core-sdk#queues/ExperienceQueue.ts#ExperienceQueueDropContext; core-sdk#queues/ExperienceQueue.ts#send; core-sdk#queues/ExperienceQueue.ts#sendPrepared; core-sdk#queues/ExperienceQueue.ts#flush; core-sdk#queues/InsightsQueue.ts#flush + ## API transport scope The API Client scopes Experience API requests under @@ -313,10 +347,10 @@ optimizations, preserves the input entry order, and returns each resolved result baseline entry. source: core-sdk#handoff.ts#resolveEntriesForSelections; core-sdk#resolvers/OptimizedEntryResolver.ts#resolveWithContext -Browser handoffs extend the core handoff with `hydration` and `initialPageEvent`. Content handoffs +Browser handoffs extend the core handoff with `hydration`. Content handoffs are accepted by `hydrateOptimizationHandoff`; analytics-only handoffs are accepted by the analytics -runtime. Both hydration paths validate `initialPageEvent` and enforce cache safety before state is -published. Browser SDK state hydration is Web handoff-owned: `@contentful/optimization-web/handoff` +runtime. Both hydration paths enforce cache safety before state is published. Browser SDK state +hydration is Web handoff-owned: `@contentful/optimization-web/handoff` exports `hydrateOptimizationHandoffState` for customer adapters; that helper awaits the Web SDK state interceptor only when handoff state contains present `selectedOptimizations`, `changes`, or `profile` own fields, keeps input handoff fields when an interceptor omits them, applies own present @@ -324,7 +358,39 @@ state interceptor only when handoff state contains present `selectedOptimization undefined or empty handoff state. Content handoff state hydration starts from a content reset for `selectedOptimizations` and `changes`, so a new content-capable handoff that omits those fields clears stale browser content state while preserving `profile` unless `profile` is an own field. -source: web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#handoff.ts#hydrateOptimizationHandoffState; web-sdk#handoff.ts#applyHydratedSignals; web-sdk#handoff.ts#applySuccessfulEmptyHandoffHydration; core-sdk#handoff.ts#assertOptimizationCacheSafety +source: web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#handoff-internal.ts#hydrateOptimizationHandoffState; web-sdk#handoff-internal.ts#applyHydratedSignals; web-sdk#handoff-internal.ts#applySuccessfulEmptyHandoffHydration; core-sdk#handoff.ts#assertOptimizationCacheSafety + +`prepareRequestHandoff()` validates and prepares an ordered initial `identify`/`track` plus page +batch, then previews it with a request-scoped Experience API `preflight: true` mutation. Preview +does not commit the events. On preview failure, the prepared replay remains available without +selection state; denied page consent or invalid preparation yields no replay. A successful preview +returns an API-issued profile ID, including when the request had no prior ID. The SDK does not +invent an ID to reserve for the preview. A later browser replay uses `preflight: false` and can +receive a different linked API-issued ID. +source: core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff; core-sdk#CoreStatelessRequest.ts#buildHandoffEvents; api-client#experience/ExperienceApiClient.ts#upsertProfile; core-sdk#queues/ExperienceQueue.ts#sendPrepared; core-sdk#queues/ExperienceQueue.ts#upsertProfile + +The prepared batch has at most 100 events total, including exactly one final page, and at most 50 +identify events. Events before the page are identify or track. Validation happens before the preview +request; invalid inputs produce no replay. Browser admission checks the configured queue capacity +for the whole batch, even when the queue is larger than the prepared-batch limit. +source: core-sdk#events/preparedEvents.ts#validatePreparedEvents; core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff; core-sdk#queues/ExperienceQueue.ts#sendPrepared + +`hydrateAndTrackCurrentPage()` hydrates the current handoff and admits a matching replay as one +retained Experience queue batch. Admission settles without waiting for HTTP delivery; the queue +retries a failed batch and publishes successful responses under the shared lifecycle and profile +revision guards. Prepared events are +validated and consent is checked again before admission; exceeding queue capacity rejects the +whole batch. Rejected validation or admission makes the Web SDK attempt ordinary current-page +tracking with the caller's payload builder or its default, subject to current route, lifecycle, +deduplication, consent, and queue guards. Repeated initialization +for the same replay and route shares the admission, while another route or fresh replay can be +tracked. At the start of a current initialization, the Web SDK adopts the existing profile ID ahead +of handoff hydration and retains that identity when later work becomes stale. A stale initialization +suppresses pending hydration and replay emission; a replay already accepted into the queue remains +accepted; delivery failure does not trigger ordinary page tracking. An absent or mismatched replay +uses ordinary current-page tracking. A profileless public/static handoff preserves durable browser profile +continuity while publishing its content selections in memory. +source: web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#ContentfulOptimization.ts#adoptHandoffIdentity; web-sdk#handoff-internal.ts#getHandoffInitialization; web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; core-sdk#CoreStatefulEventEmitter.ts#sendPreparedExperienceEvents; core-sdk#queues/ExperienceQueue.ts#sendPrepared; core-sdk#queues/ExperienceQueue.ts#sendQueuedBatches Snapshot and preview-override paths consume selection state, not necessarily a full Experience response: snapshot runtimes resolve from whichever `selectedOptimizations`, `changes`, and `profile` diff --git a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md index baf69e8a3..4ea8f21f9 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -56,28 +56,21 @@ dependencies; each carries a symbol-anchored source pointer. The bound `OptimizationRoot` / `OptimizationProvider` accept `hydration` plus `prefetchManagedEntries` descriptors; descriptors are fetched server-side through `sdk.prefetchManagedEntries()` and merged into `handoff.entries`. - source: `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#app-router-server.tsx#getAppRouterBaselineEntry`; `nextjs-sdk#app-router-server.tsx#resolveHandoffEntries`; `nextjs-sdk#app-router-server.tsx#toClientProviderConfig`; `core-sdk#CoreBase.ts#ContentfulConfig`; `core-sdk#CoreBase.ts#fetchContentfulEntry`; `core-sdk#CoreBase.ts#prefetchManagedEntries`; `core-sdk#CoreBase.ts#resolveOptimizedEntry` + source: `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#app-router-server.tsx#getAppRouterBaselineEntry`; `nextjs-sdk#app-router-server.tsx#resolveHandoffEntries`; `nextjs-sdk#app-router-server.tsx#toClientRootConfig`; `core-sdk#CoreBase.ts#ContentfulConfig`; `core-sdk#CoreBase.ts#fetchContentfulEntry`; `core-sdk#CoreBase.ts#prefetchManagedEntries`; `core-sdk#CoreBase.ts#resolveOptimizedEntry` - A bound server managed `OptimizedEntry` fetches and resolves that one server render but does not add its baseline entry to the browser handoff. A browser managed entry is seeded only when a matching `ManagedEntryHandoff` reaches the root through `handoff.entries` or is produced from the root's `prefetchManagedEntries` descriptors. Otherwise the browser has no Contentful client in its derived config and cannot infer the server component's managed fetch. - source: `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#app-router-server.tsx#renderBoundRootTree`; `nextjs-sdk#app-router-server.tsx#resolveHandoffEntries`; `nextjs-sdk#app-router-server.tsx#toClientProviderConfig`; `react-web-sdk#provider/OptimizationProvider.tsx#createPrefetchedManagedEntries` -- Request handoff: the bound `createRequestHandoff(options)` reads forwarded server context from the - request headers' `x-ctfl-opt-server-data` value only when `trustedRequestHandoff: true` is passed. - Raw forwarded server-data headers are ignored without that explicit opt-in. Trusted forwarded - context can carry `consent`, boolean `pageAccepted`, and optional non-empty `profileId`, not full - `OptimizationData`; when `profileId` is present, the helper fetches profile/selection data with - `getProfile()` and builds a browser handoff without evaluating `page()` again. The profile fetch - uses request handoff `locale` before bound config `locale` before `experienceOptions.locale`, and - forwards `experienceOptions.ip` when supplied. Boolean consent seeds both consent axes; object - consent seeds `consent` when `events` is present and always sets `persistenceConsent`, defaulting - missing `persistence` to `false`. The handoff uses `pageAccepted: true` for - `initialPageEvent: 'skip'` and `pageAccepted: false` for `initialPageEvent: 'emit'`. Without valid - forwarded context, the helper binds the request, calls `page()`, builds a browser handoff, and sets - `initialPageEvent` to `'skip'` exactly when `pageResult.accepted` is true; response data presence - is not the page-event ownership signal. - source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#app-router-request-handoff.ts#NextjsForwardedServerData`; `nextjs-sdk#app-router-request-handoff.ts#readNextjsForwardedServerData`; `nextjs-sdk#app-router-request-handoff.ts#toForwardedProfileOptions`; `nextjs-sdk#app-router-request-handoff.ts#toHandoffDefaults`; `nextjs-sdk#request-context.ts#NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER`; `nextjs-sdk#request-context.ts#parseNextjsOptimizationRequestContext`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff` + source: `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#app-router-server.tsx#renderBoundRootTree`; `nextjs-sdk#app-router-server.tsx#resolveHandoffEntries`; `nextjs-sdk#app-router-server.tsx#toClientRootConfig`; `react-web-sdk#provider/OptimizationProvider.tsx#createPrefetchedManagedEntries` +- Request handoff: the bound `createRequestHandoff()` resolves server consent from the request, + binds cookie profile continuity and the request URL, then prepares a route-keyed identify/track + plus page replay. The Experience preview supplies render state when available; preview failure + still returns replay for browser commitment and leaves selection state absent. The browser + handoff seeds consent defaults: boolean consent seeds both axes, while object consent seeds event + consent only when present and defaults missing persistence consent to `false`. Request components + share one React-cached preparation per RSC request. + source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#app-router-request-handoff.ts#toHandoffDefaults`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff` - The server binder's nested `request` components share one no-argument React-cached initializer. It reads Next.js headers and cookies, derives the request URL, route key, initial page payload, and hydration once, creates one request handoff, and shares those render inputs among the four wrappers @@ -94,8 +87,7 @@ dependencies; each carries a symbol-anchored source pointer. source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#bound-component-types.ts#NextjsAppRouterRequestHydration` - Selection handoff: the bound `createHandoffFromSelections(input)` adds browser hydration metadata to the Core selection handoff. It is the lower-level App Router helper for explicit selection - handoffs; applications supply the selected optimizations, cache metadata, hydration mode, and - initial page-event ownership. + handoffs; applications supply the selected optimizations, cache metadata, and hydration mode. source: `nextjs-sdk#handoff.ts#createHandoffFromSelections`; `core-sdk#handoff.ts#createHandoffFromSelections` - Public permutation handoff: `createPublicPermutationHandoff(input)` creates public-permutation cache metadata from `permutationKey`, optional `cacheVersion`, locale, entry IDs, selected @@ -139,16 +131,17 @@ dependencies; each carries a symbol-anchored source pointer. `createEdgeRequestHandoff(options)` reads cookies from a Next cookie reader or the raw `cookie` header, resolves server consent with cookies and headers, derives profile continuity from the anonymous-id cookie when no explicit profile is supplied, builds page context from - URL/referrer/user-agent, emits `page()`, sets `initialPageEvent` from whether that page event was - accepted, and returns `persist(response)` for the anonymous-id `Set-Cookie` append. - source: `nextjs-sdk#edge.ts#configureNextjsEdgeOptimization`; `nextjs-sdk#edge.ts#createEdgeOptimizationRuntime`; `nextjs-sdk#constants.ts#OPTIMIZATION_NEXTJS_SDK_VERSION`; `nextjs-sdk#edge.ts#createEdgeRequestSnapshot`; `nextjs-sdk#edge.ts#createEdgeRequestContext`; `nextjs-sdk#edge.ts#createEdgeRequestOptimizationHandoff`; `nextjs-sdk#edge.ts#persistEdgeAnonymousId` + URL/referrer/user-agent, and prepares a replay plus preview state. `persist(response)` appends an + anonymous-id `Set-Cookie` when persistence permits it, including after preview failure when a + known cookie profile ID exists. + source: `nextjs-sdk#edge.ts#configureNextjsEdgeOptimization`; `nextjs-sdk#edge.ts#createEdgeOptimizationRuntime`; `nextjs-sdk#constants.ts#OPTIMIZATION_NEXTJS_SDK_VERSION`; `nextjs-sdk#edge.ts#createEdgeRequestSnapshot`; `nextjs-sdk#edge.ts#createEdgeRequestContext`; `nextjs-sdk#edge.ts#persistEdgeAnonymousId`; `core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff` - Manual `/server` flow: `configureNextjsServerOptimization(config)` creates the long-lived stateless server runtime; `bindNextjsOptimizationRequest(sdk, options)` binds consent, request/page context, locale, and profile continuity to one request; `createNextjsRequestHandoff()` - emits the page event and returns a browser handoff. + previews the prepared initial batch and returns a browser handoff for later commitment. `getServerTrackingAttributes(baselineEntry, resolvedData)` maps a manual resolution to the `data-ctfl-*` attributes browser interaction tracking consumes. - source: `nextjs-sdk#server.tsx#configureNextjsServerOptimization`; `nextjs-sdk#server.tsx#bindNextjsOptimizationRequest`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#server.tsx#persistNextjsAnonymousId`; `nextjs-sdk#server.tsx#ServerOptimizedEntry`; `nextjs-sdk#tracking-attributes.ts#getServerTrackingAttributes` + source: `nextjs-sdk#server.tsx#configureNextjsServerOptimization`; `nextjs-sdk#server.tsx#bindNextjsOptimizationRequest`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#cookies.ts#persistNextjsAnonymousId`; `nextjs-sdk#server.tsx#ServerOptimizedEntry`; `nextjs-sdk#tracking-attributes.ts#getServerTrackingAttributes` ## Components & hooks @@ -187,7 +180,7 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` prop is not invoked; an absent empty-variant flag renders normally. source: `nextjs-sdk#server-entry-renderer.tsx#renderOptimizedEntryOnServer`; `nextjs-sdk#server-entry-renderer.tsx#resolveOptimizedEntryChildren`; `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#server.tsx#ServerOptimizedEntry` - `prefetchManagedEntries` without a supplied `handoff` creates a synthetic `static` + - `preserve-server` handoff with `selectedOptimizations: []` and `initialPageEvent: 'emit'`. + `preserve-server` handoff with `selectedOptimizations: []` and no replay. source: `nextjs-sdk#app-router-server.tsx#resolveHandoffEntries` - Runtime props that provide zero or multiple `baselineEntry`, `entryId`, and `managedEntry` sources reject before any managed fetch; the error names those three allowed sources. @@ -197,29 +190,32 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` | Identifier | Owner | Notes | source | | --------------------------------------- | ------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by response-persistence helpers; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#persistNextjsAnonymousId`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader`; `nextjs-sdk#edge.ts#persistEdgeAnonymousId` | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by response-persistence helpers; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#persistNextjsAnonymousId`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader`; `nextjs-sdk#edge.ts#persistEdgeAnonymousId` | | app consent cookie | reader | Reader names/writes/reads; SDK only calls `consent.server` | `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentResolver`; `nextjs-sdk#app-router-request-runtime.tsx#resolveServerConsent`; `nextjs-sdk#edge.ts#resolveServerConsent` | | `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | ## Events & tracking -- App Router request handoff helpers call the request-bound SDK's `page()` method. A browser handoff - carries explicit `initialPageEvent`; direct request helpers set it from `pageResult.accepted`, - forwarded request handoffs set it from boolean `pageAccepted`, and selection helpers require the - caller to provide it. On the default request-root path, the nested request tracker receives this - value from its shared handoff, so page-event ownership does not depend on which request wrapper - starts first. - source: `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#app-router-request-handoff.ts#readNextjsForwardedServerData`; `nextjs-sdk#handoff.ts#createHandoffFromSelections` +- App Router request handoff helpers use the request-bound `prepareRequestHandoff()` operation. + The browser admits the prepared replay for the matching route; without one it attempts the + ordinary page. The default request-root path shares the same handoff among its nested wrappers, + so wrapper start order does not create another first-page owner. + source: `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter` +- A soft navigation gets server-preview selections and matching replay only when the newly rendered + request boundary supplies its new handoff to the browser root or tracker. A retained client root + without a new handoff tracks the changed route as an ordinary browser page. Request URL + normalization removes Next.js's transport-only `_rsc` search parameter before preparing the + replay route key; browser route matching uses pathname plus the visible search string. + source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#request-context.ts#normalizeNextjsAppRequestUrl`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter`; `react-web-sdk#router/next-app.tsx#useNextAppAutoPageInputs` - Request and Edge handoff helpers derive Core page context from the request or forwarded URL before supplied page context and page-event payload layers override it. Browser route-to-page URL behavior comes from the React Web App Router inputs recorded in [`react-web.md`](./react-web.md). Both URL sources feed the shared [`campaign-attribution`](../shared/concepts.md#campaign-attribution) behavior. source: nextjs-sdk#server.tsx#createNextjsRequestContext; nextjs-sdk#edge.ts#createEdgeRequestContext; core-sdk#page-context.ts#createPageContextFromUrl; kb:web/react-web.md; kb:shared/concepts.md -- `NextAppAutoPageTracker` must stay inside `Suspense` (reads `useSearchParams`). - Duplicate-page-event control: `initialPageEvent="skip"` when the server already reported the view, - `"emit"` for browser-owned routes. - source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#InitialAutoPageEvent` +- `NextAppAutoPageTracker` must stay inside `Suspense` because it reads `useSearchParams`. It + delegates matching replay commitment or ordinary route tracking to the shared emitter. + source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter` - The App Router client binder forwards `beforeInitialPage` only to its direct and request-family content roots; its bound provider and analytics root projections omit it. The request-family root captures the callback in the client, derives the current route and lazy payload through the @@ -268,25 +264,15 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` in either export. If the file/export pair does not match the framework version, Next.js does not invoke it, so SDK request-context forwarding and public cache rewrites do not run. source: `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; `nextjs-sdk#cache-middleware.ts#createNextjsPublicPermutationCacheMiddleware`; `extern:Next.js proxy and middleware file conventions` -- Middleware/proxy helpers are chainable with an existing `NextResponse`, but existing rewrites - differ by helper. Public permutation cache middleware treats an existing rewrite, redirect, or - plain non-pass-through response as terminal and returns it unchanged. The request-context handler - treats redirects and plain non-pass-through responses as terminal; existing rewrite responses keep - their rewrite target while the handler still applies sanitized SDK request context and eligible - anonymous-id cookie persistence. For pass-through responses, helpers preserve Next's forwarded - request headers encoded in - `x-middleware-override-headers` and `x-middleware-request-*`, clear only SDK-owned `x-ctfl-opt-*` - request context, then write the current Optimization request URL back into the forwarded request - header set. This removes direct client-supplied `x-ctfl-opt-server-data` before a trusted handler - writes its own forwarded context. When configured with `sdk` and `consent`, the request-context - handler also resolves consent, calls `page()`, serializes compact - `{ consent, pageAccepted, profileId }` context with - `encodeURIComponent(JSON.stringify(value))` into the forwarded `x-ctfl-opt-server-data` request - header without serializing profile traits, changes, or selected optimizations, and persists the - SDK-owned anonymous ID cookie on the response when persistence permits it. `pageAccepted` is copied - from `pageResult.accepted`; `profileId` comes from response data or the request-bound profile. - Without options, it only forwards sanitized request context. - source: `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; `nextjs-sdk#request-handler.ts#hasExistingTerminalMiddlewareTarget`; `nextjs-sdk#request-handler.ts#sanitizeForwardedRequestHeaders`; `nextjs-sdk#request-handler.ts#getRequestOptimizationData`; `nextjs-sdk#request-context.ts#serializeNextjsOptimizationRequestContext`; `nextjs-sdk#server.tsx#getNextjsServerOptimizationData`; `nextjs-sdk#server.tsx#persistNextjsAnonymousId`; `nextjs-sdk#forwarded-request-headers.ts#createForwardedRequestHeaders`; `nextjs-sdk#forwarded-request-headers.ts#applyForwardedRequestHeaders`; `nextjs-sdk#cache-middleware.ts#createNextjsPublicPermutationCacheMiddleware`; `nextjs-sdk#cache-middleware.ts#hasExistingTerminalMiddlewareTarget` +- Middleware/proxy helpers can compose with an existing `NextResponse`. Public permutation cache + middleware treats an existing rewrite, redirect, or plain terminal response as final. The + request-context handler leaves redirects and plain terminal responses alone; it preserves an + existing rewrite target while forwarding sanitized request context and eligible cookie + persistence. It keeps Next's forwarded request headers, clears client-supplied SDK headers, and + writes the current Optimization request URL. Configured `sdk` and `consent` bind an existing + cookie profile ID and consent for later request preparation; the handler makes no Experience API + page mutation. Without options it only forwards sanitized request context. + source: `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; `nextjs-sdk#request-handler.ts#hasExistingTerminalMiddlewareTarget`; `nextjs-sdk#request-handler.ts#bindRequestIdentity`; `nextjs-sdk#request-context.ts#NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER`; `nextjs-sdk#forwarded-request-headers.ts#createForwardedRequestHeaders`; `nextjs-sdk#forwarded-request-headers.ts#applyForwardedRequestHeaders`; `nextjs-sdk#cache-middleware.ts#createNextjsPublicPermutationCacheMiddleware`; `nextjs-sdk#cache-middleware.ts#hasExistingTerminalMiddlewareTarget` - **Public permutations can be static, ISR, or edge-rendered:** routes that call `createPublicPermutationHandoff()` with application-provided selections do not need request profile state; cache safety is represented by the helper-created `public-permutation` cache @@ -321,3 +307,8 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). source: `kb:shared/concepts.md` +- A delayed browser replay request does not hold or erase a successful server preview render. + Preview failure leaves a prepared replay for browser retry but supplies no personalized + selection state, so the server render uses its baseline fallback. Denied page consent produces no + replay or Experience preview. + source: `core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff`; `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime`; `core-sdk#queues/ExperienceQueue.ts#sendPrepared` diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index c4cd6cc39..b2eee7d82 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md @@ -46,8 +46,9 @@ source: `nextjs-sdk#../package.json`; `nextjs-sdk#pages-router.ts#bindNextjsPage - **Server:** `bindNextjsPagesRouterServerOptimization(config)` → `{ createRequestHandoff }`. Server consent is supplied through `consent.server`, which receives `{ cookies, headers }`. source: `nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization`; `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterOptimization`; `nextjs-sdk#bound-component-types.ts#NextjsOptimizationServerConsentResolver`. - - Optional `cookie?` (`domain`, `expires` in days → maxAge seconds). - source: `nextjs-sdk#bound-component-types.ts#NextjsOptimizationCookieConfig`; `nextjs-sdk#pages-router-server.ts#toAnonymousIdCookieOptions`. + - Cookie policy flows through the shared Next.js cookie resolver; the emitted anonymous ID cookie + is browser readable for Web SDK continuity. + source: `nextjs-sdk#bound-component-types.ts#NextjsOptimizationCookieConfig`; `nextjs-sdk#cookies.ts#toNextjsAnonymousIdCookieOptions`; `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`. - **`contentful?: ContentfulConfig` (managed fetching):** via `OptimizationNodeConfig` → core `contentful` config; enables server-side ID or content-type/slug fetch through the request optimization instance and the `prefetchManagedEntries` option below. @@ -92,7 +93,7 @@ source: `nextjs-sdk#../package.json`; `nextjs-sdk#pages-router.ts#bindNextjsPage | `OptimizationProvider` | component | `/pages-router` | `children`; `handoff?`; `hydration?`; `prefetchManagedEntries?`; internally wraps `LiveUpdatesProvider` (`globalLiveUpdates`) | `ReactElement` / `null` | `nextjs-sdk#pages-router.ts#OptimizationProvider`; `nextjs-sdk#bound-component-types.ts#BoundNextjsOptimizationProviderProps` | | `OptimizationAnalyticsRoot` | component | `/pages-router` | analytics handoff, route key, page payload builder, children | `ReactElement` | `nextjs-sdk#pages-router.ts#OptimizationAnalyticsRoot`; `nextjs-sdk#bound-component-types.ts#BoundNextjsOptimizationAnalyticsRootProps` | | `OptimizedEntry` | component | `/pages-router` (binding return) | React Web component with `baselineEntry`, flat `entryId` + `entryQuery`, or object descriptor under `managedEntry` | `ReactElement` / `null` | `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry` | -| `NextPagesAutoPageTracker` | component | `/pages-router` | `initialPageEvent?: 'emit' / 'skip'`, `getPagePayload?` | `null` | `nextjs-sdk#pages-router.ts#NextPagesAutoPageTracker`; `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker` | +| `NextPagesAutoPageTracker` | component | `/pages-router` | Routes a matching handoff replay or ordinary page | `null` | `nextjs-sdk#pages-router.ts#NextPagesAutoPageTracker`; `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker` | | `useConsentState` | hook | `/client` | — | consent state | `react-web-sdk#hooks/useOptimizationState.ts#useConsentState` | | `useProfileState` | hook | `/client` | — | profile (`traits`) | `react-web-sdk#hooks/useOptimizationState.ts#useProfileState` | | `useOptimizationActions` | hook | `/client` | — | `{ setConsent, identifyUser, resetUser }` | `react-web-sdk#hooks/useOptimizationActions.ts#useOptimizationActions` | @@ -140,7 +141,7 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en `useSearchParams`) ⇒ **no `Suspense` boundary needed** (App Router's tracker does need it). source: `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker`. - Server request handoff prefers `resolvedUrl`, makes it absolute from forwarded host/protocol when - available, and parses it into Core page URL/query/search context before calling `page()`. In the + available, and parses it into Core page URL/query/search context before preparing the page replay. In the browser, route-to-page URL behavior comes from the React Web Pages Router tracker recorded in [`react-web.md`](./react-web.md). Both URL sources feed the shared [`campaign-attribution`](../shared/concepts.md#campaign-attribution) behavior. @@ -157,11 +158,12 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en `({ context: { pathname } }) => ...`. Arbitrary `properties` keys are allowed (`Page` is `z.catchall(z.json())`). source: `react-web-sdk#auto-page/types.ts#AutoPageEmissionContext`; `react-web-sdk#router/next-pages.tsx#NextPagesAutoPageContext`; `react-web-sdk#auto-page/pagePayload.ts#buildAutoPagePayload`; `api-client#schemas/experience/event/properties/Page.ts#Page`; `core-sdk#events/EventBuilder.ts#PageViewBuilderArgs`. -- Bound `createRequestHandoff(context, options)` forwards `options.pagePayload` through the Pages - Router request helper to the request-bound `page()` call, so it shapes the first server page event. - The returned browser handoff carries explicit `initialPageEvent`; it is `'skip'` exactly when that - page call's `pageResult.accepted` is true. - source: nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization; nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff; nextjs-sdk#server.tsx#getNextjsServerOptimizationData; nextjs-sdk#server.tsx#createNextjsRequestHandoff; core-sdk#CoreStatelessRequest.ts#page +- Bound `createRequestHandoff(context, options)` forwards `pagePayload` into the request-bound + prepared page event. The server preview supplies selected state for SSR without committing the + events; the matching browser route later admits the prepared batch. Preview failure retains a + replay without selected state. The helper appends a browser-readable anonymous ID cookie when + request persistence consent permits it and an API-issued or existing profile ID is available. + source: nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization; nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff; nextjs-sdk#server.tsx#createNextjsRequestHandoff; core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff; nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader - Interaction tracking (views/clicks/hovers): on by default with `OptimizedEntry`; opt out per-type via binding config `trackEntryInteraction`; uses resolved entry id. source: `impl:nextjs-sdk_pages-router#lib/optimization.ts`. @@ -216,11 +218,11 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en ## Failure & fallback behavior - Baseline fallback: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). -- **Experience API failure inside `getServerSideProps` REJECTS the request ⇒ 500** (no internal - try/catch to baseline): `page()` → `sendAllowedExperienceEvent` awaits `upsertProfile` with no - catch. Reader should wrap request handoff creation in try/catch and render baseline on - failure. Denied consent short-circuits to `{ accepted: false }` with no API call. - source: `core-sdk#CoreStatelessRequest.ts#page`; `core-sdk#CoreStatelessRequest.ts#sendAllowedExperienceEvent`. +- Experience preview failure inside `getServerSideProps` leaves a prepared replay and no selected + state, so server rendering falls back to baseline while the browser can still attempt commitment. + Denied page consent produces no Experience preview or replay. A delayed browser delivery does not + delay a successful server preview render. + source: `core-sdk#CoreStatelessRequest.ts#prepareRequestHandoff`; `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage`. - All-locale payloads (`withAllLocales` / `locale=*`) ⇒ baseline. Model: see [`../shared/concepts.md`](../shared/concepts.md#entry-resolution). source: `kb:shared/concepts.md`. diff --git a/documentation/internal/sdk-knowledge/web/react-web.md b/documentation/internal/sdk-knowledge/web/react-web.md index a3fb60bc7..5647e6f13 100644 --- a/documentation/internal/sdk-knowledge/web/react-web.md +++ b/documentation/internal/sdk-knowledge/web/react-web.md @@ -22,7 +22,7 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web | `@contentful/optimization-react-web/router/next-app` | Next.js App Router auto page tracker (React-Web-only setups) | react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker | | `@contentful/optimization-react-web/api-schemas` | Type guards `isMergeTagEntry`, `isRichTextDocument` | react-web-sdk#api-schemas.ts; core-sdk#contentful/typeGuards.ts#isMergeTagEntry; core-sdk#contentful/typeGuards.ts#isRichTextDocument | | `@contentful/optimization-core/entry-source` | Framework-adapter primitives for managed fetch: `prefetchManagedEntries`, `createOptimizedEntryLoadingEntry`, `getOptimizedEntrySourceKey`, `OptimizedEntrySourceController` + handoff types | core-sdk#entry-source.ts; core-sdk#OptimizedEntrySourceController.ts#prefetchManagedEntries; core-sdk#OptimizedEntrySourceController.ts#createOptimizedEntryLoadingEntry; core-sdk#managed-entry-key.ts#getOptimizedEntrySourceKey; core-sdk#OptimizedEntrySourceController.ts#OptimizedEntrySourceController; core-sdk#CoreBase.ts#ManagedEntryHandoff | -| `@contentful/optimization-react-web/handoff` | Pass-through Web handoff types and content hydration helper | react-web-sdk#handoff.ts; web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff.ts#hydrateOptimizationHandoff | +| `@contentful/optimization-react-web/handoff` | Pass-through Web handoff types and content hydration helper | react-web-sdk#handoff.ts; web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff-internal.ts#hydrateOptimizationHandoff | | `@contentful/optimization-react-web/analytics` | Pass-through analytics-only Web runtime helpers | react-web-sdk#analytics.ts; web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff | | `@contentful/optimization-react-web/tracking-attributes` | Pass-through server/static tracking-attribute resolver | react-web-sdk#tracking-attributes.ts; web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes | | `@contentful/optimization-react-web/logger` | `createScopedLogger` | react-web-sdk#logger.ts; api-client#lib/logger/Logger.ts#createScopedLogger | @@ -80,12 +80,10 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web `ContentfulOptimization is already initialized`. source: web-sdk#ContentfulOptimization.ts#ContentfulOptimization - Analytics-only root: `OptimizationAnalyticsRoot` initializes a narrow analytics runtime after - commit, hydrates an analytics-only handoff in a layout effect, emits or skips the initial route - through the handoff's `initialPageEvent`, and renders children without providing content - resolution context. A skipped initial route remains skipped across React StrictMode effect replay; - after the route key changes, later hydrations emit route changes through the analytics runtime. - Unmounts and newer hydrations cancel in-flight analytics hydration before it can apply state, warn, - or track the page. + commit, hydrates an analytics-only handoff in a layout effect, and admits matching replay or an + ordinary page for its route. It renders children without providing content resolution context. + Repeated initialization shares current replay admission; a later route can emit through the same + analytics runtime. Unmounts and newer hydrations cancel stale state apply and page tracking. source: react-web-sdk#root/OptimizationAnalyticsRoot.tsx#OptimizationAnalyticsRoot; react-web-sdk#root/OptimizationAnalyticsRoot.tsx#initializeAnalyticsRuntime; web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff - `onStatesReady` receives live SDK states during provider setup after the owned/injected SDK exists and, when a handoff is present, after live SDK hydration. With `onStatesReady` supplied, the @@ -104,8 +102,8 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web | `OptimizationProvider` | provider | root | `sdk={optimization}` (injected) OR config; `onStatesReady?`, `handoff?`, `hydration?`, `prefetchManagedEntries?` | always renders children | react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider; react-web-sdk#provider/OptimizationProvider.tsx#OptimizationHandoffProps | | `LiveUpdatesProvider` | provider | root | required for `OptimizedEntry`/`useOptimizedEntry`/`useLiveUpdates` when composing providers by hand | element | react-web-sdk#provider/LiveUpdatesProvider.tsx#LiveUpdatesProvider; react-web-sdk#hooks/useLiveUpdates.ts#useLiveUpdates | | `OptimizedEntry` | component | root | Manual `baselineEntry`, flat `entryId` + `entryQuery`, or object descriptor under `managedEntry` | element or `null` | react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry; react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntrySourceProps | -| `ReactRouterAutoPageTracker` | component | `/router/react-router` | `getPagePayload?`, `pagePayload?` (no `initialPageEvent`) | `null` | react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker | -| next-pages / next-app tracker | component | `/router/next-pages` or next-app | also accept `initialPageEvent` | `null` | react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker | +| `ReactRouterAutoPageTracker` | component | `/router/react-router` | Router page tracking | `null` | react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker | +| next-pages / next-app tracker | component | `/router/next-pages` or next-app | Can consume a matching request handoff | `null` | react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker | | `useOptimizationContext` | hook | root | — | `{ sdk, error }` (`sdk` seeded, defined from 1st render; `error` on init fail) | react-web-sdk#hooks/useOptimization.ts#useOptimizationContext; react-web-sdk#context/OptimizationContext.tsx#OptimizationContextValue | | `useOptimization` | hook | root | — | SDK instance; **throws** if unavailable / no provider | react-web-sdk#hooks/useOptimization.ts#useOptimization | | `useOptimizedEntry` | hook | root | Same `baselineEntry`, flat ID, or `managedEntry` object-descriptor source model | `{ canOptimize, baselineEntry, entry, error, isLoading, isPresentationReady, isResolved, metadata, resolvedData, selectedOptimization, selectedOptimizations }` (`error` is `Error \| undefined`; `entry`/`baselineEntry`/`metadata`/`selectedOptimization(s)` `undefined` while managed fetch unresolved) | react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntry; react-web-sdk#optimized-entry/useOptimizedEntry.ts#UseOptimizedEntryResult | @@ -181,10 +179,10 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn ## Events & tracking -- Page events: auto-page trackers emit on navigation; each dedupes consecutive route keys incl. - Strict Mode double effects. Mount ONE tracker per router tree. React Router / TanStack trackers do - NOT take `initialPageEvent`; only next-pages / next-app trackers do (React-Web-only Next setups). - source: react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker; react-web-sdk#router/tanstack-router.tsx#TanStackRouterAutoPageTracker; react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker; web-sdk#ContentfulOptimization.ts#trackCurrentPage +- Page events: auto-page trackers emit on navigation; each dedupes consecutive accepted route keys + including Strict Mode effect replay. Mount one tracker per router tree. Trackers with a handoff + delegate initialization to the Web SDK and use ordinary page tracking after admission. + source: react-web-sdk#router/react-router.tsx#ReactRouterAutoPageTracker; react-web-sdk#router/tanstack-router.tsx#TanStackRouterAutoPageTracker; react-web-sdk#router/next-pages.tsx#NextPagesAutoPageTracker; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter; web-sdk#ContentfulOptimization.ts#trackCurrentPage - A config-owned provider creates the plain Web runtime, so manual page calls inherit its current browser page provider. Built-in React Router, TanStack Router, Next.js Pages Router, and Next.js App Router trackers additionally seed page-event properties with their route-derived absolute URL, @@ -199,28 +197,31 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn the hooks and browser location agree. `NextAppAutoPageTracker` consumes those inputs and passes them to the shared auto-page emitter for its initial and later page behavior. source: react-web-sdk#router/next-app.tsx#useNextAppAutoPageInputs; react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker -- Initial page event is auto-emitted on mount, not only on navigation: the shared emitter - `useAutoPageEmitter` defaults `initialPageEvent` to `'emit'` and calls `sdk.trackCurrentPage()` in a - mount effect, so the first route emits its `page` event as soon as the tracker mounts. Trackers that - do not expose `initialPageEvent` (React Router / TanStack) always emit the initial page; only the - next-pages / next-app trackers can pass `'skip'` to suppress it. With the `['identify','page']` - pre-consent allow-list this initial `page` is admitted before any explicit consent call. - source: react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter; kb:shared/concepts.md -- Without `beforeInitialPage`, `OptimizationRoot` emits a handoff-owned initial page event only when - it has a route key and either `buildPagePayload` or `initialPagePayload`. For - `initialPageEvent: 'skip'`, it can mark the initial route accepted with the initial route key even - without a payload builder. If `initialPageEvent: 'emit'` lacks a route key or page payload source, - it warns and skips browser emission. - source: react-web-sdk#root/OptimizationRoot.tsx#resolveInitialPageEmitterProps; react-web-sdk#root/OptimizationRoot.tsx#shouldWarnMissingInitialPagePayload; react-web-sdk#root/OptimizationRoot.tsx#MissingInitialPagePayloadWarning; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter +- The shared emitter attempts the initial route after the live SDK is ready. Without a handoff it + uses ordinary current-page tracking when a payload builder exists. With a handoff it hydrates and + admits a matching replay, even without a payload builder; an absent or mismatched replay uses the + builder when available and otherwise builds an empty page payload. Each mounted emitter remembers + its accepted handoff by the prepared page's message ID, or by object identity for state-only + handoffs. Later visits with that handoff use ordinary page tracking; rejected admission remains + retryable, and a distinct preparation can initialize. The Web SDK owns consent admission and route + deduplication. + source: react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#ContentfulOptimization.ts#trackCurrentPage +- A plain `OptimizationRoot` mounts its initial page emitter only when a handoff is present. Supplying + a matching replay or an explicit root payload enables that emitter. Without either, the child + tracker owns fallback page construction, including its custom payload properties. Supplying + `routeKey` and `buildPagePayload` without a handoff does not emit a page by itself; a mounted + auto-page tracker owns ordinary initial and later route events. A root configured with + `beforeInitialPage` follows its separate callback and direct-page sequence instead. + source: react-web-sdk#root/OptimizationRoot.tsx#DefaultOptimizationRoot; react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter - A root with `beforeInitialPage` invokes the callback only after its owned runtime is live, once per retained root lifetime, with receiver-safe bound `identify`, `screen`, and `track` delegates. It awaits only returned work or the watchdog, then reads the latest route and payload builder for one - direct page attempt. A successfully applied same-route handoff can skip that attempt; a failed - handoff or changed route emits. After the attempt settles, including rejection or + direct page attempt. A root with a prepared replay uses the normal handoff path instead of this + callback sequence. After the direct attempt settles, including rejection or `{ accepted: false }`, readiness activates the existing emitter with one non-emitting `skip` mark for the attempted route. This root owns the page sequence for its subtree; a separate auto-page tracker is a second page owner. - source: react-web-sdk#before-initial-page/beforeInitialPage.ts#createBeforeInitialPageClient; react-web-sdk#before-initial-page/beforeInitialPage.ts#runBeforeInitialPage; react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter + source: react-web-sdk#before-initial-page/beforeInitialPage.ts#createBeforeInitialPageClient; react-web-sdk#before-initial-page/beforeInitialPage.ts#runBeforeInitialPage; react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence; react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter - A route change after the direct page attempt starts neither cancels that attempt nor starts a competing page attempt. The root settles and marks the captured attempted route before enabling later page emission; a route observed only during the in-flight attempt is not emitted, while a @@ -284,7 +285,7 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn path through the read-only snapshot, then hydrates the live SDK with `hydrateOptimizationHandoff`. Provider always renders children (never withheld/unmounted). The provider does NOT `destroy()` an instance it did not create (`ownsInstance:false`). - source: react-web-sdk#provider/OptimizationProvider.tsx#injectedSdkBacksInitialRender; react-web-sdk#provider/OptimizationProvider.tsx#initializeServerOptimizationState; web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#presentation/optimizationRootRuntime.ts#OptimizationRootSdkBinding; web-sdk#presentation/optimizationRootRuntime.ts#disposeOptimizationRootSdkBinding + source: react-web-sdk#provider/OptimizationProvider.tsx#injectedSdkBacksInitialRender; react-web-sdk#provider/OptimizationProvider.tsx#initializeServerOptimizationState; web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; web-sdk#presentation/optimizationRootRuntime.ts#OptimizationRootSdkBinding; web-sdk#presentation/optimizationRootRuntime.ts#disposeOptimizationRootSdkBinding - Owned/config `OptimizationRoot` path always seeds a snapshot runtime initially. source: react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime; web-sdk#runtime.ts#createWebSnapshotRuntime - Handoff-backed initial render validates cache safety before the snapshot runtime consumes @@ -331,10 +332,8 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn does not cancel callback or page work it already started. source: react-web-sdk#before-initial-page/beforeInitialPage.ts#runBeforeInitialPage; react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence - If owned SDK construction succeeds but initial handoff hydration fails, the provider retains the - live runtime and publishes the error. A root with `beforeInitialPage` treats that handoff as - unapplied and uses an emitting direct page attempt. This retention applies only to the - owned-runtime path. - source: react-web-sdk#provider/OptimizationProvider.tsx#initializeServerOptimizationState; react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider; react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence + live runtime and publishes the error. This retention applies only to the owned-runtime path. + source: react-web-sdk#provider/OptimizationProvider.tsx#initializeServerOptimizationState; react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider - On SDK **initialization failure**, `useOptimizationContext().error` is set; `OptimizedEntry` throws rather than rendering baseline, so it must render under an ancestor that handles `error` (an unguarded subtree crashes). diff --git a/documentation/internal/sdk-knowledge/web/web.md b/documentation/internal/sdk-knowledge/web/web.md index fb7706f43..5d90b4368 100644 --- a/documentation/internal/sdk-knowledge/web/web.md +++ b/documentation/internal/sdk-knowledge/web/web.md @@ -13,17 +13,17 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: ## Package & entry points -| Import path | Purpose | source | -| -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-web` (default export) | `ContentfulOptimization` class | web-sdk#index.ts; web-sdk#ContentfulOptimization.ts#ContentfulOptimization | -| `@contentful/optimization-web/web-components` | `defineContentfulOptimizationElements()` + element/detail types | web-sdk#web-components/index.ts#defineContentfulOptimizationElements | -| `@contentful/optimization-web/api-schemas` | Type guards incl. `isMergeTagEntry` | web-sdk#api-schemas.ts; core-sdk#contentful/typeGuards.ts#isMergeTagEntry | -| `@contentful/optimization-web/constants` | `ANONYMOUS_ID_COOKIE`, `DEFAULT_WEB_ALLOWED_EVENT_TYPES`, etc. | web-sdk#constants.ts#DEFAULT_WEB_ALLOWED_EVENT_TYPES; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | -| `@contentful/optimization-web/logger` | logger utilities | web-sdk#logger.ts | -| `@contentful/optimization-web/handoff` | Browser handoff types, hydration target, and content/state hydration helpers | web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff.ts#OptimizationHandoffHydrationTarget; web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#handoff.ts#hydrateOptimizationHandoffState | -| `@contentful/optimization-web/analytics` | Analytics-only runtime and analytics handoff hydration helper | web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff | -| `@contentful/optimization-web/presentation` | Low-level root/entry presentation primitives used by framework wrappers | web-sdk#presentation/index.ts; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController | -| `@contentful/optimization-web/tracking-attributes` | Presentation tracking-attribute resolver | web-sdk#tracking-attributes.ts; web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes | +| Import path | Purpose | source | +| -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-web` (default export) | `ContentfulOptimization` class | web-sdk#index.ts; web-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| `@contentful/optimization-web/web-components` | `defineContentfulOptimizationElements()` + element/detail types | web-sdk#web-components/index.ts#defineContentfulOptimizationElements | +| `@contentful/optimization-web/api-schemas` | Type guards incl. `isMergeTagEntry` | web-sdk#api-schemas.ts; core-sdk#contentful/typeGuards.ts#isMergeTagEntry | +| `@contentful/optimization-web/constants` | `ANONYMOUS_ID_COOKIE`, `DEFAULT_WEB_ALLOWED_EVENT_TYPES`, etc. | web-sdk#constants.ts#DEFAULT_WEB_ALLOWED_EVENT_TYPES; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE | +| `@contentful/optimization-web/logger` | logger utilities | web-sdk#logger.ts | +| `@contentful/optimization-web/handoff` | Browser handoff types, hydration target, and content/state hydration helpers | web-sdk#handoff.ts#BrowserOptimizationHandoff; web-sdk#handoff.ts#OptimizationHandoffHydrationTarget; web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; web-sdk#handoff-internal.ts#hydrateOptimizationHandoffState | +| `@contentful/optimization-web/analytics` | Analytics-only runtime and analytics handoff hydration helper | web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff | +| `@contentful/optimization-web/presentation` | Low-level root/entry presentation primitives used by framework wrappers | web-sdk#presentation/index.ts; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController | +| `@contentful/optimization-web/tracking-attributes` | Presentation tracking-attribute resolver | web-sdk#tracking-attributes.ts; web-sdk#presentation/OptimizedEntryTrackingAttributes.ts#resolveOptimizedEntryTrackingAttributes | ## Setup / initialization and binding @@ -56,10 +56,16 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: [`../shared/concepts.md`](../shared/concepts.md#entry-source-boundary-managed-or-manual). source: core-sdk#CoreBase.ts#CoreConfig; core-sdk#CoreBase.ts#ContentfulConfig; web-sdk#ContentfulOptimization.ts#OptimizationWebConfig; core-sdk#CoreStateful.ts#CoreStatefulConfig - Browser handoff model: see [`../shared/concepts.md`](../shared/concepts.md#optimization-handoff). - `hydrateOptimizationHandoff(sdk, handoff)` accepts only content handoffs, validates - `initialPageEvent`, enforces cache safety, hydrates state into the live SDK through Web handoff - state hydration, and leaves page-event emission to the root or route tracker that consumes - `initialPageEvent`. `@contentful/optimization-web/handoff` also exports + `hydrateOptimizationHandoff(sdk, handoff)` enforces cache safety and hydrates state into the live + SDK. At the start of a current initialization, `hydrateAndTrackCurrentPage()` adopts an existing + profile ID and retains it if pending hydration or replay becomes stale. It then admits a matching prepared replay as one retained queue + batch. An absent, mismatched, or rejected replay attempts ordinary current-page tracking with the + caller's payload builder or its default, subject to current route, lifecycle, deduplication, + consent, and queue guards. An accepted replay result settles on queue admission, before HTTP + delivery and live state update. A rejected replay returns the ordinary current-page tracking + result, which can await online HTTP delivery. An admitted replay's delivery failure does not + trigger ordinary page tracking. + `@contentful/optimization-web/handoff` also exports `hydrateOptimizationHandoffState` for customer adapters. Undefined or empty handoff state still marks the Experience request state successful and clears stale browser content state by publishing `selectedOptimizations: undefined` and `changes: undefined` while leaving `profile` untouched. When @@ -67,13 +73,13 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: as intentional, keeps input handoff fields when an interceptor omits them, applies own present `undefined` fields, and publishes only those present fields plus the content reset in one browser SDK batch. - source: web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#handoff.ts#hydrateOptimizationHandoffState; web-sdk#handoff.ts#applyHydratedSignals; web-sdk#handoff.ts#applySuccessfulEmptyHandoffHydration; core-sdk#handoff.ts#assertOptimizationCacheSafety + source: web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#handoff-internal.ts#hydrateOptimizationHandoffState; web-sdk#handoff-internal.ts#applyHydratedSignals; web-sdk#handoff-internal.ts#applySuccessfulEmptyHandoffHydration; core-sdk#handoff.ts#assertOptimizationCacheSafety - `hydrateOptimizationHandoff()` treats profileless `static` and `public-permutation` handoffs as live-memory hydration: it publishes handoff `changes` / `selectedOptimizations` to browser signals while suppressing durable continuity persistence, so existing durable `LocalStore` continuity is preserved. Private request handoffs, or profile-backed handoffs that pass cache safety, follow normal signal persistence and can update durable continuity when persistence consent allows. - source: web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#handoff.ts#shouldPreserveDurableContinuity; web-sdk#handoff.ts#applyHydratedSignals; web-sdk#storage/durableContinuityPersistence.ts#suppressDurableContinuityPersistence; web-sdk#storage/LocalStore.ts#LocalStore + source: web-sdk#handoff-internal.ts#hydrateOptimizationHandoff; web-sdk#handoff-internal.ts#shouldPreserveDurableContinuity; web-sdk#handoff-internal.ts#applyHydratedSignals; web-sdk#storage/durableContinuityPersistence.ts#suppressDurableContinuityPersistence; web-sdk#storage/LocalStore.ts#LocalStore ## Components & hooks @@ -163,12 +169,14 @@ None (imperative class + Web Components; no React surface). Web Components eleme resolved browser page URL for the URL campaign inference recorded in [`../shared/concepts.md`](../shared/concepts.md#campaign-attribution). source: web-sdk#ContentfulOptimization.ts#mergeConfig; web-sdk#builders/EventBuilder.ts#getPageProperties; core-sdk#events/EventBuilder.ts#buildUniversalEventProperties; core-sdk#events/EventBuilder.ts#buildPageView; kb:shared/concepts.md -- `page()` (accepted) populates `states.selectedOptimizations`. - source: core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals -- `trackCurrentPage({ routeKey, buildPayload, initialPageEvent? })` — dedupes consecutive identical - route keys; `initialPageEvent: 'skip'` for hybrid first-route dedupe; a bare `page()` always emits - when consent permits. - source: web-sdk#ContentfulOptimization.ts#trackCurrentPage; core-sdk#tracking/AcceptedCurrentStateTracker.ts#emitIfNeeded +- A successful `page()` populates `states.selectedOptimizations` when its lifecycle and captured + profile revision still permit publication; see the shared + [Experience response contract](../shared/concepts.md#experience-response-payload). + source: core-sdk#queues/ExperienceQueue.ts#upsertProfile; core-sdk#state/applyOptimizationDataToSignals.ts#applyOptimizationDataToSignals; kb:shared/concepts.md +- `trackCurrentPage()` dedupes consecutive accepted route keys; a bare `page()` always emits when + consent permits. A matching prepared replay marks its route accepted at queue admission, avoiding + a second browser page for that route. + source: web-sdk#ContentfulOptimization.ts#trackCurrentPage; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; core-sdk#tracking/AcceptedCurrentStateTracker.ts#emitIfNeeded - Interaction tracking: SDK observes any DOM element carrying `data-ctfl-*`; auto view/click/hover on by default; opt out per-type via `autoTrackEntryInteraction`. Manual: `tracking.enableElement('views', el, { data })` / `disableElement` / `clearElement` (manual data @@ -189,15 +197,14 @@ None (imperative class + Web Components; no React surface). Web Components eleme flush uses Beacon, so final interaction events are queued first. source: web-sdk#entry-tracking/events/observerSupport.ts#addVisibilityChangeListener; web-sdk#entry-tracking/events/view/ElementViewObserver.ts#ElementViewObserver; web-sdk#entry-tracking/events/hover/ElementHoverObserver.ts#ElementHoverObserver; web-sdk#entry-tracking/EntryInteractionRuntime.ts#EntryInteractionRuntime; web-sdk#ContentfulOptimization.ts#ContentfulOptimization; web-sdk#handlers/createVisibilityChangeListener.ts#createVisibilityChangeListener - Analytics-only handoff: `initializeOptimizationAnalyticsRuntime(config)` creates a narrow Web - runtime with `tracking`, `trackCurrentPage`, `flush`, and `destroy`, but no content-resolution - surface. It removes the global browser SDK reference if construction registered this analytics - runtime's own internal SDK instance. Hydration through `hydrateOptimizationAnalyticsHandoff` - accepts only `hydration: 'analytics-only'`, hydrates handoff state, warns when a skipped initial - page lacks profile continuity, then delegates initial route ownership to `trackCurrentPage()`. - Stale analytics hydrations stop before state apply, the warning, or page tracking; profileless + runtime with tracking and page admission but no content-resolution surface. It removes the global + browser SDK reference if construction registered this analytics runtime's own internal SDK + instance. `hydrateOptimizationAnalyticsHandoff()` accepts only `hydration: 'analytics-only'`, + hydrates state, then admits the matching replay or ordinary current page through the shared Web + operation. Stale analytics hydrations stop before state apply or page tracking; profileless `static` and `public-permutation` analytics handoffs suppress durable continuity persistence the same way content handoffs do. - source: web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#analytics.ts#warnSkippedInitialPageWithoutProfileContinuity; web-sdk#handoff.ts#shouldPreserveDurableContinuity + source: web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#handoff-internal.ts#shouldPreserveDurableContinuity - Flags: `getFlag(name)` one-off and `states.flag(name)` reactive reads auto-attempt flag-view tracking; explicit/manual replacement is `trackFlagView()`. See [`../shared/concepts.md`](../shared/concepts.md#custom-flag-views). diff --git a/implementations/ios-sdk/uitests/Support/TestHelpers.swift b/implementations/ios-sdk/uitests/Support/TestHelpers.swift index c360cb8fe..b131672c3 100644 --- a/implementations/ios-sdk/uitests/Support/TestHelpers.swift +++ b/implementations/ios-sdk/uitests/Support/TestHelpers.swift @@ -103,21 +103,6 @@ func waitForComponentEventCount( } } -func getViewDuration(_ componentId: String, app: XCUIApplication) -> Int? { - let text = getElementTextById("event-duration-\(componentId)", app: app) - let pattern = #/Duration:\s*(\d+)/# - guard let match = text.firstMatch(of: pattern) else { return nil } - return Int(match.1) -} - -func getViewId(_ componentId: String, app: XCUIApplication) -> String? { - let text = getElementTextById("event-view-id-\(componentId)", app: app) - let pattern = #/ViewId:\s*(.+)/# - guard let match = text.firstMatch(of: pattern) else { return nil } - let id = String(match.1).trimmingCharacters(in: .whitespaces) - return id == "N/A" ? nil : id -} - /// Scrolls a scroll view until the target element is visible. func scrollToElement(testId: String, scrollViewId: String, app: XCUIApplication, maxSwipes: Int = 10) { let scrollView = app.scrollViews[scrollViewId] diff --git a/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift b/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift index a152a88bd..1a1607f33 100644 --- a/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift +++ b/implementations/ios-sdk/uitests/Tests/ExtendedViewTrackingTests.swift @@ -17,7 +17,7 @@ final class ExtendedViewTrackingTests: XCTestCase { clearProfileState(app: app, requireFreshAppInstance: true) } - func testQualifiedStartAndFinalEventForContinuouslyVisibleEntry() { + func testVisibleEntryProducesViewAndScrollOutUpdate() { waitForElement(app.staticTexts["Analytics Events"]) // Observe the qualified start without scrolling the entry out of view. @@ -25,112 +25,36 @@ final class ExtendedViewTrackingTests: XCTestCase { let startText = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { self.parseComponentCount($0) >= 1 } - XCTAssertEqual(parseComponentCount(startText), 1) + let startCount = parseComponentCount(startText) // Scrolling to the stats ends the active visibility cycle. scrollToElement(testId: eventCountId, scrollViewId: "main-scroll-view", app: app) - let finalText = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { - self.parseComponentCount($0) >= 2 - } - XCTAssertEqual(parseComponentCount(finalText), 2) - } - - func testViewDurationIncreasesAtVisibilityEnd() { - waitForElement(app.staticTexts["Analytics Events"]) - - let eventCountId = "event-count-\(VISIBLE_ENTRY_ID)" _ = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { - self.parseComponentCount($0) >= 1 + self.parseComponentCount($0) >= startCount + 1 } - let startDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - - scrollToElement(testId: eventCountId, scrollViewId: "main-scroll-view", app: app) - _ = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { - self.parseComponentCount($0) >= 2 - } - let finalDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - - XCTAssertNotNil(startDuration) - XCTAssertNotNil(finalDuration) - XCTAssertGreaterThanOrEqual(startDuration!, 1000) - XCTAssertGreaterThan(finalDuration!, startDuration!) } - func testStableViewIdBetweenStartAndFinal() { + func testTracksEntryAgainAfterScrollAwayAndBack() { waitForElement(app.staticTexts["Analytics Events"]) let eventCountId = "event-count-\(VISIBLE_ENTRY_ID)" - _ = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { + let startText = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { self.parseComponentCount($0) >= 1 } - let firstEventViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - - XCTAssertNotNil(firstEventViewId) - XCTAssertGreaterThan(firstEventViewId!.count, 0) - - scrollToElement(testId: eventCountId, scrollViewId: "main-scroll-view", app: app) - _ = waitForElementText(eventCountId, app: app, timeout: EXTENDED_TIMEOUT) { - self.parseComponentCount($0) >= 2 - } - let finalEventViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - - XCTAssertEqual(finalEventViewId, firstEventViewId) - } - - func testFinalEventOnScrollOut() { - waitForElement(app.staticTexts["Analytics Events"]) - - // Wait for at least 1 event from the visible entry - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) - - let preScrollViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - - // Scroll the entry out of the viewport - let scrollView = app.scrollViews["main-scroll-view"] - scrollView.swipeUp(times: 2) - - // Give the final event time to fire - Thread.sleep(forTimeInterval: 1.0) + let startCount = parseComponentCount(startText) - // Scroll back to the top so the stats elements become visible again - scrollView.swipeDown(times: 3) - - // Scroll to the events display to read updated stats - scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", - scrollViewId: "main-scroll-view", app: app) - - // The event count should have incremented by the final event - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: ELEMENT_VISIBILITY_TIMEOUT) - - // The viewId should still match the original cycle - let postScrollViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertEqual(postScrollViewId, preScrollViewId) - } - - func testNewViewIdAfterScrollAwayAndBack() { - waitForElement(app.staticTexts["Analytics Events"]) - - // Cycle 1: wait for the initial event; reading the stats scrolls entry 0 - // off, ending cycle 1 with a final event. - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) - let firstCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(firstCycleViewId) - let countAfterCycle1 = parseComponentCount( - getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) + // Scroll the first exposure out of view and observe its update. + app.scrollViews["main-scroll-view"].swipeUp(times: 2) + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: startCount + 1, + app: app, timeout: EXTENDED_TIMEOUT) + let countAfterScroll = parseComponentCount(getElementTextById(eventCountId, app: app)) - // Scroll entry 0 back into view to start a fresh cycle, and dwell past - // the threshold so the new cycle emits its initial event. + // The visible entry can be observed again after returning. scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) Thread.sleep(forTimeInterval: 2.6) - - // Wait for the new cycle's event and confirm it carries a fresh viewId. - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countAfterCycle1 + 1, + waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countAfterScroll + 1, app: app, timeout: EXTENDED_TIMEOUT) - let secondCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(secondCycleViewId) - XCTAssertNotEqual(secondCycleViewId, firstCycleViewId, - "Second visibility cycle should have a different viewId") } func testNoEventsBeforeDwellThreshold() { @@ -160,24 +84,15 @@ final class ExtendedViewTrackingTests: XCTestCase { XCTAssertFalse(statsElement.waitForExistence(timeout: 2.0)) } - func testIndependentViewIdsForMultipleEntries() { + func testTracksMultipleVisibleEntries() { waitForElement(app.staticTexts["Analytics Events"]) // Wait for at least 1 event from each visible entry waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) waitForComponentEventCount(SECOND_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) - - // Get viewIds for both entries - let viewId1 = getViewId(VISIBLE_ENTRY_ID, app: app) - let viewId2 = getViewId(SECOND_ENTRY_ID, app: app) - - // Both should have non-null, distinct viewIds - XCTAssertNotNil(viewId1) - XCTAssertNotNil(viewId2) - XCTAssertNotEqual(viewId1, viewId2) } - func testFinalEventOnNavigationUnmount() { + func testRecordsViewActivityAcrossNavigation() { waitForElement(app.staticTexts["Analytics Events"]) // Wait for at least 1 tracking event (active cycle with emitted event) @@ -191,13 +106,13 @@ final class ExtendedViewTrackingTests: XCTestCase { let scrollView = app.scrollViews["main-scroll-view"] scrollView.swipeDown(times: 3) - // Navigate away: this unmounts all tracked entries, triggering cleanup + // Navigate away and back to exercise the visible entry's lifecycle. let navButton = app.buttons["navigation-test-button"] waitForElement(navButton) navButton.tap() waitForElement(app.buttons["close-navigation-test-button"]) - // Give the final event time to fire + // Allow the navigation transition to settle before returning. Thread.sleep(forTimeInterval: 0.5) // Navigate back to main screen @@ -208,41 +123,35 @@ final class ExtendedViewTrackingTests: XCTestCase { scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) - // The event count should have increased (final event emitted during unmount) + // View activity remains observable across navigation. let postNavText = getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app) let postNavCount = parseComponentCount(postNavText) XCTAssertGreaterThan(postNavCount, preNavCount) } - func testPauseResumeOnBackgroundForeground() { + func testResumesViewTrackingAfterBackground() { waitForElement(app.staticTexts["Analytics Events"]) // Cycle 1: entry 0 is visible on launch. Wait for its initial event; // reading the stats then scrolls it off, which ends cycle 1. waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 1, app: app, timeout: EXTENDED_TIMEOUT) - let firstCycleViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(firstCycleViewId) let countBeforeBackground = parseComponentCount( getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - // Start a cycle that is ACTIVE when the app backgrounds: scroll entry 0 - // back into view and dwell past the threshold so its initial event - // fires. `pause()` must then emit a final event for this active cycle. + // Return the entry to view and let it qualify before backgrounding. scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) Thread.sleep(forTimeInterval: 3.0) - // Send app to background — pause() ends the active cycle with a final event. + // Backgrounding can end the active exposure. XCUIDevice.shared.press(.home) Thread.sleep(forTimeInterval: 1.0) - // Foreground — resume() re-evaluates the stored geometry (entry 0 was - // visible at background time) and starts a fresh cycle. + // Return to the app and let the visible entry qualify again. app.activate() waitForElement(app.staticTexts["Analytics Events"]) - // Let the resumed cycle dwell past the threshold so its initial event - // fires, then scroll the stats into view to read the updated count. + // Scroll the stats into view to read the updated count. Thread.sleep(forTimeInterval: 3.0) scrollToElement(testId: "event-count-\(VISIBLE_ENTRY_ID)", scrollViewId: "main-scroll-view", app: app) @@ -252,52 +161,6 @@ final class ExtendedViewTrackingTests: XCTestCase { // count must advance by at least 2. waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countBeforeBackground + 2, app: app, timeout: EXTENDED_TIMEOUT) - - // The resumed cycle must carry a different viewId than the first cycle. - let postForegroundViewId = getViewId(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotEqual(postForegroundViewId, firstCycleViewId, - "ViewId should change after background/foreground cycle (new tracking cycle)") - } - - func testDurationResetOnNewCycle() { - waitForElement(app.staticTexts["Analytics Events"]) - - // Cycle 1: entry 0 is visible on launch. Leave it untouched well past the - // dwell threshold so cycle 1 accumulates more than 4000 ms of view time. - Thread.sleep(forTimeInterval: 6.0) - - // Reading the stats scrolls entry 0 off, ending cycle 1 with a final - // event whose duration is the full ~6 s the entry was continuously - // visible — comfortably above the 4000 ms contract floor. - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: 2, app: app, timeout: EXTENDED_TIMEOUT) - let firstCycleDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(firstCycleDuration) - XCTAssertGreaterThan(firstCycleDuration!, 4000) - - let countAfterCycle1 = parseComponentCount( - getElementTextById("event-count-\(VISIBLE_ENTRY_ID)", app: app)) - - // Start a fresh, short cycle. Scroll entry 0 back to the top, then reset - // its tracking cycle to a known start with a quick out-and-in jiggle — - // measuring from the jiggle (rather than from somewhere inside the slow - // scroll-in) keeps the new cycle's duration tightly bounded and well - // under 4000 ms, proving the accumulator reset between cycles. - let fastVelocity = XCUIGestureVelocity(rawValue: 2500) - scrollEntryIntoView("content-entry-\(VISIBLE_ENTRY_ID)", - scrollViewId: "main-scroll-view", app: app) - scrollByOffset(scrollViewId: "main-scroll-view", dy: 260, app: app, velocity: fastVelocity) - scrollByOffset(scrollViewId: "main-scroll-view", dy: -260, app: app, velocity: fastVelocity) - Thread.sleep(forTimeInterval: 1.4) - waitForComponentEventCount(VISIBLE_ENTRY_ID, minCount: countAfterCycle1 + 1, - app: app, timeout: EXTENDED_TIMEOUT) - - // The new cycle's duration must have reset — it reflects only this short - // cycle, not the 4000+ ms accumulated in cycle 1. - let secondCycleDuration = getViewDuration(VISIBLE_ENTRY_ID, app: app) - XCTAssertNotNil(secondCycleDuration) - XCTAssertGreaterThanOrEqual(secondCycleDuration!, 1000) - XCTAssertLessThan(secondCycleDuration!, 4000, - "New cycle duration should reset — expected < 4000ms but got \(secondCycleDuration!)ms") } // MARK: - Helpers diff --git a/implementations/nextjs-sdk_app-router/.env.example b/implementations/nextjs-sdk_app-router/.env.example index a2ec81982..ab20214a3 100644 --- a/implementations/nextjs-sdk_app-router/.env.example +++ b/implementations/nextjs-sdk_app-router/.env.example @@ -1,6 +1,6 @@ DOTENV_CONFIG_QUIET=true -E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS +E2E_FLAGS=CSR,HYDRATION,SSR APP_PORT=3002 PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false" diff --git a/implementations/nextjs-sdk_app-router/AGENTS.md b/implementations/nextjs-sdk_app-router/AGENTS.md index 9b8051049..a54a3d475 100644 --- a/implementations/nextjs-sdk_app-router/AGENTS.md +++ b/implementations/nextjs-sdk_app-router/AGENTS.md @@ -11,12 +11,11 @@ per-entry `liveUpdates` or `loadingFallback`. - `lib/optimization.ts` is the only place that imports `bindNextjsAppRouterServerOptimization()` from `@contentful/optimization-nextjs/app-router/server`. -- Configure request hydration and trusted handoff once in `lib/optimization.ts`. -- Configure the callback-bound `ClientRequestOptimizationRoot` only in - `lib/optimization-client.ts`, then inject it into the server binding from `lib/optimization.ts`. -- The app-local request family exports `RequestOptimizationRoot` and `RequestOptimizedEntry` from - `@/lib/optimization`. The request root owns initial and later browser page sequencing; do not - mount or export a request page tracker beside it. +- Configure request hydration and prepared initial events once in `lib/optimization.ts`. +- The app-local request family exports `RequestOptimizationProvider`, `RequestOptimizationRoot`, + `RequestOptimizedEntry`, and `RequestPageTracker` from `@/lib/optimization`. The request template + mounts one tracker inside the request provider for initial replay and later navigation. Do not + mount a second tracker inside a request root or page. - Request routes, private request slots, and `EntryCard` use the applicable request aliases. - Static selection-handoff routes use `ExplicitOptimizationRoot` and `ExplicitOptimizedEntry` from `@/lib/optimization`. Analytics-only routes use the top-level `OptimizationAnalyticsRoot`. @@ -48,8 +47,10 @@ per-entry `liveUpdates` or `loadingFallback`. ## E2E -- Shared behavioral tests run via `lib/e2e-web` with `E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS` +- Shared behavioral tests run via `lib/e2e-web` with `E2E_FLAGS=CSR,HYDRATION,SSR` (port 3002). +- Keep JavaScript-disabled SSR checks active. Investigate a visible-content failure instead of + adding `SKIP_NO_JS` to the App Router runner. - App Router hydration behavior is covered by shared `lib/e2e-web` specs. - `test:e2e` starts the app + mocks via `serve`, then delegates to `lib/e2e-web`. - `test:e2e:ui` opens the shared Playwright UI with the App Router target pre-configured. diff --git a/implementations/nextjs-sdk_app-router/README.md b/implementations/nextjs-sdk_app-router/README.md index af0e38164..141b5becd 100644 --- a/implementations/nextjs-sdk_app-router/README.md +++ b/implementations/nextjs-sdk_app-router/README.md @@ -18,7 +18,7 @@ This reference implementation integrates `@contentful/optimization-nextjs` with the Next.js App Router. It demonstrates request-bound personalization, public permutation handoff, analytics-only -rendering, Cache Components routes, and browser takeover without importing lower-level SDKs. +rendering, blocking request SSR, and browser takeover without importing lower-level SDKs. For a complete integration walkthrough, see [Integrate the Optimization SDK in a Next.js App Router app](../../documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md). @@ -28,10 +28,12 @@ Edge runtime routes live in the ## What this covers - A single server binding in `lib/optimization.ts`. -- A client-only binding in `lib/optimization-client.ts` that runs optional before-initial-page work - before the request root's browser-owned page event. +- Request `initialEvents` and the page event are prepared with the server handoff for the browser + queue. - Request-bound Server Components with browser hydration and live updates. - Static public permutation and analytics-only handoff. +- Browser consent bootstrap for public handoffs, preserving a consented visitor's profile across + static selection and analytics-only routes. - App-owned and SDK-managed Contentful entry fetching. - Preview panel attachment behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL`. @@ -56,7 +58,7 @@ export const config = { ``` Next.js 13 to 15 uses the same handler from `middleware.ts` with a `middleware` export. The linked -guide covers forwarding and trusted handoff options. +guide covers request-context forwarding. ## Compose a private island @@ -87,19 +89,20 @@ async function PrivateRequestSlot() { Keep provider-dependent tools inside `RequestOptimizationRoot`. -## Run before-initial-page work +## Prepare request events for browser commitment -`lib/optimization-client.ts` binds a client-only `beforeInitialPage` callback. The maintained -callback identifies only when the URL contains `?beforeInitialPage=readiness`; ordinary routes do -nothing. `lib/optimization.ts` injects that module's `ClientRequestOptimizationRoot` into the server -request family as a Client Component reference. +`lib/optimization.ts` configures `request.initialEvents` for the maintained +`?beforeInitialPage=readiness` scenario; ordinary routes have no extra initial event. The request +family builds that event together with the page event, previews the batch through the Experience +API, and returns the preview state for server rendering with a private handoff. Preview does not +commit the profile or events. After hydration, the browser admits the matching replay to its normal +Experience queue. The request template mounts the route tracker for browser navigation, so keep one +tracker at that routing boundary. -The server passes only children, defaults, handoff, and hydration through the request root. The -client root derives the current App Router route and lazy page payload in the browser, so neither the -callback nor a payload-builder function crosses the Server Component boundary. It makes the direct -initial page attempt, marks that attempted route through the existing non-emitting initial `skip` -path, and emits once for each later route. Do not mount a separate request page tracker in the same -subtree. +The request route uses the visible pathname and search as the replay route key. Next.js transport +URLs do not become route identity. On a preview failure the replay remains available for browser +commitment, while the server can render baseline content. Server consent and the API-issued profile +cookie continue to control event admission and durable identity persistence. ## Choose entry-fetch ownership @@ -165,6 +168,9 @@ pnpm setup:e2e:nextjs-sdk_app-router pnpm test:e2e:nextjs-sdk_app-router ``` +The runner uses `E2E_FLAGS=CSR,HYDRATION,SSR`, so JavaScript-disabled SSR checks run alongside +hydration and browser checks. Do not enable `SKIP_NO_JS` for this reference implementation. + ## Related - [Next.js SDK Pages Router](../nextjs-sdk_pages-router/README.md) - Pages Router reference diff --git a/implementations/nextjs-sdk_app-router/app/(request)/layout.tsx b/implementations/nextjs-sdk_app-router/app/(request)/layout.tsx index 3c0fe5584..20829a682 100644 --- a/implementations/nextjs-sdk_app-router/app/(request)/layout.tsx +++ b/implementations/nextjs-sdk_app-router/app/(request)/layout.tsx @@ -1,37 +1,25 @@ -import { AppShellBody, AppShellChrome, PersonalizedContentFallback } from '@/components/AppShell' +import { AppShellBody, AppShellChrome } from '@/components/AppShell' import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvider' import { PreviewPanel } from '@/components/PreviewPanel' -import { RequestOptimizationRoot } from '@/lib/optimization' +import { RequestOptimizationProvider } from '@/lib/optimization' import { connection } from 'next/server' -import { Suspense, type ReactNode } from 'react' +import type { ReactNode } from 'react' -async function RequestRuntime({ +export default async function RequestLayout({ children, }: Readonly<{ children: ReactNode }>) { await connection() - return ( - - - - {children} - - - ) -} - -export default function RequestLayout({ - children, -}: Readonly<{ - children: ReactNode -}>) { return ( - }> - {children} - + + + + {children} + + ) } diff --git a/implementations/nextjs-sdk_app-router/app/(request)/template.tsx b/implementations/nextjs-sdk_app-router/app/(request)/template.tsx new file mode 100644 index 000000000..c8d88d368 --- /dev/null +++ b/implementations/nextjs-sdk_app-router/app/(request)/template.tsx @@ -0,0 +1,13 @@ +import { RequestPageTracker } from '@/lib/optimization' +import { Suspense, type ReactNode } from 'react' + +export default function RequestTemplate({ children }: Readonly<{ children: ReactNode }>) { + return ( + <> + + + + {children} + + ) +} diff --git a/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/layout.tsx b/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/layout.tsx index da00a0c3e..894c1f11e 100644 --- a/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/layout.tsx +++ b/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/layout.tsx @@ -26,8 +26,8 @@ export default async function AnalyticsOnlyLayout({ return ( pagePayload} handoff={handoff} + initialPagePayload={pagePayload} routeKey={routeKey} > {children} diff --git a/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/page.tsx b/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/page.tsx index bfb27d495..d8c1c3a68 100644 --- a/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/page.tsx +++ b/implementations/nextjs-sdk_app-router/app/(static)/analytics-only/[segment]/page.tsx @@ -9,34 +9,35 @@ import { getServerTrackingAttributes, resolveEntriesForSelections, } from '@/lib/optimization' -import { cacheLife, cacheTag } from 'next/cache' +import { unstable_cache } from 'next/cache' import { notFound } from 'next/navigation' +export const revalidate = 60 + export function generateStaticParams() { return getCustomerSegmentStaticParams() } async function loadAnalyticsOnlyPage(segmentSlug: string) { - 'use cache' - - cacheLife({ revalidate: 60 }) - const segment = getCustomerSegment(segmentSlug) if (segment === undefined) return undefined - const entries = await loadRequiredPageEntries([segment.baselineEntryId], { - onMissingEntry: staticPublicHandoffMissingRequiredEntryBehavior, - }) + const handoff = createCustomerSegmentAnalyticsHandoff(segment) + const entries = await unstable_cache( + () => + loadRequiredPageEntries([segment.baselineEntryId], { + onMissingEntry: staticPublicHandoffMissingRequiredEntryBehavior, + }), + [`analytics-only:${segment.slug}`], + { revalidate, tags: [...(handoff.cache.tags ?? [])] }, + )() if (entries === undefined) return undefined const resolvedEntries = resolveEntriesForSelections({ entries, selectedOptimizations: segment.selectedOptimizations, }) - const handoff = createCustomerSegmentAnalyticsHandoff(segment) - if (handoff.cache.tags !== undefined) cacheTag(...handoff.cache.tags) - return { handoff, resolvedEntries } } diff --git a/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/layout.tsx b/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/layout.tsx index fa1d74dc4..cbab17379 100644 --- a/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/layout.tsx +++ b/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/layout.tsx @@ -26,8 +26,8 @@ export default async function SelectionHandoffLayout({ return ( pagePayload} handoff={handoff} + initialPagePayload={pagePayload} routeKey={routeKey} > {children} diff --git a/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/page.tsx b/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/page.tsx index b051d2a79..bd0a86b71 100644 --- a/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/page.tsx +++ b/implementations/nextjs-sdk_app-router/app/(static)/selection-handoff/[segment]/page.tsx @@ -5,29 +5,30 @@ import { } from '@/lib/contentful' import { getCustomerSegment, getCustomerSegmentStaticParams } from '@/lib/customer-segments' import { createCustomerSegmentHandoff, ExplicitOptimizedEntry } from '@/lib/optimization' -import { cacheLife, cacheTag } from 'next/cache' +import { unstable_cache } from 'next/cache' import { notFound } from 'next/navigation' +export const revalidate = 60 + export function generateStaticParams() { return getCustomerSegmentStaticParams() } async function loadSelectionHandoffPage(segmentSlug: string) { - 'use cache' - - cacheLife({ revalidate: 60 }) - const segment = getCustomerSegment(segmentSlug) if (segment === undefined) return undefined - const entries = await loadRequiredPageEntries([segment.baselineEntryId], { - onMissingEntry: staticPublicHandoffMissingRequiredEntryBehavior, - }) - if (entries === undefined) return undefined - const handoff = createCustomerSegmentHandoff(segment) - if (handoff.cache.tags !== undefined) cacheTag(...handoff.cache.tags) + const entries = await unstable_cache( + () => + loadRequiredPageEntries([segment.baselineEntryId], { + onMissingEntry: staticPublicHandoffMissingRequiredEntryBehavior, + }), + [`selection-handoff:${segment.slug}`], + { revalidate, tags: [...(handoff.cache.tags ?? [])] }, + )() + if (entries === undefined) return undefined return { entries, handoff } } diff --git a/implementations/nextjs-sdk_app-router/components/PublicHandoffRoots.tsx b/implementations/nextjs-sdk_app-router/components/PublicHandoffRoots.tsx new file mode 100644 index 000000000..9f0266989 --- /dev/null +++ b/implementations/nextjs-sdk_app-router/components/PublicHandoffRoots.tsx @@ -0,0 +1,72 @@ +'use client' + +import { optimizationSdkConfig } from '@/lib/config' +import { getBrowserAppConsent } from '@/lib/util' +import { + OptimizationAnalyticsRoot as ClientOptimizationAnalyticsRoot, + OptimizationRoot as ClientOptimizationRoot, + type AutoPagePayload, + type OptimizationAnalyticsRootProps, + type OptimizationRootProps, +} from '@contentful/optimization-nextjs/client' +import type { JSX, ReactNode } from 'react' + +type PublicContentRootProps = { + readonly children: ReactNode + readonly handoff: NonNullable + readonly initialPagePayload: AutoPagePayload + readonly routeKey: string +} + +type PublicAnalyticsRootProps = { + readonly children: ReactNode + readonly handoff: OptimizationAnalyticsRootProps['handoff'] + readonly initialPagePayload: AutoPagePayload + readonly routeKey: string +} + +function browserConsentDefaults(): { + readonly consent: boolean + readonly persistenceConsent: boolean +} { + const consent = getBrowserAppConsent() === true + return { consent, persistenceConsent: consent } +} + +export function PublicContentRoot({ + children, + handoff, + initialPagePayload, + routeKey, +}: PublicContentRootProps): JSX.Element { + return ( + + {children} + + ) +} + +export function PublicAnalyticsRoot({ + children, + handoff, + initialPagePayload, + routeKey, +}: PublicAnalyticsRootProps): JSX.Element { + return ( + + {children} + + ) +} diff --git a/implementations/nextjs-sdk_app-router/lib/config.ts b/implementations/nextjs-sdk_app-router/lib/config.ts index 94316aedd..7a25330f6 100644 --- a/implementations/nextjs-sdk_app-router/lib/config.ts +++ b/implementations/nextjs-sdk_app-router/lib/config.ts @@ -12,3 +12,16 @@ export const appConfig = { env.PUBLIC_EXPERIENCE_API_BASE_URL?.trim() ?? 'http://localhost:8000/experience/', }, } as const + +export const optimizationSdkConfig = { + spaceId: appConfig.spaceId, + environment: appConfig.environment, + locale: appConfig.locale, + logLevel: 'debug', + api: appConfig.api, + app: { + name: 'Contentful Optimization Next.js SDK App Router', + version: '0.1.0', + }, + trackEntryInteraction: { views: true, clicks: true, hovers: true }, +} as const diff --git a/implementations/nextjs-sdk_app-router/lib/optimization-client.ts b/implementations/nextjs-sdk_app-router/lib/optimization-client.ts deleted file mode 100644 index 613c80b27..000000000 --- a/implementations/nextjs-sdk_app-router/lib/optimization-client.ts +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization-nextjs/app-router/client' -import { appConfig } from './config' -import { getBrowserAppConsent } from './util' - -const BEFORE_INITIAL_PAGE_QUERY_VALUE = 'readiness' -const BEFORE_INITIAL_PAGE_MAX_WAIT_MS = 2_500 - -function getBrowserClientDefaults(): { - readonly consent: boolean - readonly persistenceConsent: boolean -} { - const consent = getBrowserAppConsent() ?? false - - return { consent, persistenceConsent: consent } -} - -const optimization = bindNextjsAppRouterClientOptimization({ - spaceId: appConfig.spaceId, - environment: appConfig.environment, - locale: appConfig.locale, - logLevel: 'debug', - api: appConfig.api, - app: { - name: 'Contentful Optimization Next.js SDK App Router', - version: '0.1.0', - }, - consent: { - clientDefaults: getBrowserClientDefaults(), - }, - beforeInitialPage: { - maxWaitMs: BEFORE_INITIAL_PAGE_MAX_WAIT_MS, - run: ({ identify }) => { - const scenario = new URLSearchParams(window.location.search).get('beforeInitialPage') - if (scenario !== BEFORE_INITIAL_PAGE_QUERY_VALUE) return - - return identify({ userId: 'charles', traits: { identified: true } }) - }, - }, - trackEntryInteraction: { views: true, clicks: true, hovers: true }, -}) - -export const { RequestOptimizationRoot: ClientRequestOptimizationRoot } = optimization diff --git a/implementations/nextjs-sdk_app-router/lib/optimization.ts b/implementations/nextjs-sdk_app-router/lib/optimization.ts index 0a3e0e613..ec7ef8a31 100644 --- a/implementations/nextjs-sdk_app-router/lib/optimization.ts +++ b/implementations/nextjs-sdk_app-router/lib/optimization.ts @@ -6,17 +6,16 @@ import { createNextjsPublicPermutationCacheMiddleware, type NextjsPublicPermutationCacheMiddleware, } from '@contentful/optimization-nextjs/cache-middleware' -import { createNextjsOptimizationContextHandler } from '@contentful/optimization-nextjs/request-handler' import { type NextjsOptimizationServerConsentResolver } from '@contentful/optimization-nextjs/server' import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes' import type { NextRequest, NextResponse } from 'next/server' -import { appConfig } from './config' +import { optimizationSdkConfig } from './config' import { client } from './contentful' import { getCustomerSegment, type CustomerSegment } from './customer-segments' -import { ClientRequestOptimizationRoot } from './optimization-client' import { getAppConsent } from './util' const HIDDEN_UNTIL_READY_ROUTE = '/hidden-until-ready' +const INITIAL_IDENTIFY_QUERY_VALUE = 'readiness' const PUBLIC_HANDOFF_PREFIXES = ['/selection-handoff/', '/analytics-only/'] as const type AppRouterOptimization = ReturnType @@ -24,56 +23,46 @@ export type ContentHandoff = NonNullable< Parameters[0]['handoff'] > -const serverOptimizationConfig = { - spaceId: appConfig.spaceId, - environment: appConfig.environment, - locale: appConfig.locale, - logLevel: 'debug', - api: appConfig.api, - app: { - name: 'Contentful Optimization Next.js SDK App Router', - version: '0.1.0', - }, -} as const - const serverConsent: NextjsOptimizationServerConsentResolver = ({ cookies }) => getAppConsent(cookies) ? { events: true, persistence: true } : false -const optimization = bindNextjsAppRouterServerOptimization( - { - ...serverOptimizationConfig, - contentful: { client }, - trackEntryInteraction: { views: true, clicks: true, hovers: true }, - consent: { - server: serverConsent, - clientDefaults: { consent: false, persistenceConsent: false }, - }, - request: { - hydration: ({ routeKey }) => - routeKey.split('?')[0] === HIDDEN_UNTIL_READY_ROUTE - ? 'client-only-hidden-until-ready' - : 'preserve-server', - }, +const optimization = bindNextjsAppRouterServerOptimization({ + ...optimizationSdkConfig, + contentful: { client }, + consent: { + server: serverConsent, + clientDefaults: { consent: false, persistenceConsent: false }, }, - { - request: { - OptimizationRoot: ClientRequestOptimizationRoot, - }, + request: { + hydration: ({ routeKey }) => + routeKey.split('?')[0] === HIDDEN_UNTIL_READY_ROUTE + ? 'client-only-hidden-until-ready' + : 'preserve-server', + initialEvents: ({ requestUrl }) => + new URL(requestUrl).searchParams.get('beforeInitialPage') === INITIAL_IDENTIFY_QUERY_VALUE + ? [{ type: 'identify', userId: 'charles', traits: { identified: true } }] + : [], }, -) +}) export const { - OptimizationAnalyticsRoot, - OptimizationRoot: ExplicitOptimizationRoot, OptimizedEntry: ExplicitOptimizedEntry, createHandoffFromSelections, createOptimizationCacheKey, createPublicPermutationHandoff, resolveEntriesForSelections, } = optimization -export const { OptimizationRoot: RequestOptimizationRoot, OptimizedEntry: RequestOptimizedEntry } = - optimization.request +export { + PublicContentRoot as ExplicitOptimizationRoot, + PublicAnalyticsRoot as OptimizationAnalyticsRoot, +} from '@/components/PublicHandoffRoots' export { getServerTrackingAttributes } +export const { + NextAppAutoPageTracker: RequestPageTracker, + OptimizationProvider: RequestOptimizationProvider, + OptimizationRoot: RequestOptimizationRoot, + OptimizedEntry: RequestOptimizedEntry, +} = optimization.request const cacheMiddleware: NextjsPublicPermutationCacheMiddleware = createNextjsPublicPermutationCacheMiddleware({ @@ -94,14 +83,11 @@ const cacheMiddleware: NextjsPublicPermutationCacheMiddleware = }, }) -const forwardOptimizationContext = createNextjsOptimizationContextHandler() - export function createCustomerSegmentHandoff(segment: CustomerSegment): ContentHandoff { return createPublicPermutationHandoff({ cacheVersion: segment.cacheVersion, entryIds: segment.baselineEntryIds, hydration: 'preserve-server', - initialPageEvent: 'emit', locale: segment.locale, permutationKey: segment.slug, selectedOptimizations: segment.selectedOptimizations, @@ -114,7 +100,6 @@ export function createCustomerSegmentAnalyticsHandoff(segment: CustomerSegment) cacheVersion: segment.cacheVersion, entryIds: segment.baselineEntryIds, hydration: 'analytics-only', - initialPageEvent: 'emit', locale: segment.locale, permutationKey: segment.slug, selectedOptimizations: segment.selectedOptimizations, @@ -139,7 +124,7 @@ export async function proxy(request: NextRequest): Promise { return cacheMiddleware(request) } - return forwardOptimizationContext(request) + return optimization.requestHandler(request) } function isPublicHandoffPath(pathname: string): boolean { diff --git a/implementations/nextjs-sdk_app-router/next.config.ts b/implementations/nextjs-sdk_app-router/next.config.ts index 2653f81f9..4010a037c 100644 --- a/implementations/nextjs-sdk_app-router/next.config.ts +++ b/implementations/nextjs-sdk_app-router/next.config.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url' const configDir = dirname(fileURLToPath(import.meta.url)) const nextConfig: NextConfig = { - cacheComponents: true, transpilePackages: ['e2e-web'], env: { PUBLIC_EXPERIENCE_API_BASE_URL: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, diff --git a/implementations/nextjs-sdk_app-router/package.json b/implementations/nextjs-sdk_app-router/package.json index 1650f2eb7..f378eda4b 100644 --- a/implementations/nextjs-sdk_app-router/package.json +++ b/implementations/nextjs-sdk_app-router/package.json @@ -14,14 +14,14 @@ "serve": "pnpm serve:mocks && pnpm serve:app", "serve:app": "pnpm build && pm2 start --name nextjs-sdk-app-router-app \"pnpm start\"", "serve:app:stop": "pm2 stop nextjs-sdk-app-router-app && pm2 delete nextjs-sdk-app-router-app", - "serve:mocks": "pm2 start --name nextjs-sdk-app-router-mocks \"pnpm --dir ../.. serve:mocks\"", + "serve:mocks": "pm2 start --name nextjs-sdk-app-router-mocks \"pnpm --dir ../.. serve:mocks\" && curl --retry 80 --retry-delay 0 --retry-max-time 20 --retry-connrefused -fsS http://localhost:8000/health >/dev/null", "serve:mocks:stop": "pm2 stop nextjs-sdk-app-router-mocks && pm2 delete nextjs-sdk-app-router-mocks", "serve:stop": "pnpm serve:app:stop; pnpm serve:mocks:stop", "serve:e2e": "pnpm build && pnpm start", - "test:e2e": "E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test", - "test:e2e:codegen": "APP_PORT=3002 E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test:codegen", + "test:e2e": "E2E_FLAGS=CSR,HYDRATION,SSR IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test", + "test:e2e:codegen": "APP_PORT=3002 E2E_FLAGS=CSR,HYDRATION,SSR IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test:codegen", "test:e2e:report": "pnpm --dir ../../lib/e2e-web test:report", - "test:e2e:ui": "E2E_FLAGS=CSR,HYDRATION,SSR,SKIP_NO_JS IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test:ui", + "test:e2e:ui": "E2E_FLAGS=CSR,HYDRATION,SSR IMPLEMENTATION=nextjs-sdk_app-router pnpm --dir ../../lib/e2e-web test:ui", "test:unit": "echo \"No unit tests necessary\"", "typecheck": "tsc --noEmit", "lint": "eslint" diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/.env.example b/implementations/nextjs-sdk_app-router_edge-runtime/.env.example index 8d3df19a6..da29ff7c9 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/.env.example +++ b/implementations/nextjs-sdk_app-router_edge-runtime/.env.example @@ -8,3 +8,5 @@ PUBLIC_INSIGHTS_API_BASE_URL="http://localhost:8000/insights/" PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_ENVIRONMENT="master" +PUBLIC_CONTENTFUL_CDA_BASE_URL="http://localhost:8000/contentful/" +PUBLIC_CONTENTFUL_TOKEN="mock-token" diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/README.md b/implementations/nextjs-sdk_app-router_edge-runtime/README.md index 5f9fe3456..d04168c09 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/README.md +++ b/implementations/nextjs-sdk_app-router_edge-runtime/README.md @@ -20,15 +20,23 @@ Reference implementation for actual Edge runtime handoff routes in `@contentful/optimization-nextjs`. Routes export `runtime = 'edge'`, avoid Node-only APIs, and use `@contentful/optimization-nextjs/edge` from `@/lib/edge-optimization`. -This implementation is separate from the App Router Cache Components reference implementation. It -does not cover ISR, route-level `revalidate`, or Cache Components. +This implementation covers actual Edge runtime routes. It does not cover ISR, route-level +`revalidate`, or Cache Components; the App Router reference implementation owns static and ISR +routes. ## What this covers - Request-personalized Edge runtime handoff from `app/edge-request/route.ts` - Public permutation Edge runtime handoff from `app/edge-selection/[segment]/route.ts` +- Request-selected content rendered on the Edge at `/edge-render/private`, with Experience API + preview on the server and prepared-event commitment through the browser queue after hydration +- App-owned public selections rendered on the Edge at `/edge-render/public/[segment]` - Edge runtime assertion with `globalThis.EdgeRuntime === 'edge-runtime'` -- Browser handoff state created without Node-only APIs +- Profile cookie persistence through the response owned by `/edge-request` + +For E2E clarity, this reference separates cookie persistence (`/edge-request`, which owns the HTTP +response) from rendered pages (`/edge-render/*`, which exercise visible content and browser +hydration). Applications with an Edge-safe HTML renderer can do both in one Route Handler response. ## Prerequisites @@ -57,6 +65,9 @@ pnpm implementation:run -- nextjs-sdk_app-router_edge-runtime lint ``` The development server runs on `http://localhost:3003`. +The rendered routes fetch their baseline and variant entries through the Contentful Delivery API. +Set `PUBLIC_CONTENTFUL_CDA_BASE_URL` and `PUBLIC_CONTENTFUL_TOKEN` in the local `.env` for your +content; `.env.example` points them at the local mock CDA. ## Running E2E tests @@ -67,12 +78,14 @@ pnpm setup:e2e:nextjs-sdk_app-router_edge-runtime pnpm test:e2e:nextjs-sdk_app-router_edge-runtime ``` -The E2E suite uses the shared `lib/e2e-web` Edge runtime scenarios with `E2E_FLAGS=EDGE`. +The Edge-specific `lib/e2e-web/e2e/edge-consumer.spec.ts` runs through the shared Playwright runner +with `E2E_FLAGS=EDGE`. It checks visible selected content without JavaScript, browser commitment, +consent and profile-cookie continuity, and navigation across the rendered Edge routes. ## Related -- [Next.js SDK App Router](../nextjs-sdk_app-router/README.md) - Cache Components App Router - reference implementation +- [Next.js SDK App Router](../nextjs-sdk_app-router/README.md) - App Router reference implementation + with static and ISR routes - [Next.js SDK Pages Router](../nextjs-sdk_pages-router/README.md) - Pages Router ISR reference implementation - [@contentful/optimization-nextjs](../../packages/web/frameworks/nextjs-sdk/README.md) - Next.js diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/EdgeHandoffRoot.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/EdgeHandoffRoot.tsx new file mode 100644 index 000000000..c3f350586 --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/EdgeHandoffRoot.tsx @@ -0,0 +1,80 @@ +'use client' + +import { appConfig } from '@/lib/config' +import { + OptimizationRoot, + useConsentState, + useEventStreamState, + useOptimizationActions, + type OptimizationRootProps, +} from '@contentful/optimization-nextjs/client' +import { useEffect, useSyncExternalStore, type ReactNode } from 'react' + +const subscribeToMount = (): (() => void) => () => undefined +const isMounted = (): boolean => true +const isServer = (): boolean => false + +function hasBrowserConsent(): boolean { + return document.cookie + .split(';') + .some((part) => part.trim() === 'app-personalization-consent=granted') +} + +function EdgeControls() { + const consent = useConsentState() + const latestEvent = useEventStreamState() + const { setConsent } = useOptimizationActions() + const mounted = useSyncExternalStore(subscribeToMount, isMounted, isServer) + + useEffect(() => { + const granted = hasBrowserConsent() + if (consent !== granted) setConsent(granted) + }, [consent, setConsent]) + + if (!mounted) return null + + return ( +
+

Consent: {consent === true ? 'Yes' : 'No'}

+

Latest event: {latestEvent?.type ?? 'None'}

+ +
+ ) +} + +export function EdgeHandoffRoot({ + children, + consent, + handoff, + routeKey, +}: { + readonly children: ReactNode + readonly consent: boolean + readonly handoff: NonNullable + readonly routeKey: string +}) { + const initialConsent = typeof document === 'undefined' ? consent : hasBrowserConsent() + + return ( + + {children} + + + ) +} diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/private/page.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/private/page.tsx new file mode 100644 index 000000000..b38c4fc81 --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/private/page.tsx @@ -0,0 +1,64 @@ +import { EdgeHandoffRoot } from '@/app/edge-render/EdgeHandoffRoot' +import { getCustomerSegment } from '@/lib/customer-segments' +import { loadEdgeRenderedEntry } from '@/lib/edge-content' +import { createEdgeRequestHandoff } from '@/lib/edge-optimization' +import { assertEdgeRuntime } from '@/lib/edge-runtime' +import { getAppConsent } from '@/lib/util' +import { cookies, headers } from 'next/headers' +import Link from 'next/link' + +export const runtime = 'edge' +export const dynamic = 'force-dynamic' + +const ROUTE_KEY = '/edge-render/private' + +export default async function EdgePrivatePage({ + searchParams, +}: { + readonly searchParams: Promise> +}) { + assertEdgeRuntime() + const requestHeaders = await headers() + const requestCookies = await cookies() + const consent = getAppConsent(requestCookies) + const protocol = requestHeaders.get('x-forwarded-proto') ?? 'http' + const host = + requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? 'localhost:3003' + const requestUrl = new URL(ROUTE_KEY, `${protocol}://${host}`) + for (const [name, value] of Object.entries(await searchParams)) { + for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) { + requestUrl.searchParams.append(name, item) + } + } + const routeKey = `${ROUTE_KEY}${requestUrl.search}` + const { data, handoff } = await createEdgeRequestHandoff({ + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + pagePayload: { properties: { path: ROUTE_KEY, url: requestUrl.toString() } }, + request: { headers: new Headers(requestHeaders), url: requestUrl.toString() }, + }) + if (handoff.hydration !== 'preserve-server') { + throw new Error('Private Edge rendering requires a content handoff.') + } + const segment = getCustomerSegment('new-visitor') + if (segment === undefined) throw new Error('New-visitor customer segment is missing.') + const entry = await loadEdgeRenderedEntry({ + baselineId: segment.baselineEntryId, + locale: segment.locale, + selectedOptimizations: data?.selectedOptimizations, + }) + + return ( + +
+

Personalized Edge request

+
+

{entry.text}

+
+ + View public Edge selection + +
+
+ ) +} diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/public/[segment]/page.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/public/[segment]/page.tsx new file mode 100644 index 000000000..98a6853bc --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-render/public/[segment]/page.tsx @@ -0,0 +1,54 @@ +import { EdgeHandoffRoot } from '@/app/edge-render/EdgeHandoffRoot' +import { getCustomerSegment } from '@/lib/customer-segments' +import { loadEdgeRenderedEntry } from '@/lib/edge-content' +import { createEdgeCustomerSegmentHandoff } from '@/lib/edge-optimization' +import { assertEdgeRuntime } from '@/lib/edge-runtime' +import Link from 'next/link' +import { notFound } from 'next/navigation' + +export const runtime = 'edge' +export const dynamic = 'force-dynamic' + +export default async function EdgePublicPage({ + params, + searchParams, +}: { + readonly params: Promise<{ readonly segment: string }> + readonly searchParams: Promise> +}) { + assertEdgeRuntime() + const { segment: slug } = await params + const segment = getCustomerSegment(slug) + if (segment === undefined) notFound() + + const handoff = createEdgeCustomerSegmentHandoff(segment) + if (handoff.hydration !== 'preserve-server') { + throw new Error('Public Edge rendering requires a content handoff.') + } + const entry = await loadEdgeRenderedEntry({ + baselineId: segment.baselineEntryId, + locale: segment.locale, + selectedOptimizations: segment.selectedOptimizations, + }) + const routeKeyUrl = new URL(`/edge-render/public/${segment.slug}`, 'http://localhost') + for (const [name, value] of Object.entries(await searchParams)) { + for (const item of Array.isArray(value) ? value : value === undefined ? [] : [value]) { + routeKeyUrl.searchParams.append(name, item) + } + } + const routeKey = `${routeKeyUrl.pathname}${routeKeyUrl.search}` + + return ( + +
+

Public Edge selection: {segment.label}

+
+

{entry.text}

+
+ + View personalized Edge request + +
+
+ ) +} diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-request/route.ts b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-request/route.ts index ff46b5d5e..993d65782 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-request/route.ts +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-request/route.ts @@ -6,7 +6,7 @@ export const runtime = 'edge' export async function GET(request: Request): Promise { const runtimeWitness = assertEdgeRuntime() const url = new URL(request.url) - const { handoff, pageResult, persist } = await createEdgeRequestHandoff({ + const { handoff, persist } = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', pagePayload: { properties: { path: url.pathname, url: request.url } }, @@ -14,11 +14,10 @@ export async function GET(request: Request): Promise { }) const response = Response.json( { - accepted: pageResult.accepted, cache: handoff.cache, hasState: handoff.state !== undefined, hydration: handoff.hydration, - initialPageEvent: handoff.initialPageEvent, + replay: handoff.replay, runtime: runtimeWitness, }, { diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-selection/[segment]/route.ts b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-selection/[segment]/route.ts index d510db8c1..754e40d55 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-selection/[segment]/route.ts +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-selection/[segment]/route.ts @@ -27,7 +27,6 @@ export async function GET( { cache: handoff.cache, hydration: handoff.hydration, - initialPageEvent: handoff.initialPageEvent, runtime: runtimeWitness, selectedOptimizations: handoff.state?.selectedOptimizations ?? [], }, diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/page.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/page.tsx index 898f27718..7ecacea34 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/app/page.tsx +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/page.tsx @@ -1,7 +1,20 @@ +import Link from 'next/link' + export default function HomePage() { return (

Next.js Edge runtime reference implementation

+
) } diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/lib/config.ts b/implementations/nextjs-sdk_app-router_edge-runtime/lib/config.ts index 30f91ba08..dfa05525c 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/lib/config.ts +++ b/implementations/nextjs-sdk_app-router_edge-runtime/lib/config.ts @@ -10,4 +10,8 @@ export const appConfig = { experienceBaseUrl: env.PUBLIC_EXPERIENCE_API_BASE_URL?.trim() ?? 'http://localhost:8000/experience/', }, + contentful: { + baseUrl: env.PUBLIC_CONTENTFUL_CDA_BASE_URL?.trim() ?? 'http://localhost:8000/contentful/', + accessToken: env.PUBLIC_CONTENTFUL_TOKEN?.trim() ?? 'mock-token', + }, } as const diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-content.ts b/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-content.ts new file mode 100644 index 000000000..fcf63a82a --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-content.ts @@ -0,0 +1,90 @@ +import type { SelectedOptimizationArray } from '@contentful/optimization-nextjs/api-schemas' +import { isResolvedContentfulEntry } from '@contentful/optimization-nextjs/api-schemas' +import { appConfig } from './config' + +interface EntryCollection { + readonly items?: unknown + readonly includes?: { readonly Entry?: unknown } +} + +export interface EdgeRenderedEntry { + readonly text: string + readonly trackingAttributes: { + readonly 'data-ctfl-baseline-id': string + readonly 'data-ctfl-entry-id': string + readonly 'data-ctfl-optimization-id': string | undefined + readonly 'data-ctfl-variant-index': string + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function loadEntryCollection(baselineId: string, locale: string): Promise { + const url = new URL( + `spaces/${appConfig.spaceId}/environments/${appConfig.environment}/entries`, + appConfig.contentful.baseUrl, + ) + url.searchParams.set('sys.id', baselineId) + url.searchParams.set('include', '10') + url.searchParams.set('locale', locale) + + const response = await fetch(url, { + cache: 'no-store', + headers: { Authorization: `Bearer ${appConfig.contentful.accessToken}` }, + }) + if (!response.ok) throw new Error(`Contentful entry request failed: ${response.status}`) + + const value: unknown = await response.json() + if (!isRecord(value)) throw new TypeError('Contentful entry response is not an object.') + return value +} + +function findEntry(collection: EntryCollection, entryId: string) { + const included = isRecord(collection.includes) ? collection.includes.Entry : undefined + const candidates = [ + ...(Array.isArray(collection.items) ? collection.items : []), + ...(Array.isArray(included) ? included : []), + ] + const entry = candidates.find( + (candidate) => isResolvedContentfulEntry(candidate) && candidate.sys.id === entryId, + ) + if (!isResolvedContentfulEntry(entry)) { + throw new Error(`Contentful entry ${entryId} is missing from the selected baseline response.`) + } + return entry +} + +export async function loadEdgeRenderedEntry({ + baselineId, + locale, + selectedOptimizations, +}: { + readonly baselineId: string + readonly locale: string + readonly selectedOptimizations: SelectedOptimizationArray | undefined +}): Promise { + const collection = await loadEntryCollection(baselineId, locale) + findEntry(collection, baselineId) + const selectedOptimization = selectedOptimizations?.find((selection) => + Object.prototype.hasOwnProperty.call(selection.variants, baselineId), + ) + const selectedId = selectedOptimization?.variants[baselineId] ?? baselineId + const selected = findEntry(collection, selectedId) + const text = selected.fields.text + + if (typeof text !== 'string') { + throw new TypeError(`Contentful entry ${selectedId} has no plain text field.`) + } + + return { + text, + trackingAttributes: { + 'data-ctfl-baseline-id': baselineId, + 'data-ctfl-entry-id': selectedId, + 'data-ctfl-optimization-id': selectedOptimization?.experienceId, + 'data-ctfl-variant-index': String(selectedOptimization?.variantIndex ?? 0), + }, + } +} diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-optimization.ts b/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-optimization.ts index 5731e5c49..8f5a79717 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-optimization.ts +++ b/implementations/nextjs-sdk_app-router_edge-runtime/lib/edge-optimization.ts @@ -26,7 +26,6 @@ export function createEdgeCustomerSegmentHandoff(segment: CustomerSegment) { cacheVersion: segment.cacheVersion, entryIds: segment.baselineEntryIds, hydration: 'preserve-server', - initialPageEvent: 'emit', locale: segment.locale, permutationKey: segment.slug, selectedOptimizations: segment.selectedOptimizations, diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/next.config.ts b/implementations/nextjs-sdk_app-router_edge-runtime/next.config.ts index b09c763a3..b6367512e 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/next.config.ts +++ b/implementations/nextjs-sdk_app-router_edge-runtime/next.config.ts @@ -11,6 +11,8 @@ const nextConfig: NextConfig = { PUBLIC_CONTENTFUL_ENVIRONMENT: process.env.PUBLIC_CONTENTFUL_ENVIRONMENT, PUBLIC_EXPERIENCE_API_BASE_URL: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, PUBLIC_INSIGHTS_API_BASE_URL: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + PUBLIC_CONTENTFUL_CDA_BASE_URL: process.env.PUBLIC_CONTENTFUL_CDA_BASE_URL, + PUBLIC_CONTENTFUL_TOKEN: process.env.PUBLIC_CONTENTFUL_TOKEN, }, turbopack: { root: configDir, diff --git a/implementations/nextjs-sdk_pages-router/README.md b/implementations/nextjs-sdk_pages-router/README.md index 8d2e46df4..4ea956c8e 100644 --- a/implementations/nextjs-sdk_pages-router/README.md +++ b/implementations/nextjs-sdk_pages-router/README.md @@ -18,16 +18,19 @@ Reference implementation for `@contentful/optimization-nextjs` in a Next.js Pages Router application. Pages call `createRequestHandoff()` from `getServerSideProps`, pass the returned -browser handoff through `pageProps`, and mount the bound Pages Router root once in `pages/_app.tsx`. -That callback-enabled root owns its initial and later browser page events. The public permutation -route uses `createPublicPermutationHandoff()` from `getStaticProps` with ISR. +private handoff through `pageProps`, and mount the bound Pages Router root once in `pages/_app.tsx`. +The helper previews the prepared page and configured initial events on the server; after hydration, +the browser admits that replay through its Experience queue. The preview returns selection data for +server rendering but does not commit the profile or events. The public permutation route uses +`createPublicPermutationHandoff()` from `getStaticProps` with ISR and contains no visitor-specific +replay. The implementation binds `OptimizationRoot` and `OptimizedEntry` once in `@/lib/optimization` with `bindNextjsPagesRouterOptimization()`. Browser runtime imports use Next.js SDK package subpaths. The package root is not imported: - `@contentful/optimization-nextjs/pages-router` in `@/lib/optimization` for the bound component - binding and before-initial-page callback + binding and browser page root - `@contentful/optimization-nextjs/pages-router/server` in `@/lib/optimization-server` for `getServerSideProps` request handoff - `@contentful/optimization-nextjs/client` for browser hooks and providers @@ -42,11 +45,10 @@ after hydration. It covers: - App-local bound components from `bindNextjsPagesRouterOptimization()` - Request handoff from `createRequestHandoff()` through `pageProps` - Public permutation handoff from `getStaticProps` with `fallback: false` and `revalidate: 60` -- Query-controlled before-initial-page work in the root-owned browser page flow +- Query-controlled initial identify prepared with the server page event - Root-owned initial and later route tracking without a separate router tracker - Browser-side entry resolution with the app-local `OptimizedEntry` -- `initialPageEvent` ownership from the handoff so the browser skips only when the server request - accepted the first page event +- Browser commitment of the matching prepared replay through the normal Experience queue - Live re-resolution after consent, identify, reset, and client-side route changes - Preview panel attachment behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` @@ -60,19 +62,13 @@ single-locale fields such as `fields.nt_experiences` and `fields.nt_variants`. ## Route strategy Use `getServerSideProps` for pages that need server-personalized first paint. It fetches entries, -calls the Pages Router Optimization helper, and returns both through `props`. `pages/_app.tsx` -passes `pageProps.contentfulOptimization.handoff` to the bound `OptimizationRoot` with the current -`routeKey` and `buildPagePayload`; the handoff carries the first-page-event decision so the browser -does not duplicate an accepted server page event. - -The bound client config uses `beforeInitialPage` to make this root the only browser page owner in -its subtree. The callback returns immediately on ordinary routes. Add -`?beforeInitialPage=readiness` to run the maintained identify-before-page scenario: the callback -returns its `identify()` request, and the root waits for that work before making its direct page -attempt with the latest route and lazy payload. When the attempt finishes, the root activates its -existing page emitter with a non-emitting initial `skip` mark for the attempted route. A later route -change uses the emitter's normal `emit` path. Do not mount `NextPagesAutoPageTracker` beside this -callback-enabled root; that would introduce a second page owner. +calls the Pages Router Optimization helper, and returns the handoff through `props`. +`pages/_app.tsx` passes `pageProps.contentfulOptimization.handoff` to the bound `OptimizationRoot` +with the current route key and page payload. When the handoff has a matching replay, the root +hydrates it and admits the prepared events as one browser queue submission. The query-controlled +`?beforeInitialPage=readiness` route prepares its identify event before the page event; ordinary +routes prepare only the page event. Later client-side route changes continue through the root's +normal route tracking. Use `getStaticProps` and `getStaticPaths` for finite public personalization permutations. The `/selection-handoff/[segment]` route builds a public-permutation handoff with the SDK helper, diff --git a/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts b/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts index 68c7e2e91..53a4d458c 100644 --- a/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts +++ b/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts @@ -4,6 +4,8 @@ import { appConfig } from './config' import type { PagesRouterContentHandoff } from './optimization' import { getAppConsent } from './util' +const BEFORE_INITIAL_PAGE_QUERY_VALUE = 'readiness' + const { createRequestHandoff } = bindNextjsPagesRouterServerOptimization({ spaceId: appConfig.spaceId, environment: appConfig.environment, @@ -60,6 +62,11 @@ export async function getPagesRouterOptimizationProps( const handoff = await createRequestHandoff(context, { cache: { scope: 'private-request' }, hydration: 'preserve-server', + initialEvents: + new URLSearchParams(routeKey.split('?')[1]).get('beforeInitialPage') === + BEFORE_INITIAL_PAGE_QUERY_VALUE + ? [{ type: 'identify', userId: 'charles', traits: { identified: true } }] + : undefined, pagePayload: createRoutePagePayload(routeKey), }) assertContentHandoff(handoff) diff --git a/implementations/nextjs-sdk_pages-router/pages/_app.tsx b/implementations/nextjs-sdk_pages-router/pages/_app.tsx index d283d1473..47b4de0fc 100644 --- a/implementations/nextjs-sdk_pages-router/pages/_app.tsx +++ b/implementations/nextjs-sdk_pages-router/pages/_app.tsx @@ -60,7 +60,7 @@ export default function App({ Home - + Page Two diff --git a/implementations/nextjs-sdk_pages-router/pages/selection-handoff/[segment].tsx b/implementations/nextjs-sdk_pages-router/pages/selection-handoff/[segment].tsx index 03a88da27..567d704a4 100644 --- a/implementations/nextjs-sdk_pages-router/pages/selection-handoff/[segment].tsx +++ b/implementations/nextjs-sdk_pages-router/pages/selection-handoff/[segment].tsx @@ -61,7 +61,6 @@ export const getStaticProps: GetStaticProps = async ({ pa cacheVersion: segment.cacheVersion, entryIds: segment.baselineEntryIds, hydration: 'preserve-server', - initialPageEvent: 'emit', locale: segment.locale, permutationKey: segment.slug, selectedOptimizations: segment.selectedOptimizations.map((selection) => ({ diff --git a/implementations/node-sdk+web-sdk/README.md b/implementations/node-sdk+web-sdk/README.md index 3dfc77c52..5b31503df 100644 --- a/implementations/node-sdk+web-sdk/README.md +++ b/implementations/node-sdk+web-sdk/README.md @@ -27,16 +27,18 @@ Use this implementation when you need a hybrid SSR/browser example. It demonstra SDK server flow, a stateful Web SDK browser flow, consent-aware cookie-based profile continuity between them, and local mock API usage for end-to-end validation. -On the server side, the stateless Node SDK is created once at module load. Each request binds -request-scoped options with `sdk.forRequest(...)`, then calls stateless event methods on the -returned request object. The demo stores application-owned consent in a server-readable cookie and -writes the shared anonymous ID cookie only when consent permits profile continuity. When app consent -is missing or denied, the server clears the shared anonymous ID cookie, skips Node SDK event calls, -and lets the browser render baseline entries. - -The goal of this reference implementation is to illustrate the usage of cookie-based communication -in both the Node and Web SDKs, which is an important component of many server-side/client-side -hybrid SSR and ESR solutions. +On the server side, the stateless Node SDK is created once at module load. With application consent, +each request binds its request context with `sdk.forRequest(...)` and calls +`prepareRequestHandoff()`. The Experience API previews the prepared events and returns data for SSR; +the handoff carries the event batch for the browser Web SDK to admit to its normal Experience queue. +This keeps the server preview from committing the page or profile changes. The reference stores +application-owned consent in a server-readable cookie and writes the shared anonymous ID cookie only +when persistence consent allows profile continuity. The ID comes from the Experience API; the SDK +does not manufacture one. When app consent is missing or denied, the server clears the shared +anonymous ID cookie, makes no Optimization request, and renders baseline entries. + +This reference implementation illustrates cookie-based communication between the Node and Web SDKs +in hybrid SSR and ESR solutions. > [!NOTE] > diff --git a/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants-cookie.spec.ts b/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants-cookie.spec.ts index 10b6a50be..24f9f7fa5 100644 --- a/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants-cookie.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants-cookie.spec.ts @@ -1,13 +1,28 @@ -import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' -import { expect, test } from '@playwright/test' +import { expect, test, type BrowserContext, type Page } from '@playwright/test' import { getAnonymousIdFromCookie, getAnonymousIdFromStorage } from './utils' -const CUSTOM_PROFILE_ID = 'custom-profile-id' const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' -test.describe('identified user: cookie', () => { - test.beforeEach(async ({ page, context }) => { - // user is already identified with a custom profile id +async function visitReturningUser(page: Page, context: BrowserContext): Promise { + const delivery = page.waitForResponse( + (response) => + response.url().includes('/experience/') && + response.request().method() === 'POST' && + response.ok(), + ) + await page.goto('/') + await expect.poll(async () => await getAnonymousIdFromCookie(context)).toBeTruthy() + const initialProfileId = await getAnonymousIdFromCookie(context) + if (initialProfileId === undefined) throw new Error('The first visit did not issue a profile ID.') + await delivery + + await page.goto('/user/someone') + await page.waitForLoadState('domcontentloaded') + return initialProfileId +} + +test.describe('identified returning user: cookie', () => { + test.beforeEach(async ({ context }) => { await context.addCookies([ { name: APP_PERSONALIZATION_CONSENT_COOKIE, @@ -16,33 +31,24 @@ test.describe('identified user: cookie', () => { path: '/', sameSite: 'Lax', }, - { - name: ANONYMOUS_ID_COOKIE, - value: CUSTOM_PROFILE_ID, - domain: 'localhost', - path: '/', - sameSite: 'Lax', // good default for same-site apps - }, ]) - await page.goto(`/user/someone`) - await page.waitForLoadState('domcontentloaded') }) - test('should preserve custom profile id in cookie', async ({ context }) => { - const cookieId = await getAnonymousIdFromCookie(context) - expect(cookieId).toBeDefined() - expect(cookieId).toEqual(CUSTOM_PROFILE_ID) + test('preserves the API-issued profile ID on the next visit', async ({ context, page }) => { + const initialProfileId = await visitReturningUser(page, context) + await expect.poll(async () => await getAnonymousIdFromCookie(context)).toBe(initialProfileId) }) - test('should sync profile id between cookie and localStorage', async ({ context }) => { - const cookieId = await getAnonymousIdFromCookie(context) - const storedId = await getAnonymousIdFromStorage(context) - - expect(storedId).toBeDefined() - expect(storedId).toEqual(cookieId) + test('syncs the returned profile ID between cookie and localStorage', async ({ + context, + page, + }) => { + const initialProfileId = await visitReturningUser(page, context) + await expect.poll(async () => await getAnonymousIdFromStorage(context)).toBe(initialProfileId) }) - test('displays common variants', async ({ page }) => { + test('displays common variants', async ({ context, page }) => { + await visitReturningUser(page, context) await expect( page.getByText( 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', @@ -58,7 +64,8 @@ test.describe('identified user: cookie', () => { ).toBeVisible() }) - test('displays identified user variants', async ({ page }) => { + test('displays identified user variants', async ({ context, page }) => { + await visitReturningUser(page, context) await expect(page.getByText('This is a level 0 nested variant entry.')).toBeVisible() await expect(page.getByText('This is a level 1 nested variant entry.')).toBeVisible() diff --git a/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants.spec.ts b/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants.spec.ts index 1fedf127d..f9f694160 100644 --- a/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/displays-identified-user-variants.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Route } from '@playwright/test' import { getAnonymousIdFromCookie, getAnonymousIdFromStorage } from './utils' const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' @@ -71,3 +71,81 @@ test.describe('identified user', () => { ).toBeVisible() }) }) + +test('commits a prepared identified visit in the browser without a duplicate page', async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: APP_PERSONALIZATION_CONSENT_COOKIE, + value: 'granted', + domain: 'localhost', + path: '/', + sameSite: 'Lax', + }, + ]) + const delivery = page.waitForResponse( + (response) => + response.url().includes('/experience/') && + response.request().method() === 'POST' && + response.ok(), + ) + + await page.goto('/user/someone') + await expect( + page.getByText('This is a variant content entry for identified users.'), + ).toBeVisible() + await delivery + await expect( + page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'page' }) }), + ).toHaveCount(1) +}) + +test('keeps personalized content visible when browser delivery is delayed and fails', async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: APP_PERSONALIZATION_CONSENT_COOKIE, + value: 'granted', + domain: 'localhost', + path: '/', + sameSite: 'Lax', + }, + ]) + let release = (): void => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + let held = false + let failed = false + const routeHandler = async (route: Route): Promise => { + if (held || route.request().method() !== 'POST') { + await route.continue() + return + } + held = true + await gate + await route.abort('failed') + failed = true + } + await page.route('**/experience/**', routeHandler) + + try { + await page.goto('/user/someone', { waitUntil: 'domcontentloaded' }) + await expect.poll(() => held).toBe(true) + const identifiedContent = page.getByText( + 'This is a variant content entry for identified users.', + ) + await expect(identifiedContent).toBeVisible() + + release() + await expect.poll(() => failed).toBe(true) + await expect(identifiedContent).toBeVisible() + } finally { + release() + await page.unroute('**/experience/**', routeHandler) + } +}) diff --git a/implementations/node-sdk+web-sdk/e2e/offline-admission.spec.ts b/implementations/node-sdk+web-sdk/e2e/offline-admission.spec.ts new file mode 100644 index 000000000..05a7056c3 --- /dev/null +++ b/implementations/node-sdk+web-sdk/e2e/offline-admission.spec.ts @@ -0,0 +1,110 @@ +import type ContentfulOptimization from '@contentful/optimization-web' +import { expect, test } from '@playwright/test' + +interface OptimizationWindow { + navigator: { onLine: boolean } + contentfulOptimization: ContentfulOptimization + flushCompletions?: number + pendingFlushes?: Promise +} + +declare const window: OptimizationWindow + +test.beforeEach(async ({ context, page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Accept Consent' }).click() + await expect(page.getByRole('button', { name: 'Reject Consent' })).toBeVisible() + await page.waitForLoadState('networkidle') + await page.evaluate(async () => { + await window.contentfulOptimization.flush() + }) + await context.setOffline(true) + await expect.poll(async () => await page.evaluate(() => window.navigator.onLine)).toBe(false) +}) + +test('refuses incoming offline events at the default capacity', async ({ page }) => { + const results = await page.evaluate(async () => { + const { contentfulOptimization: sdk } = window + const accepted: boolean[] = [] + for (let index = 0; index < 101; index += 1) { + accepted.push((await sdk.track({ event: `offline-capacity-${index}` })).accepted) + } + return accepted + }) + expect(results).toEqual([...Array.from({ length: 100 }, () => true), false]) +}) + +test('preserves offline event order and joins the active public flush', async ({ + context, + page, +}) => { + const events = ['offline-first', 'offline-second'] + expect( + await page.evaluate(async (names) => { + const accepted: boolean[] = [] + for (const event of names) { + accepted.push((await window.contentfulOptimization.track({ event })).accepted) + } + return accepted + }, events), + ).toEqual([true, true]) + + let release = (): void => undefined + const responseGate = new Promise((resolve) => { + release = resolve + }) + const requests: string[][] = [] + await page.route('**/experience/**', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue() + return + } + const payload: unknown = route.request().postDataJSON() + if (typeof payload !== 'object' || payload === null || !('events' in payload)) { + throw new Error('Expected an Experience request containing events.') + } + if (!Array.isArray(payload.events)) throw new Error('Expected an events array.') + const names: string[] = [] + for (const event of payload.events) { + if ( + typeof event === 'object' && + event !== null && + 'event' in event && + typeof event.event === 'string' + ) { + names.push(event.event) + } + } + requests.push(names) + await responseGate + await route.continue() + }) + + try { + await context.setOffline(false) + await expect.poll(() => requests.length).toBe(1) + await page.evaluate(() => { + const target = window + target.flushCompletions = 0 + target.pendingFlushes = Promise.all( + [target.contentfulOptimization.flush(), target.contentfulOptimization.flush()].map( + async (attempt) => { + await attempt + target.flushCompletions = (target.flushCompletions ?? 0) + 1 + }, + ), + ).then(() => undefined) + }) + expect(await page.evaluate(() => window.flushCompletions)).toBe(0) + expect(requests).toEqual([events]) + release() + await page.evaluate(async () => { + await window.pendingFlushes + }) + expect(await page.evaluate(() => window.flushCompletions)).toBe(2) + expect(requests).toHaveLength(1) + } finally { + release() + await context.setOffline(false) + } +}) diff --git a/implementations/node-sdk+web-sdk/package.json b/implementations/node-sdk+web-sdk/package.json index 04f41c6b0..328670b1a 100644 --- a/implementations/node-sdk+web-sdk/package.json +++ b/implementations/node-sdk+web-sdk/package.json @@ -11,12 +11,13 @@ "build:sdk": "mkdir -p ./public/dist && cp -r ./node_modules/@contentful/optimization-web/dist/. ./public/dist && cp ./node_modules/@contentful/optimization-web-preview-panel/dist/contentful-optimization-web-preview-panel.umd.js* ./public/dist", "clean": "rimraf ./.rsdoctor ./.rslib ./dist ./public/dist ./coverage ./playwright-report ./test-results .tsbuildinfo", "serve": "pnpm serve:mocks && pnpm serve:app", + "serve:e2e": "pnpm build && tsx --env-file=.env ./src/app.ts", "serve:app": "pnpm build && pm2 start --name esr-app \"tsx --env-file=.env ./src/app.ts\"", "serve:app:stop": "pm2 stop esr-app && pm2 delete esr-app", "serve:mocks": "pm2 start --name esr-mocks \"pnpm --dir ../.. serve:mocks\"", "serve:mocks:stop": "pm2 stop esr-mocks && pm2 delete esr-mocks", "serve:stop": "pnpm serve:app:stop; pnpm serve:mocks:stop", - "test:e2e": "sh -c 'pnpm serve && playwright test \"$@\"; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT' --", + "test:e2e": "playwright test", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", "test:e2e:ui": "playwright test --ui", diff --git a/implementations/node-sdk+web-sdk/playwright.config.mjs b/implementations/node-sdk+web-sdk/playwright.config.mjs index ecbcccc2c..304683e51 100644 --- a/implementations/node-sdk+web-sdk/playwright.config.mjs +++ b/implementations/node-sdk+web-sdk/playwright.config.mjs @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename) dotenv.config({ path: path.resolve(__dirname, '.env') }) const isCI = Boolean(process.env.CI) +const APP_URL = 'http://localhost:3000' /** * See https://playwright.dev/docs/test-configuration. @@ -27,7 +28,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:3000', + baseURL: APP_URL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -71,10 +72,24 @@ export default defineConfig({ // }, ], - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'pnpm --filter e2e serve', - // url: 'http://localhost', - // reuseExistingServer: !isCI, - // }, + webServer: [ + { + name: 'Mocks', + command: 'pnpm --dir ../.. serve:mocks', + url: 'http://localhost:8000/health', + reuseExistingServer: true, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + { + name: 'App', + command: 'pnpm serve:e2e', + url: `${APP_URL}/health`, + reuseExistingServer: true, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, + ], }) diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index 13f006419..85bc76597 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -2,7 +2,7 @@ import ContentfulOptimization from '@contentful/optimization-node' import type { OptimizationData } from '@contentful/optimization-node/api-schemas' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import type { - EventEmissionResult, + OptimizationHandoff, UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' import cookieParser from 'cookie-parser' @@ -18,6 +18,9 @@ const limiter = rateLimit({ }) const app: Express = express() +app.get('/health', (_req, res) => { + res.type('text/plain').send('ok') +}) app.use(cookieParser()) app.use(limiter) const APP_LOCALE = 'en-US' @@ -59,11 +62,13 @@ type QsArray = QsPrimitive[] // Note: mixed arrays are allowed by ParsedQs type QsValue = QsPrimitive | QsArray | undefined interface ProfileResult { readonly appLocale: string + readonly handoff?: OptimizationHandoff readonly optimizationData: OptimizationData | undefined } interface RenderResponseOptions { readonly appConsent: boolean | undefined readonly appLocale: string + readonly handoff?: OptimizationHandoff readonly id?: string readonly optimizationData?: OptimizationData readonly userId?: string @@ -129,13 +134,9 @@ function getAppConsentFromCookies(cookies: unknown): boolean | undefined { return undefined } -function getAcceptedOptimizationData(result: EventEmissionResult): OptimizationData | undefined { - return result.accepted ? result.data : undefined -} - function respond( res: Response, - { appConsent, appLocale, id, optimizationData, userId }: RenderResponseOptions, + { appConsent, appLocale, handoff, id, optimizationData, userId }: RenderResponseOptions, ): void { if (appConsent === true && id) { res.cookie(ANONYMOUS_ID_COOKIE, id, { @@ -150,6 +151,7 @@ function respond( config, appConsent: appConsent ?? null, appLocale, + optimizationHandoff: handoff ?? null, identified: userId, optimizationData: optimizationData ?? null, }) @@ -177,32 +179,29 @@ async function getProfile( profile: cookieProfile, }) - if (!userId) { - return { - appLocale: APP_LOCALE, - optimizationData: getAcceptedOptimizationData(await requestOptimization.page()), - } - } - - await requestOptimization.identify({ - userId, - traits: { identified: true }, + const { data, handoff } = await requestOptimization.prepareRequestHandoff({ + routeKey: req.originalUrl, + initialEvents: userId + ? [{ type: 'identify', userId, traits: { identified: true } }] + : undefined, }) return { appLocale: APP_LOCALE, - optimizationData: getAcceptedOptimizationData(await requestOptimization.page()), + handoff, + optimizationData: data, } } app.get('/', limiter, async (req, res) => { const appConsent = getAppConsentFromCookies(req.cookies) - const { appLocale, optimizationData } = await getProfile(req, appConsent) + const { appLocale, handoff, optimizationData } = await getProfile(req, appConsent) respond(res, { appConsent, appLocale, - id: optimizationData?.profile.id, + handoff, + id: optimizationData?.profile.id ?? handoff?.profileId, optimizationData, }) }) @@ -211,6 +210,7 @@ app.get('/smoke-test', limiter, (_, res) => { appConsent: null, config, appLocale: APP_LOCALE, + optimizationHandoff: null, optimizationData: null, }) }) @@ -218,12 +218,18 @@ app.get('/user/:id', limiter, async (req, res) => { const anonymousId = getAnonymousIdFromCookies(req.cookies) const appConsent = getAppConsentFromCookies(req.cookies) const userId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id - const { appLocale, optimizationData } = await getProfile(req, appConsent, userId, anonymousId) + const { appLocale, handoff, optimizationData } = await getProfile( + req, + appConsent, + userId, + anonymousId, + ) respond(res, { appConsent, appLocale, - id: optimizationData?.profile.id, + handoff, + id: optimizationData?.profile.id ?? handoff?.profileId, optimizationData, userId, }) diff --git a/implementations/node-sdk+web-sdk/src/index.ejs b/implementations/node-sdk+web-sdk/src/index.ejs index 150168590..ea408c57f 100644 --- a/implementations/node-sdk+web-sdk/src/index.ejs +++ b/implementations/node-sdk+web-sdk/src/index.ejs @@ -200,7 +200,8 @@ const CONFIG = <%- JSON.stringify(config) %> const APP_LOCALE = <%- JSON.stringify(appLocale) %> const SERVER_APP_CONSENT = <%- JSON.stringify(appConsent) %> - const SERVER_OPTIMIZATION_DATA = <%- JSON.stringify(optimizationData) %> + const SERVER_OPTIMIZATION_DATA = <%- JSON.stringify(optimizationData).replaceAll('<', '\\u003c') %> + const SERVER_OPTIMIZATION_HANDOFF = <%- JSON.stringify(optimizationHandoff).replaceAll('<', '\\u003c') %> diff --git a/packages/web/web-sdk/dev/rsbuild.config.ts b/packages/web/web-sdk/dev/rsbuild.config.ts index dabfe0e83..f4a123b0a 100644 --- a/packages/web/web-sdk/dev/rsbuild.config.ts +++ b/packages/web/web-sdk/dev/rsbuild.config.ts @@ -51,6 +51,12 @@ export default defineConfig({ templateParameters: env, }, + server: { + proxy: { + '/__handoff': { target: process.env.HANDOFF_SERVER_URL ?? 'http://localhost:3001' }, + }, + }, + output: { target: 'web', }, diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index fa50bb739..065f3862d 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -151,24 +151,24 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "contentful-optimization-web.umd.js": 36600, - "index.cjs": 11800, - "index.mjs": 12400, + "contentful-optimization-web.umd.js": 38900, + "index.cjs": 12400, + "index.mjs": 12700, "bridge-support.cjs": 700, "bridge-support.mjs": 300, - "runtime.cjs": 1000, + "runtime.cjs": 1100, "runtime.mjs": 500, - "handoff.cjs": 1700, - "handoff.mjs": 1600, - "analytics.cjs": 12900, - "analytics.mjs": 14100, - "contentful-optimization-web-components.umd.js": 42100, + "handoff.cjs": 1600, + "handoff.mjs": 1200, + "analytics.cjs": 12700, + "analytics.mjs": 13000, + "contentful-optimization-web-components.umd.js": 44400, "presentation.cjs": 3600, - "presentation.mjs": 3500, + "presentation.mjs": 3300, "tracking-attributes.cjs": 1000, - "tracking-attributes.mjs": 700, - "web-components.cjs": 17200, - "web-components.mjs": 18700 + "tracking-attributes.mjs": 600, + "web-components.cjs": 17700, + "web-components.mjs": 18800 } } }, diff --git a/packages/web/web-sdk/src/ContentfulOptimization.identity-lifecycle.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.identity-lifecycle.test.ts new file mode 100644 index 000000000..f6a134450 --- /dev/null +++ b/packages/web/web-sdk/src/ContentfulOptimization.identity-lifecycle.test.ts @@ -0,0 +1,431 @@ +import { + batch, + signals, + type EventEmissionResult, + type OptimizationHandoff, +} from '@contentful/optimization-core' +import type { + ExperienceEvent, + OptimizationData, + Profile, +} from '@contentful/optimization-core/api-schemas' +import { ANONYMOUS_ID_COOKIE } from './constants' +import ContentfulOptimization from './ContentfulOptimization' +import { getCookie, removeCookie } from './lib/cookies' +import LocalStore from './storage/LocalStore' +import { deferred } from './test/helpers' + +const config = { + spaceId: 'key_123', + environment: 'main', + defaults: { consent: true, persistenceConsent: true }, +} + +function createProfile(id: string): Profile { + return { + id, + stableId: id, + random: 1, + audiences: [], + traits: {}, + location: {}, + session: { + id: `${id}-session`, + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + } +} + +function handoff( + sdk: ContentfulOptimization, + profileId: string, + routeKey: string, + state?: OptimizationHandoff['state'], +): OptimizationHandoff { + return { + cache: { scope: 'private-request' }, + profileId, + state, + replay: { routeKey, events: [sdk.eventBuilder.buildPageView()] }, + } +} + +class DeferredPreparedAdmission extends ContentfulOptimization { + readonly admissionStarted = deferred() + readonly releaseAdmission = deferred() + + protected override async sendPreparedExperienceEvents( + events: readonly ExperienceEvent[], + profileId?: string, + locale?: string, + ): Promise { + const result = await super.sendPreparedExperienceEvents(events, profileId, locale) + this.admissionStarted.resolve() + await this.releaseAdmission.promise + return result + } +} + +function resetSignals(): void { + batch(() => { + signals.blockedEvent.value = undefined + signals.changes.value = undefined + signals.consent.value = undefined + signals.event.value = undefined + signals.experienceRequestState.value = { status: 'idle' } + signals.locale.value = undefined + signals.online.value = true + signals.persistenceConsent.value = undefined + signals.previewPanelAttached.value = false + signals.previewPanelOpen.value = false + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + }) +} + +describe('Web handoff identity lifecycle', () => { + beforeEach(() => { + delete window.contentfulOptimization + localStorage.clear() + removeCookie(ANONYMOUS_ID_COOKIE) + resetSignals() + }) + + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + rs.restoreAllMocks() + }) + + it('validates cache safety before replacing the current attempt or publishing identity', async () => { + const sdk = new ContentfulOptimization(config) + rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('valid-id'), + changes: [], + selectedOptimizations: [], + }) + const pendingHydration = deferred() + const hydrationStarted = deferred() + sdk.interceptors.state.add(async (state) => { + hydrationStarted.resolve() + await pendingHydration.promise + return state + }) + const valid = handoff(sdk, 'valid-id', '/valid', { changes: [] }) + const initial = sdk.hydrateAndTrackCurrentPage(valid, { routeKey: '/valid' }) + await hydrationStarted.promise + + await expect( + sdk.hydrateAndTrackCurrentPage( + { cache: { scope: 'static' }, profileId: 'unsafe-id' }, + { routeKey: '/unsafe' }, + ), + ).rejects.toThrow('Profile state, identity and replay must not be included') + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('valid-id') + + pendingHydration.resolve() + expect(await initial).toEqual({ accepted: true }) + }) + + it('adopts nothing when superseded before its attempt starts', async () => { + const sdk = new ContentfulOptimization(config) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('new-id'), + changes: [], + selectedOptimizations: [], + }) + const old = sdk.hydrateAndTrackCurrentPage(handoff(sdk, 'old-id', '/old'), { + routeKey: '/old', + }) + const current = sdk.hydrateAndTrackCurrentPage(handoff(sdk, 'new-id', '/new'), { + routeKey: '/new', + }) + + expect(await old).toEqual({ accepted: false }) + expect(await current).toEqual({ accepted: true }) + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0]?.[0].profileId).toBe('new-id') + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('new-id') + }) + + it('publishes the selected identity before hydration, with live continuity taking precedence', async () => { + const sdk = new ContentfulOptimization(config) + signals.profile.value = createProfile('live-id') + const waiting = deferred() + const started = deferred() + sdk.interceptors.state.add(async (state) => { + started.resolve() + await waiting.promise + return state + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('live-id'), + changes: [], + selectedOptimizations: [], + }) + const initialization = sdk.hydrateAndTrackCurrentPage( + handoff(sdk, 'candidate-id', '/current', { changes: [] }), + { routeKey: '/current' }, + ) + await started.promise + + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('live-id') + expect(deliver).not.toHaveBeenCalled() + waiting.resolve() + expect(await initialization).toEqual({ accepted: true }) + expect(deliver.mock.calls[0]?.[0].profileId).toBe('live-id') + }) + + it('keeps the published identity across navigation while suppressing obsolete hydration and replay', async () => { + const sdk = new ContentfulOptimization(config) + const waiting = deferred() + const started = deferred() + sdk.interceptors.state.add(async (state) => { + if (state.profile?.id === 'old-preview') { + started.resolve() + await waiting.promise + } + return state + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('response-id'), + changes: [], + selectedOptimizations: [], + }) + const old = sdk.hydrateAndTrackCurrentPage( + handoff(sdk, 'issued-id', '/old', { profile: createProfile('old-preview') }), + { routeKey: '/old' }, + ) + await started.promise + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('issued-id') + + await sdk.trackCurrentPage({ routeKey: '/new', buildPayload: () => ({}) }) + waiting.resolve() + expect(await old).toEqual({ accepted: false }) + expect(sdk.states.profile.current?.id).not.toBe('old-preview') + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0]?.[0].profileId).toBe('issued-id') + }) + + it('carries A identity through an ordinary event into B after A hydration is superseded', async () => { + const sdk = new ContentfulOptimization(config) + const releaseAHydration = deferred() + const aHydrationStarted = deferred() + sdk.interceptors.state.add(async (state) => { + if (state.profile?.id === 'a-preview') { + aHydrationStarted.resolve() + await releaseAHydration.promise + } + return state + }) + const ordinaryResponse = deferred() + const replayResponse = deferred() + const ordinaryRequestStarted = deferred() + const replayRequestStarted = deferred() + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockImplementationOnce(async () => { + ordinaryRequestStarted.resolve() + return await ordinaryResponse.promise + }) + .mockImplementationOnce(async () => { + replayRequestStarted.resolve() + return await replayResponse.promise + }) + + const a = sdk.hydrateAndTrackCurrentPage( + handoff(sdk, 'a-issued', '/a', { profile: createProfile('a-preview') }), + { routeKey: '/a' }, + ) + await aHydrationStarted.promise + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('a-issued') + + const ordinary = sdk.track({ event: 'between-handoffs' }) + await ordinaryRequestStarted.promise + expect(deliver.mock.calls[0]?.[0].profileId).toBe('a-issued') + expect(sdk.states.profile.current).toBeUndefined() + + const b = sdk.hydrateAndTrackCurrentPage(handoff(sdk, 'b-candidate', '/b'), { + routeKey: '/b', + }) + expect(await b).toEqual({ accepted: true }) + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('a-issued') + await replayRequestStarted.promise + expect(deliver.mock.calls[1]?.[0].profileId).toBe('a-issued') + expect(deliver.mock.calls[1]?.[0].events.map(({ type }) => type)).toEqual(['page']) + + releaseAHydration.resolve() + expect(await a).toEqual({ accepted: false }) + expect(sdk.states.profile.current).toBeUndefined() + expect(deliver).toHaveBeenCalledTimes(2) + + const data = { profile: createProfile('a-issued'), changes: [], selectedOptimizations: [] } + ordinaryResponse.resolve(data) + replayResponse.resolve(data) + expect((await ordinary).accepted).toBe(true) + await sdk.flush() + }) + + it('keeps admitted replay in Core delivery after navigation', async () => { + const sdk = new ContentfulOptimization(config) + const response = deferred() + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const prepared = handoff(sdk, 'issued-id', '/first') + + expect(await sdk.hydrateAndTrackCurrentPage(prepared, { routeKey: '/first' })).toEqual({ + accepted: true, + }) + expect(deliver).toHaveBeenCalledTimes(1) + const admittedEvents = deliver.mock.calls[0]?.[0].events + window.dispatchEvent(new Event('offline')) + await sdk.trackCurrentPage({ routeKey: '/second', buildPayload: () => ({}) }) + expect(deliver.mock.calls[0]?.[0].events).toBe(admittedEvents) + expect(deliver.mock.calls[0]?.[0].profileId).toBe('issued-id') + + response.resolve({ + profile: createProfile('response-id'), + changes: [], + selectedOptimizations: [], + }) + await sdk.flush() + expect(sdk.states.profile.current?.id).toBe('response-id') + }) + + it('uses adopted continuity for replay while hydrating a distinct preview profile', async () => { + const sdk = new ContentfulOptimization({ + ...config, + getAnonymousId: () => 'adopted-id', + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('response-id'), + changes: [], + selectedOptimizations: [], + }) + const pendingResponse = deferred() + deliver.mockReturnValue(pendingResponse.promise) + + const prepared = handoff(sdk, 'candidate-id', '/current', { + profile: createProfile('preview-id'), + }) + expect(await sdk.hydrateAndTrackCurrentPage(prepared, { routeKey: '/current' })).toEqual({ + accepted: true, + }) + expect(sdk.states.profile.current?.id).toBe('preview-id') + expect(deliver.mock.calls[0]?.[0].profileId).toBe('adopted-id') + pendingResponse.resolve({ + profile: createProfile('response-id'), + changes: [], + selectedOptimizations: [], + }) + await sdk.flush() + }) + + it('shares repeated current work and settles admission before replay HTTP completes', async () => { + const sdk = new ContentfulOptimization(config) + const response = deferred() + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const prepared = handoff(sdk, 'issued-id', '/current') + const first = sdk.hydrateAndTrackCurrentPage(prepared, { routeKey: '/current' }) + const repeated = sdk.hydrateAndTrackCurrentPage({ ...prepared }, { routeKey: '/current' }) + + expect(await first).toEqual({ accepted: true }) + expect(await repeated).toEqual({ accepted: true }) + expect(deliver).toHaveBeenCalledTimes(1) + expect(sdk.states.profile.current).toBeUndefined() + response.resolve({ + profile: createProfile('response-id'), + changes: [], + selectedOptimizations: [], + }) + await sdk.flush() + }) + + it.each(['supersession', 'reset', 'destroy'] as const)( + 'suppresses fallback when %s invalidates initialization during prepared admission', + async (action) => { + const sdk = new DeferredPreparedAdmission({ ...config, queuePolicy: { offlineMaxEvents: 1 } }) + window.dispatchEvent(new Event('offline')) + const ordinaryPage = rs.spyOn(sdk, 'page') + const currentPage = rs.spyOn(sdk, 'trackCurrentPage') + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('issued-id'), + changes: [], + selectedOptimizations: [], + }) + const prepared = handoff(sdk, 'issued-id', '/stale') + const initialization = sdk.hydrateAndTrackCurrentPage( + { + ...prepared, + replay: { + routeKey: '/stale', + events: [ + sdk.eventBuilder.buildTrack({ event: 'prepared' }), + sdk.eventBuilder.buildPageView(), + ], + }, + }, + { routeKey: '/stale' }, + ) + await sdk.admissionStarted.promise + if (action === 'supersession') { + expect( + await sdk.hydrateAndTrackCurrentPage( + { cache: { scope: 'static' } }, + { + routeKey: '/current', + buildPayload: () => ({ properties: { title: 'current' } }), + }, + ), + ).toEqual({ accepted: true }) + } else sdk[action]() + sdk.releaseAdmission.resolve() + expect(await initialization).toEqual({ accepted: false }) + expect(currentPage).toHaveBeenCalledTimes(action === 'supersession' ? 1 : 0) + expect(ordinaryPage).toHaveBeenCalledTimes(action === 'supersession' ? 1 : 0) + if (action === 'supersession') { + expect(ordinaryPage).toHaveBeenCalledExactlyOnceWith({ properties: { title: 'current' } }) + window.dispatchEvent(new Event('online')) + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(1) + } else expect(deliver).not.toHaveBeenCalled() + }, + ) + + it.each(['reset', 'destroy'] as const)( + 'invalidates pending hydration and replay on %s', + async (method) => { + const sdk = new ContentfulOptimization(config) + const waiting = deferred() + const started = deferred() + sdk.interceptors.state.add(async (state) => { + started.resolve() + await waiting.promise + return state + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile') + const initialization = sdk.hydrateAndTrackCurrentPage( + handoff(sdk, 'issued-id', '/current', { changes: [] }), + { routeKey: '/current' }, + ) + await started.promise + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('issued-id') + + sdk[method]() + waiting.resolve() + expect(await initialization).toEqual({ accepted: false }) + expect(deliver).not.toHaveBeenCalled() + if (method === 'reset') expect(LocalStore.anonymousId).toBeUndefined() + }, + ) +}) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.profile-revision.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.profile-revision.test.ts new file mode 100644 index 000000000..d2ebd8331 --- /dev/null +++ b/packages/web/web-sdk/src/ContentfulOptimization.profile-revision.test.ts @@ -0,0 +1,240 @@ +import { batch, signals } from '@contentful/optimization-core' +import type { OptimizationData, Profile } from '@contentful/optimization-core/api-schemas' +import ContentfulOptimization from './ContentfulOptimization' +import { ANONYMOUS_ID_COOKIE } from './constants' +import { removeCookie } from './lib/cookies' +import { deferred } from './test/helpers' + +const config = { + spaceId: 'key_123', + environment: 'main', + defaults: { consent: true, persistenceConsent: true }, +} + +function response(id: string, selectionId = id): OptimizationData { + const profile: Profile = { + id, + stableId: id, + random: 1, + audiences: [], + traits: {}, + location: {}, + session: { + id: `${id}-session`, + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + } + return { + profile, + changes: [], + selectedOptimizations: [ + { experienceId: selectionId, variantIndex: 1, variants: {}, sticky: false }, + ], + } +} + +describe('Web Experience profile publication', () => { + beforeEach(() => { + delete window.contentfulOptimization + localStorage.clear() + removeCookie(ANONYMOUS_ID_COOKIE) + batch(() => { + signals.blockedEvent.value = undefined + signals.changes.value = undefined + signals.consent.value = undefined + signals.event.value = undefined + signals.experienceRequestState.value = { status: 'idle' } + signals.locale.value = undefined + signals.online.value = true + signals.persistenceConsent.value = undefined + signals.previewPanelAttached.value = false + signals.previewPanelOpen.value = false + signals.profile.value = undefined + signals.selectedOptimizations.value = undefined + }) + }) + + afterEach(() => { + window.contentfulOptimization?.destroy() + delete window.contentfulOptimization + rs.restoreAllMocks() + }) + + it('publishes an ordinary Experience response when its profile is current', async () => { + const sdk = new ContentfulOptimization(config) + const current = response('current') + rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue(current) + + expect(await sdk.track({ event: 'ordinary' })).toMatchObject({ accepted: true }) + expect(sdk.states.profile.current).toEqual(current.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(current.selectedOptimizations) + expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) + }) + + it('does not publish a delayed response over a newer ordinary Experience response', async () => { + const sdk = new ContentfulOptimization(config) + const oldResponse = deferred() + const oldStarted = deferred() + const oldData = response('old') + const newer = response('newer') + const interceptedProfiles: string[] = [] + sdk.interceptors.state.add((state) => { + if (state.profile) interceptedProfiles.push(state.profile.id) + return state + }) + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockImplementationOnce(async () => { + oldStarted.resolve() + return await oldResponse.promise + }) + .mockResolvedValueOnce(newer) + + const old = sdk.track({ event: 'old' }) + await oldStarted.promise + expect(await sdk.track({ event: 'newer' })).toMatchObject({ accepted: true }) + expect(deliver).toHaveBeenCalledTimes(2) + expect(sdk.states.profile.current).toEqual(newer.profile) + + oldResponse.resolve(oldData) + expect(await old).toEqual({ accepted: true, data: oldData }) + expect(interceptedProfiles).toEqual(['newer', 'old']) + expect(sdk.states.profile.current).toEqual(newer.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) + }) + + it('keeps newer selections when overlapping responses contain the same profile', async () => { + const sdk = new ContentfulOptimization(config) + const oldResponse = deferred() + const oldStarted = deferred() + const initial = response('shared', 'initial-selection') + const oldData = response('shared', 'old-selection') + const newer = response('shared', 'newer-selection') + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockResolvedValueOnce(initial) + .mockImplementationOnce(async () => { + oldStarted.resolve() + return await oldResponse.promise + }) + .mockResolvedValueOnce(newer) + + expect(await sdk.track({ event: 'initial' })).toEqual({ accepted: true, data: initial }) + expect(sdk.states.profile.current).toEqual(initial.profile) + const old = sdk.track({ event: 'old' }) + await oldStarted.promise + expect(await sdk.track({ event: 'newer' })).toEqual({ accepted: true, data: newer }) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + + oldResponse.resolve(oldData) + expect(await old).toEqual({ accepted: true, data: oldData }) + expect(deliver).toHaveBeenCalledTimes(3) + expect(sdk.states.profile.current).toEqual(newer.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + }) + + it('suppresses an entire response when newer profile publication wins during a state interceptor', async () => { + const sdk = new ContentfulOptimization(config) + const oldStateStarted = deferred() + const releaseOldState = deferred() + sdk.interceptors.state.add(async (state) => { + if (state.profile?.id === 'old') { + oldStateStarted.resolve() + await releaseOldState.promise + } + return state + }) + const newer = response('newer') + const oldData = response('old') + rs.spyOn(sdk.api.experience, 'upsertProfile') + .mockResolvedValueOnce(oldData) + .mockResolvedValueOnce(newer) + + const old = sdk.track({ event: 'old' }) + await oldStateStarted.promise + expect(await sdk.track({ event: 'newer' })).toMatchObject({ accepted: true }) + expect(sdk.states.profile.current).toEqual(newer.profile) + + releaseOldState.resolve() + expect(await old).toEqual({ accepted: true, data: oldData }) + expect(sdk.states.profile.current).toEqual(newer.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) + }) + + it('does not publish a response whose ordinary event interceptor preceded newer profile publication', async () => { + const sdk = new ContentfulOptimization(config) + const oldEventStarted = deferred() + const releaseOldEvent = deferred() + let interceptedCount = 0 + sdk.interceptors.event.add(async (event) => { + interceptedCount += 1 + if (interceptedCount === 1) { + oldEventStarted.resolve() + await releaseOldEvent.promise + } + return event + }) + const oldData = response('old') + const newer = response('newer') + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockResolvedValueOnce(newer) + .mockResolvedValueOnce(oldData) + + const old = sdk.track({ event: 'old' }) + await oldEventStarted.promise + expect(await sdk.track({ event: 'newer' })).toEqual({ accepted: true, data: newer }) + expect(sdk.states.profile.current).toEqual(newer.profile) + + releaseOldEvent.resolve() + expect(await old).toEqual({ accepted: true, data: oldData }) + expect(deliver).toHaveBeenCalledTimes(2) + expect(sdk.states.profile.current).toEqual(newer.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + }) + + it('drains an accepted prepared delivery after a newer ordinary response publishes', async () => { + const sdk = new ContentfulOptimization(config) + const preparedResponse = deferred() + const preparedStarted = deferred() + const newer = response('newer') + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockImplementationOnce(async () => { + preparedStarted.resolve() + return await preparedResponse.promise + }) + .mockResolvedValueOnce(newer) + const handoff = { + cache: { scope: 'private-request' as const }, + profileId: 'issued', + replay: { routeKey: '/prepared', events: [sdk.eventBuilder.buildPageView()] }, + } + + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/prepared' })).toEqual({ + accepted: true, + }) + await preparedStarted.promise + expect(await sdk.track({ event: 'newer' })).toMatchObject({ accepted: true }) + + preparedResponse.resolve(response('prepared')) + await sdk.flush() + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(2) + expect(sdk.states.profile.current).toEqual(newer.profile) + expect(sdk.states.selectedOptimizations.current).toEqual(newer.selectedOptimizations) + }) +}) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 96fac3712..bdc462ca1 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -566,61 +566,6 @@ describe('ContentfulOptimization', () => { expect(upsertProfile).toHaveBeenCalledTimes(1) }) - it('can mark an SSR-emitted initial current page as accepted', async () => { - const web = new ContentfulOptimization(config) - const upsertProfile = rs - .spyOn(web.api.experience, 'upsertProfile') - .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - - await expect( - web.trackCurrentPage({ - initialPageEvent: 'skip', - routeKey: '/', - }), - ).resolves.toEqual({ accepted: true }) - await expect( - web.trackCurrentPage({ - routeKey: '/', - buildPayload: () => ({}), - }), - ).resolves.toEqual({ accepted: false }) - - expect(upsertProfile).not.toHaveBeenCalled() - }) - - it('can mark an SSR-emitted current page as accepted after another route', async () => { - const web = new ContentfulOptimization(config) - const upsertProfile = rs - .spyOn(web.api.experience, 'upsertProfile') - .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - const skippedPayload = rs.fn(() => ({})) - const dedupedPayload = rs.fn(() => ({})) - - await expect( - web.trackCurrentPage({ - routeKey: '/', - buildPayload: () => ({}), - }), - ).resolves.toEqual({ accepted: true, data: EMPTY_OPTIMIZATION_DATA }) - await expect( - web.trackCurrentPage({ - initialPageEvent: 'skip', - routeKey: '/page-two', - buildPayload: skippedPayload, - }), - ).resolves.toEqual({ accepted: true }) - await expect( - web.trackCurrentPage({ - routeKey: '/page-two', - buildPayload: dedupedPayload, - }), - ).resolves.toEqual({ accepted: false }) - - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(skippedPayload).not.toHaveBeenCalled() - expect(dedupedPayload).not.toHaveBeenCalled() - }) - it('forwards onEventBlocked callback to core stateful guards', async () => { const onEventBlocked = rs.fn() const web = new ContentfulOptimization({ ...config, onEventBlocked }) diff --git a/packages/web/web-sdk/src/ContentfulOptimization.ts b/packages/web/web-sdk/src/ContentfulOptimization.ts index 8c2fd40de..0d4268b03 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -12,12 +12,15 @@ import { AcceptedCurrentStateTracker, + assertOptimizationCacheSafety, CoreStateful, effect, resolveStatefulDefaults, signals, + type ConsentInput, type CoreStatefulConfig, type EventEmissionResult, + type OptimizationHandoff, type PageViewBuilderArgs, } from '@contentful/optimization-core' import type { App } from '@contentful/optimization-core/api-schemas' @@ -41,6 +44,11 @@ import { createOnlineChangeListener, createVisibilityChangeListener, } from './handlers' +import { + clearHandoffInitialization, + getHandoffInitialization, + hydrateOptimizationHandoff, +} from './handoff-internal' import { getCookie, removeCookie, setCookie, type CookieAttributes } from './lib/cookies' import { clearProfilelessHandoffDurableContinuity, @@ -116,13 +124,6 @@ export interface CurrentPageEmissionMetadata { readonly isInitialEmission: boolean } -/** - * Controls how {@link ContentfulOptimization.trackCurrentPage} treats the current route. - * - * @public - */ -export type InitialCurrentPageEvent = 'emit' | 'skip' - /** * Options for {@link ContentfulOptimization.trackCurrentPage}. * @@ -133,11 +134,6 @@ export interface TrackCurrentPageOptions { * Stable route identity used for current-page deduplication. */ readonly routeKey: string - /** - * Controls the current route emission. SSR integrations can use `skip` when - * the server already emitted this route's page event. - */ - readonly initialPageEvent?: InitialCurrentPageEvent /** * Builds the page payload only when a page event will be emitted. */ @@ -145,22 +141,14 @@ export interface TrackCurrentPageOptions { } /** - * Skip-only options for {@link ContentfulOptimization.trackCurrentPage}. + * Route identity and ordinary-page builder used during browser takeover. * * @public */ -export interface TrackCurrentPageSkipOptions { - /** - * Stable route identity used for current-page deduplication. - */ +export interface HydrateAndTrackCurrentPageOptions { + /** Pathname and search string for the browser route being initialized or tracked. */ readonly routeKey: string - /** - * Marks the current route accepted without emitting a page event. - */ - readonly initialPageEvent: 'skip' - /** - * Ignored for skip-only tracking. Kept for callers that share option builders. - */ + /** Builds an ordinary browser page event when the handoff has no replay matching `routeKey`. */ readonly buildPayload?: TrackCurrentPageOptions['buildPayload'] } @@ -274,6 +262,8 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { declare readonly [CORE_BRIDGE_CAPABILITIES_SYMBOL]: CoreBridgeCapabilities private readonly currentPageTracker = new AcceptedCurrentStateTracker() + private readonly handoffIdentity: { profileId?: string } + private readonly effectDisposers: Array<() => void> = [] /** * Tracked entry interaction runtime state and trackers. @@ -335,8 +325,14 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { const { autoTrackEntryInteraction, ...restConfig } = config const mergedConfig: OptimizationWebConfig = mergeConfig(restConfig) + const handoffIdentity: { profileId?: string } = {} + const { getAnonymousId } = mergedConfig + mergedConfig.getAnonymousId = () => + signals.profile.value?.id ?? handoffIdentity.profileId ?? getAnonymousId?.() super(mergedConfig) + this.handoffIdentity = handoffIdentity + clearHandoffInitialization() clearProfilelessHandoffDurableContinuity() const canLoadPersistedContinuity = mergedConfig.defaults?.persistenceConsent === true @@ -348,7 +344,7 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { this.tracking = tracking this.cookieAttributes = { - domain: mergedConfig.cookie?.domain, + ...mergedConfig.cookie, expires: mergedConfig.cookie?.expires ?? EXPIRATION_DAYS_DEFAULT, } @@ -361,62 +357,61 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { await this.flushQueues({ force: true, beacon: beaconHandler }) }) - effect(() => { - const { - changes: { value }, - persistenceConsent: { value: persistenceConsent }, - } = signals - - if (canPersistDurableContinuity(persistenceConsent)) LocalStore.changes = value - }) - - effect(() => { - const { - consent: { value }, - } = signals - - this.entryInteractionRuntime.syncAutoTrackedEntryInteractions() - LocalStore.consent = value - }) - - effect(() => { - const { - persistenceConsent: { value }, - } = signals - - LocalStore.persistenceConsent = value - if (value === true) this.initializeFromCurrentCookieValues() - if (value === false) { - removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) - removeCookie(ANONYMOUS_ID_COOKIE_LEGACY, this.cookieAttributes) - LocalStore.clearProfileContinuity() - } - }) - - effect(() => { - const { - persistenceConsent: { value: persistenceConsent }, - profile: { value }, - } = signals - - if (value !== undefined && !isDurableContinuityPersistenceSuppressed()) { - clearProfilelessHandoffDurableContinuity() - } - - if (persistenceConsent !== true) return - - LocalStore.profile = value - this.setAnonymousId(value?.id ?? LocalStore.anonymousId) - }) - - effect(() => { - const { - persistenceConsent: { value: persistenceConsent }, - selectedOptimizations: { value }, - } = signals - - if (canPersistDurableContinuity(persistenceConsent)) LocalStore.selectedOptimizations = value - }) + this.effectDisposers.push( + effect(() => { + const { + changes: { value }, + persistenceConsent: { value: persistenceConsent }, + } = signals + + if (canPersistDurableContinuity(persistenceConsent)) LocalStore.changes = value + }), + effect(() => { + const { + consent: { value }, + } = signals + + this.entryInteractionRuntime.syncAutoTrackedEntryInteractions() + LocalStore.consent = value + }), + effect(() => { + const { + persistenceConsent: { value }, + } = signals + + LocalStore.persistenceConsent = value + if (value === true) this.initializeFromCurrentCookieValues() + if (value === false) { + removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) + removeCookie(ANONYMOUS_ID_COOKIE_LEGACY, this.cookieAttributes) + LocalStore.clearProfileContinuity() + } + }), + effect(() => { + const { + persistenceConsent: { value: persistenceConsent }, + profile: { value }, + } = signals + + if (value !== undefined && !isDurableContinuityPersistenceSuppressed()) { + clearProfilelessHandoffDurableContinuity() + } + + if (persistenceConsent !== true) return + + LocalStore.profile = value + this.setAnonymousId(value?.id ?? this.handoffIdentity.profileId ?? LocalStore.anonymousId) + }), + effect(() => { + const { + persistenceConsent: { value: persistenceConsent }, + selectedOptimizations: { value }, + } = signals + + if (canPersistDurableContinuity(persistenceConsent)) + LocalStore.selectedOptimizations = value + }), + ) this.initializeFromCookieValues(cookieValue, legacyCookieValue) @@ -489,6 +484,8 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * @public */ reset(): void { + clearHandoffInitialization() + this.handoffIdentity.profileId = undefined this.currentPageTracker.reset() this.entryInteractionRuntime.reset() removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) @@ -506,15 +503,9 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * * @public */ - async trackCurrentPage( - options: TrackCurrentPageOptions | TrackCurrentPageSkipOptions, - ): Promise { + async trackCurrentPage(options: TrackCurrentPageOptions): Promise { const { routeKey } = options - - if (options.initialPageEvent === 'skip') { - this.currentPageTracker.markAccepted(routeKey) - return { accepted: true } - } + clearHandoffInitialization(routeKey) const { buildPayload } = options const isInitialEmission = !this.currentPageTracker.hasAccepted() @@ -530,6 +521,73 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { return { accepted: true, data: result.data } } + /** + * Hydrate the current render from a server handoff and admit its matching replay through the + * Experience queue. Server preparation previews the events; the browser sends the replay through + * normal Experience delivery. For an admitted replay, this promise settles while the HTTP + * response updates live state independently. If replay is absent, does not match `routeKey`, or + * is rejected before admission, the SDK attempts ordinary current-page tracking and returns that + * attempt's result. An admitted replay's delivery failure does not trigger a fallback. + * Repeated current initialization shares work; later navigation remains trackable. + * @public + */ + async hydrateAndTrackCurrentPage( + handoff: OptimizationHandoff, + { routeKey, buildPayload = () => ({}) }: HydrateAndTrackCurrentPageOptions, + ): Promise { + assertOptimizationCacheSafety(handoff) + const initialization = getHandoffInitialization(handoff, routeKey) + if (initialization.delivery !== undefined) return await initialization.delivery + + initialization.delivery = Promise.resolve().then(async () => { + if (!initialization.isCurrent()) return { accepted: false } + const profileId = this.adoptHandoffIdentity(handoff) + await hydrateOptimizationHandoff(this, handoff, { routeKey }) + if (!initialization.isCurrent()) return { accepted: false } + + const { replay } = handoff + if (replay?.routeKey !== routeKey || replay.events.at(-1)?.type !== 'page') { + return await this.trackCurrentPage({ routeKey, buildPayload }) + } + const result = await this.sendPreparedExperienceEvents( + replay.events, + profileId, + replay.locale, + ) + if (!initialization.isCurrent()) return { accepted: false } + if (!result.accepted) { + return await this.trackCurrentPage({ routeKey, buildPayload }) + } + this.currentPageTracker.markAccepted(routeKey) + return result + }) + try { + const result = await initialization.delivery + if (!result.accepted) initialization.delivery = undefined + return result + } catch (error) { + initialization.delivery = undefined + throw error + } + } + + private adoptHandoffIdentity(handoff: OptimizationHandoff): string | undefined { + const profileId = + signals.profile.value?.id ?? this.config.getAnonymousId?.() ?? handoff.profileId + this.handoffIdentity.profileId = profileId + if (this.states.persistenceConsent.current === true && profileId !== undefined) { + this.setAnonymousId(profileId) + } + return profileId + } + + override consent(accept: ConsentInput): void { + if ((typeof accept === 'boolean' ? accept : accept.events) === false) { + if (clearHandoffInitialization()) this.currentPageTracker.reset() + } + super.consent(accept) + } + /** * Destroy the Web SDK instance and release runtime resources. * @@ -538,6 +596,10 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * clear persisted user state. */ destroy(): void { + clearHandoffInitialization() + this.effectDisposers.splice(0).forEach((dispose) => { + dispose() + }) this.entryInteractionRuntime.destroy() this.cleanupOnlineListener() this.cleanupVisibilityListener() diff --git a/packages/web/web-sdk/src/analytics.test.ts b/packages/web/web-sdk/src/analytics.test.ts index 28e796e21..153685225 100644 --- a/packages/web/web-sdk/src/analytics.test.ts +++ b/packages/web/web-sdk/src/analytics.test.ts @@ -98,7 +98,6 @@ function createAnalyticsHandoff( return { cache: { scope: 'private-request' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { profile, selectedOptimizations, @@ -305,7 +304,6 @@ describe('Optimization analytics handoff runtime', () => { expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenCalledWith({ buildPayload: secondPayload, - initialPageEvent: 'emit', routeKey: '/segment-b', }) @@ -383,32 +381,6 @@ describe('Optimization analytics handoff runtime', () => { expect(LocalStore.selectedOptimizations).toEqual(selectedOptimizations) }) - it('warns without throwing when skipping the page event without profile continuity', async () => { - const warn = rs.spyOn(console, 'warn').mockImplementation(() => undefined) - runtime = initializeOptimizationAnalyticsRuntime({ - ...config, - logLevel: 'warn', - }) - - await expect( - hydrateOptimizationAnalyticsHandoff( - runtime, - createAnalyticsHandoff({ - initialPageEvent: 'skip', - state: { selectedOptimizations }, - }), - { - routeKey: '/segment-a', - buildPagePayload: () => ({}), - }, - ), - ).resolves.toBeUndefined() - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('without handoff profile state or browser profile continuity'), - ) - }) - it('rejects content handoffs', async () => { runtime = initializeOptimizationAnalyticsRuntime(config) @@ -418,7 +390,6 @@ describe('Optimization analytics handoff runtime', () => { { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', }, { routeKey: '/', @@ -428,24 +399,6 @@ describe('Optimization analytics handoff runtime', () => { ).rejects.toThrow('analytics-only optimization handoffs') }) - it('rejects invalid initialPageEvent values', async () => { - runtime = initializeOptimizationAnalyticsRuntime(config) - - await expect( - Reflect.apply(hydrateOptimizationAnalyticsHandoff, undefined, [ - runtime, - { - ...createAnalyticsHandoff(), - initialPageEvent: 'invalid', - }, - { - routeKey: '/', - buildPagePayload: () => ({}), - }, - ]), - ).rejects.toThrow('initialPageEvent') - }) - it('rejects public profile state before hydrating browser signals', async () => { runtime = initializeOptimizationAnalyticsRuntime(config) @@ -461,7 +414,7 @@ describe('Optimization analytics handoff runtime', () => { }, ), ).rejects.toThrow( - 'Profile state should not be included in public or static optimization caches.', + 'Profile state, identity and replay must not be included in public or static optimization caches.', ) expect(signals.profile.value).toBeUndefined() diff --git a/packages/web/web-sdk/src/analytics.ts b/packages/web/web-sdk/src/analytics.ts index 58687a2cf..619d8863a 100644 --- a/packages/web/web-sdk/src/analytics.ts +++ b/packages/web/web-sdk/src/analytics.ts @@ -4,23 +4,15 @@ * @packageDocumentation */ -import { assertOptimizationCacheSafety } from '@contentful/optimization-core' -import { createScopedLogger } from '@contentful/optimization-core/logger' import ContentfulOptimization, { type OptimizationTrackingApi, type OptimizationWebConfig, type TrackCurrentPageOptions, } from './ContentfulOptimization' -import { - hydrateOptimizationHandoffState, - shouldPreserveDurableContinuity, - type AnalyticsOptimizationHandoff, -} from './handoff' - -const logger = createScopedLogger('Web:AnalyticsHandoff') +import { hydrateOptimizationHandoff, type AnalyticsOptimizationHandoff } from './handoff' +import { getHandoffInitialization } from './handoff-internal' const runtimeSdks = new WeakMap() -let latestAnalyticsHandoffHydration = 0 /** * Options used when hydrating an analytics-only handoff. @@ -30,7 +22,7 @@ let latestAnalyticsHandoffHydration = 0 export interface HydrateOptimizationAnalyticsHandoffOptions { /** Stable route identity used for current-page deduplication. */ readonly routeKey: string - /** Builds the browser page payload when `handoff.initialPageEvent` is `emit`. */ + /** Builds an ordinary browser page payload when there is no matching replay. */ readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] /** Cancels async hydration before state apply or page tracking. */ readonly isCurrent?: () => boolean @@ -48,36 +40,12 @@ export interface OptimizationAnalyticsRuntime { flush: () => Promise /** Track the current route with the same semantics as the full Web SDK. */ trackCurrentPage: ContentfulOptimization['trackCurrentPage'] + /** Hydrate and admit the current handoff through the shared Web operation. */ + hydrateAndTrackCurrentPage: ContentfulOptimization['hydrateAndTrackCurrentPage'] /** Release browser listeners and queued runtime resources. */ destroy: () => void } -function getRuntimeSdk(runtime: OptimizationAnalyticsRuntime): ContentfulOptimization { - const sdk = runtimeSdks.get(runtime) - - if (sdk === undefined) { - throw new TypeError('Unknown Optimization analytics runtime.') - } - - return sdk -} - -function hasProfileContinuity(sdk: ContentfulOptimization): boolean { - return sdk.states.persistenceConsent.current === true && sdk.states.profile.current !== undefined -} - -function warnSkippedInitialPageWithoutProfileContinuity( - sdk: ContentfulOptimization, - handoff: AnalyticsOptimizationHandoff, -): void { - if (handoff.initialPageEvent !== 'skip') return - if (handoff.state?.profile !== undefined || hasProfileContinuity(sdk)) return - - logger.warn( - 'Analytics-only handoff skipped the initial page event without handoff profile state or browser profile continuity.', - ) -} - function assertAnalyticsHandoff(handoff: AnalyticsOptimizationHandoff): void { const hydration: unknown = handoff.hydration @@ -88,12 +56,6 @@ function assertAnalyticsHandoff(handoff: AnalyticsOptimizationHandoff): void { ) } -function assertInitialPageEvent(initialPageEvent: unknown): void { - if (initialPageEvent === 'emit' || initialPageEvent === 'skip') return - - throw new TypeError('Optimization handoff requires initialPageEvent to be "emit" or "skip".') -} - /** * Initialize the analytics-only browser runtime. * @@ -119,16 +81,17 @@ export function initializeOptimizationAnalyticsRuntime( await sdk.flush() }, trackCurrentPage: async (options) => await sdk.trackCurrentPage(options), + hydrateAndTrackCurrentPage: async (handoff, options) => + await sdk.hydrateAndTrackCurrentPage(handoff, options), tracking: sdk.tracking, } - runtimeSdks.set(runtime, sdk) return runtime } /** - * Hydrate analytics-only browser state and emit or mark the current page event. + * Hydrate analytics-only browser state and admit its replay or ordinary current page. * * @param runtime - Analytics runtime returned by {@link initializeOptimizationAnalyticsRuntime}. * @param handoff - Analytics-only browser handoff. @@ -142,27 +105,17 @@ export async function hydrateOptimizationAnalyticsHandoff( options: HydrateOptimizationAnalyticsHandoffOptions, ): Promise { assertAnalyticsHandoff(handoff) - assertInitialPageEvent(handoff.initialPageEvent) - assertOptimizationCacheSafety(handoff) - const sdk = getRuntimeSdk(runtime) - latestAnalyticsHandoffHydration += 1 - const hydration = latestAnalyticsHandoffHydration - const isCurrent = (): boolean => - hydration === latestAnalyticsHandoffHydration && options.isCurrent?.() !== false - - await hydrateOptimizationHandoffState(sdk, handoff.state, { - isCurrent, - suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), + if (options.isCurrent?.() === false) return + const sdk = runtimeSdks.get(runtime) + if (sdk === undefined) throw new TypeError('Unknown Optimization analytics runtime.') + const initialization = getHandoffInitialization(handoff, options.routeKey) + await hydrateOptimizationHandoff(sdk, handoff, { + routeKey: options.routeKey, + isCurrent: options.isCurrent, }) - if (!isCurrent()) return - - warnSkippedInitialPageWithoutProfileContinuity(sdk, handoff) - - if (!isCurrent()) return - - await runtime.trackCurrentPage({ + if (!initialization.isCurrent() || options.isCurrent?.() === false) return + await runtime.hydrateAndTrackCurrentPage(handoff, { buildPayload: options.buildPagePayload, - initialPageEvent: handoff.initialPageEvent, routeKey: options.routeKey, }) } diff --git a/packages/web/web-sdk/src/handoff-internal.ts b/packages/web/web-sdk/src/handoff-internal.ts new file mode 100644 index 000000000..d419e02db --- /dev/null +++ b/packages/web/web-sdk/src/handoff-internal.ts @@ -0,0 +1,194 @@ +import { + assertOptimizationCacheSafety, + batch, + hasOptimizationSelectionStateField, + mergeOptimizationSelectionState, + signals, + type EventEmissionResult, + type OptimizationHandoff, + type OptimizationSelectionState, +} from '@contentful/optimization-core' +import type { HandoffStateHydrationOptions, OptimizationHandoffHydrationTarget } from './handoff' +import { + preserveProfilelessHandoffDurableContinuity, + suppressDurableContinuityPersistence, +} from './storage/durableContinuityPersistence' + +const CONTENT_STATE_RESET: OptimizationSelectionState = { + changes: undefined, + selectedOptimizations: undefined, +} + +let latestHandoffStateHydration = 0 + +/** The current initialization only; admitted batches remain owned by Core's queue. @internal */ +export interface HandoffInitialization { + readonly key: string | OptimizationHandoff + readonly routeKey: string + readonly isCurrent: () => boolean + hydration?: Promise + delivery?: Promise +} + +let currentHandoffInitialization: HandoffInitialization | undefined = undefined + +/** Join current work before seeding state, including state-only framework initialization. @internal */ +export function getHandoffInitialization( + handoff: OptimizationHandoff, + routeKey: string, +): HandoffInitialization { + const key = handoff.replay?.events.at(-1)?.messageId ?? handoff + const current = currentHandoffInitialization + if (current?.routeKey === routeKey && current.key === key) return current + + const initialization: HandoffInitialization = { + key, + routeKey, + isCurrent: () => currentHandoffInitialization === initialization, + } + currentHandoffInitialization = initialization + return initialization +} + +/** Invalidate unfinished hydration when the SDK resets, is cleared or is destroyed. @internal */ +export function clearHandoffInitialization(routeKey?: string): boolean { + if (routeKey !== undefined && currentHandoffInitialization?.routeKey === routeKey) return false + const hadInitialization = currentHandoffInitialization !== undefined + currentHandoffInitialization = undefined + latestHandoffStateHydration += 1 + return hadInitialization +} + +function shouldContinueHydration(options: HandoffStateHydrationOptions): boolean { + return options.isCurrent?.() !== false +} + +/** + * @internal + */ +export function shouldPreserveDurableContinuity(handoff: OptimizationHandoff): boolean { + return ( + (handoff.cache.scope === 'public-permutation' || handoff.cache.scope === 'static') && + handoff.state?.profile === undefined + ) +} + +function applyHydratedSignals( + state: OptimizationSelectionState, + options: HandoffStateHydrationOptions, +): void { + const { changes, profile, selectedOptimizations } = state + const { + changes: changesSignal, + experienceRequestState, + profile: profileSignal, + selectedOptimizations: selectedOptimizationsSignal, + } = signals + + const updateSignals = (): void => { + batch(() => { + changesSignal.value = changes + if (hasOptimizationSelectionStateField(state, 'profile')) profileSignal.value = profile + selectedOptimizationsSignal.value = selectedOptimizations + + experienceRequestState.value = { status: 'success' } + }) + } + + if (options.suppressDurableContinuityPersistence === true) { + preserveProfilelessHandoffDurableContinuity() + suppressDurableContinuityPersistence(updateSignals) + return + } + + updateSignals() +} + +function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): void { + if (!shouldContinueHydration(options)) return + + applyHydratedSignals(CONTENT_STATE_RESET, options) +} + +/** + * Hydrate a live Web SDK from public browser handoff state. + * + * @param sdk - Live Web SDK instance to hydrate. + * @param state - Public browser handoff state. + * + * @public + */ +export async function hydrateOptimizationHandoffState( + sdk: OptimizationHandoffHydrationTarget, + state: OptimizationHandoff['state'], + options: HandoffStateHydrationOptions = {}, +): Promise { + latestHandoffStateHydration += 1 + const hydration = latestHandoffStateHydration + + if (!state) { + applySuccessfulEmptyHandoffHydration(options) + return + } + + const hasChanges = hasOptimizationSelectionStateField(state, 'changes') + const hasProfile = hasOptimizationSelectionStateField(state, 'profile') + const hasSelectedOptimizations = hasOptimizationSelectionStateField( + state, + 'selectedOptimizations', + ) + if (!hasChanges && !hasProfile && !hasSelectedOptimizations) { + applySuccessfulEmptyHandoffHydration(options) + return + } + + const inputState = mergeOptimizationSelectionState(CONTENT_STATE_RESET, state) + + const { + interceptors: { state: stateInterceptors }, + } = sdk + + const hydratedState = await stateInterceptors + .run(inputState, mergeOptimizationSelectionState) + .catch((error: unknown) => { + if (hydration === latestHandoffStateHydration) throw error + return undefined + }) + + if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return + if (!shouldContinueHydration(options)) return + + const mergedState = mergeOptimizationSelectionState(inputState, hydratedState) + + applyHydratedSignals(mergedState, options) +} + +/** + * Hydrate a live Web SDK from a content-capable browser handoff. + * + * @param sdk - Live Web SDK instance to hydrate. + * @param handoff - Content handoff produced by server, static, or edge rendering. + * + * @public + */ +export async function hydrateOptimizationHandoff( + sdk: OptimizationHandoffHydrationTarget, + handoff: OptimizationHandoff, + options: { readonly routeKey?: string; readonly isCurrent?: () => boolean } = {}, +): Promise { + assertOptimizationCacheSafety(handoff) + const routeKey = + options.routeKey ?? + (typeof window === 'undefined' ? '/' : `${window.location.pathname}${window.location.search}`) + const initialization = getHandoffInitialization(handoff, routeKey) + if (handoff.replay !== undefined && handoff.replay.routeKey !== routeKey) return + initialization.hydration ??= hydrateOptimizationHandoffState(sdk, handoff.state, { + isCurrent: () => initialization.isCurrent() && options.isCurrent?.() !== false, + suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), + }).catch((error: unknown) => { + initialization.hydration = undefined + throw error + }) + await initialization.hydration + if (options.isCurrent?.() === false) initialization.hydration = undefined +} diff --git a/packages/web/web-sdk/src/handoff.test.ts b/packages/web/web-sdk/src/handoff.test.ts index e642312bf..fbd1b18e9 100644 --- a/packages/web/web-sdk/src/handoff.test.ts +++ b/packages/web/web-sdk/src/handoff.test.ts @@ -1,6 +1,7 @@ import { batch, signals } from '@contentful/optimization-core' import type { ChangeArray, + OptimizationData, Profile, SelectedOptimizationArray, } from '@contentful/optimization-core/api-schemas' @@ -13,8 +14,10 @@ import { hydrateOptimizationHandoffState, type ContentOptimizationHandoff, } from './handoff' -import { removeCookie } from './lib/cookies' +import { getCookie, removeCookie } from './lib/cookies' +import { createWebSnapshotRuntime } from './runtime' import LocalStore from './storage/LocalStore' +import { deferred } from './test/helpers' const config = { spaceId: 'key_123', @@ -73,7 +76,6 @@ function createContentHandoff( return { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state, ...overrides, } @@ -192,6 +194,7 @@ describe('hydrateOptimizationHandoff', () => { window.contentfulOptimization?.destroy() delete window.contentfulOptimization rs.restoreAllMocks() + rs.useRealTimers() }) it('keeps handoff hydration out of Web bridge support', () => { @@ -199,6 +202,478 @@ describe('hydrateOptimizationHandoff', () => { expect('hydrateOptimizationHandoffState' in webBridgeSupport).toBe(false) }) + it('admits replay without waiting for delivery, persists its API ID and keeps early Insights attributable', async () => { + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: true, persistenceConsent: true }, + }) + const response = deferred() + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const insightsSent = createDeferred() + const insights = rs.spyOn(sdk.api.insights, 'sendBatchEvents').mockImplementation(async () => { + insightsSent.resolve() + return await Promise.resolve(true) + }) + const browserTransform = rs.spyOn(sdk.interceptors.event, 'run') + const events = [ + sdk.eventBuilder.buildIdentify({ userId: 'customer' }), + sdk.eventBuilder.buildPageView(), + ] + const handoff: ContentOptimizationHandoff = { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + profileId: 'api-issued-id', + replay: { routeKey: '/paired', events, locale: 'de-DE' }, + } + const ordinaryPage = rs.spyOn(sdk, 'page') + + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: true, + }) + expect(browserTransform).not.toHaveBeenCalled() + expect(sdk.states.profile.current).toBeUndefined() + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('api-issued-id') + await sdk.trackClick({ + componentId: 'displayed-variant', + experienceId: 'experience-id', + variantIndex: 1, + }) + const flushing = sdk.flush() + await insightsSent.promise + expect(insights.mock.calls[0]?.[0]).toMatchObject([ + { + profile: { id: 'api-issued-id' }, + events: [ + { componentId: 'displayed-variant', experienceId: 'experience-id', variantIndex: 1 }, + ], + }, + ]) + expect(deliver).toHaveBeenCalledWith( + { profileId: 'api-issued-id', events }, + { preflight: false, locale: 'de-DE' }, + ) + const liveReady = createDeferred() + const subscription = sdk.states.profile.subscribe((profile) => { + if (profile?.id === 'linked-api-id') liveReady.resolve() + }) + response.resolve({ profile: createProfile('linked-api-id'), changes, selectedOptimizations }) + await liveReady.promise + subscription.unsubscribe() + await flushing + expect(sdk.states.profile.current?.id).toBe('linked-api-id') + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBe('linked-api-id') + expect(ordinaryPage).not.toHaveBeenCalled() + }) + + it('shares state-only and delivery initialization without reseeding after a live response', async () => { + const seed = createProfile('api-issued-id') + const live = { ...seed, traits: { accepted: true } } + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: live, + changes: [], + selectedOptimizations: [], + }) + const handoff = createContentHandoff( + { profile: seed, changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + profileId: seed.id, + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/paired' }) + const first = sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + const repeat = sdk.hydrateAndTrackCurrentPage({ ...handoff }, { routeKey: '/paired' }) + expect(await first).toEqual({ accepted: true }) + expect(await repeat).toEqual({ accepted: true }) + await sdk.flush() + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/paired' }) + await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + expect(deliver).toHaveBeenCalledTimes(1) + expect(sdk.states.profile.current).toEqual(live) + expect(sdk.states.selectedOptimizations.current).toEqual([]) + }) + + it('reassesses consent, retaining the known ID in memory when persistence is denied', async () => { + const sdk = new ContentfulOptimization({ + ...config, + allowedEventTypes: [], + defaults: { persistenceConsent: false }, + }) + window.dispatchEvent(new Event('offline')) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const insights = rs.spyOn(sdk.api.insights, 'sendBatchEvents').mockResolvedValue(true) + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: false, + }) + sdk.consent({ events: true }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: true, + }) + await sdk.trackClick({ componentId: 'displayed-variant' }) + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBeUndefined() + expect(deliver).not.toHaveBeenCalled() + window.dispatchEvent(new Event('online')) + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(1) + expect(insights.mock.calls[0]?.[0][0]?.profile.id).toBe('api-issued-id') + }) + + it.each([1, 2])('uses ordinary capacity guards with %i queued events', async (queuedCount) => { + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: true }, + queuePolicy: { offlineMaxEvents: 2 }, + }) + window.dispatchEvent(new Event('offline')) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const queuedIds: string[] = [] + for (let index = 0; index < queuedCount; index++) { + expect(await sdk.track({ event: `accepted-${index}` })).toEqual({ accepted: true }) + queuedIds.push(signals.event.value?.messageId ?? '') + } + const ordinaryPage = rs.spyOn(sdk, 'page') + const currentPage = rs.spyOn(sdk, 'trackCurrentPage') + const buildPayload = rs.fn(() => ({ properties: { title: 'fallback' } })) + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { + routeKey: '/paired', + events: [ + sdk.eventBuilder.buildIdentify({ userId: 'customer' }), + sdk.eventBuilder.buildPageView(), + ], + }, + }) + const result = await sdk.hydrateAndTrackCurrentPage(handoff, { + routeKey: '/paired', + buildPayload, + }) + expect(result).toEqual({ accepted: queuedCount === 1 }) + expect(await currentPage.mock.results[0]?.value).toEqual(result) + expect(ordinaryPage).toHaveBeenCalledExactlyOnceWith({ properties: { title: 'fallback' } }) + expect(buildPayload).toHaveBeenCalledExactlyOnceWith({ isInitialEmission: true }) + expect(deliver).not.toHaveBeenCalled() + window.dispatchEvent(new Event('online')) + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(1) + const deliveredEvents = deliver.mock.calls.flatMap(([payload]) => payload.events) + expect(deliveredEvents.map(({ type }) => type)).toEqual( + queuedCount === 1 ? ['track', 'page'] : ['track', 'track'], + ) + expect(deliveredEvents.slice(0, queuedCount).map(({ messageId }) => messageId)).toEqual( + queuedIds, + ) + if (queuedCount === 1) + expect(deliveredEvents[1]).toMatchObject({ properties: { title: 'fallback' } }) + }) + + it.each([true, false])( + 'checks ordinary page consent after batch rejection (page permitted: %s)', + async (pagePermitted) => { + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: false }, + allowedEventTypes: pagePermitted ? ['page'] : [], + }) + window.dispatchEvent(new Event('offline')) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const currentPage = rs.spyOn(sdk, 'trackCurrentPage') + const ordinaryPage = rs.spyOn(sdk, 'page') + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + replay: { + routeKey: '/paired', + events: [ + sdk.eventBuilder.buildTrack({ event: 'disallowed' }), + sdk.eventBuilder.buildPageView(), + ], + }, + }) + expect(await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).toEqual({ + accepted: pagePermitted, + }) + expect(currentPage).toHaveBeenCalledTimes(1) + if (pagePermitted) expect(ordinaryPage).toHaveBeenCalledExactlyOnceWith({}) + else expect(ordinaryPage).not.toHaveBeenCalled() + window.dispatchEvent(new Event('online')) + await sdk.flush() + expect(deliver).toHaveBeenCalledTimes(pagePermitted ? 1 : 0) + if (pagePermitted) expect(deliver.mock.calls[0]?.[0].events).toMatchObject([{ type: 'page' }]) + }, + ) + + it('shares repeated fallback work and returns the ordinary page response', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const response = deferred() + const started = deferred() + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockImplementation(async () => { + started.resolve() + return await response.promise + }) + const buildPayload = rs.fn(() => ({ properties: { title: 'fallback' } })) + const handoff = createContentHandoff( + { changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + profileId: 'retained-id', + replay: { + routeKey: '/paired', + events: [sdk.eventBuilder.buildPageView(), sdk.eventBuilder.buildPageView()], + }, + }, + ) + const first = sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired', buildPayload }) + const repeat = sdk.hydrateAndTrackCurrentPage( + { ...handoff }, + { routeKey: '/paired', buildPayload }, + ) + await started.promise + expect(sdk.states.selectedOptimizations.current).toEqual(selectedOptimizations) + expect(deliver.mock.calls[0]?.[0]).toMatchObject({ + profileId: 'retained-id', + events: [{ type: 'page', properties: { title: 'fallback' } }], + }) + const data = { profile: createProfile('retained-id'), changes, selectedOptimizations } + response.resolve(data) + expect(await first).toEqual({ accepted: true, data }) + expect(await repeat).toEqual({ accepted: true, data }) + expect( + await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired', buildPayload }), + ).toEqual({ accepted: true, data }) + expect(await sdk.trackCurrentPage({ routeKey: '/paired', buildPayload })).toEqual({ + accepted: false, + }) + expect( + await sdk.hydrateAndTrackCurrentPage( + { + ...handoff, + replay: { + routeKey: '/paired', + events: [sdk.eventBuilder.buildPageView(), sdk.eventBuilder.buildPageView()], + }, + }, + { routeKey: '/paired', buildPayload }, + ), + ).toEqual({ accepted: false }) + expect(deliver).toHaveBeenCalledTimes(1) + expect(buildPayload).toHaveBeenCalledTimes(1) + }) + + it('does not fall back after accepted replay delivery fails and retains the batch for retry', async () => { + rs.useFakeTimers() + const sdk = new ContentfulOptimization({ + ...config, + defaults: { consent: true }, + queuePolicy: { flush: { baseBackoffMs: 50, maxBackoffMs: 50, jitterRatio: 0 } }, + }) + const response = deferred() + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockReturnValueOnce(response.promise) + .mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const ordinaryPage = rs.spyOn(sdk, 'page') + const buildPayload = rs.fn(() => ({})) + const events = [ + sdk.eventBuilder.buildTrack({ event: 'prepared' }), + sdk.eventBuilder.buildPageView(), + ] + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + replay: { routeKey: '/paired', events }, + }) + expect( + await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired', buildPayload }), + ).toEqual({ accepted: true }) + const flushing = sdk.flush() + response.reject(new Error('Experience unavailable')) + await flushing + expect( + await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired', buildPayload }), + ).toEqual({ accepted: true }) + expect(ordinaryPage).not.toHaveBeenCalled() + expect(buildPayload).not.toHaveBeenCalled() + await rs.advanceTimersByTimeAsync(50) + expect(deliver).toHaveBeenCalledTimes(2) + expect(deliver.mock.calls[0]?.[0].events).toEqual(events) + expect(deliver.mock.calls[1]?.[0].events).toEqual(events) + }) + + it('tracks new preparations and return visits while leaving retry ownership with Core', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + window.dispatchEvent(new Event('offline')) + const delivered = createDeferred() + let pages = 0 + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockImplementation(async (payload) => { + pages += payload.events.filter(({ type }) => type === 'page').length + if (pages === 4) delivered.resolve() + return await Promise.resolve({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + }) + const first = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/one', events: [sdk.eventBuilder.buildPageView()] }, + }) + const second = { + ...first, + replay: { routeKey: '/one', events: [sdk.eventBuilder.buildPageView()] }, + } + await sdk.hydrateAndTrackCurrentPage(first, { routeKey: '/one' }) + await sdk.hydrateAndTrackCurrentPage(second, { routeKey: '/one' }) + await sdk.trackCurrentPage({ routeKey: '/two', buildPayload: () => ({}) }) + await sdk.hydrateAndTrackCurrentPage(first, { routeKey: '/one' }) + window.dispatchEvent(new Event('online')) + await delivered.promise + expect( + deliver.mock.calls + .flatMap(([payload]) => payload.events) + .filter(({ type }) => type === 'page'), + ).toHaveLength(4) + }) + + it('skips stale-route state and replay while permitting the current ordinary page', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes: [], + selectedOptimizations: [], + }) + const handoff = createContentHandoff( + { changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + profileId: 'api-issued-id', + replay: { routeKey: '/old', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + await hydrateOptimizationHandoff(sdk, handoff, { routeKey: '/current' }) + expect(sdk.states.selectedOptimizations.current).toBeUndefined() + await sdk.hydrateAndTrackCurrentPage(handoff, { + routeKey: '/current', + buildPayload: () => ({ properties: { title: 'current' } }), + }) + expect(deliver.mock.calls[0]?.[0].events).toMatchObject([ + { type: 'page', properties: { title: 'current' } }, + ]) + }) + + it.each(['reset', 'destroy'] as const)( + 'does not finish pending hydration after %s', + async (method) => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const waiting = createDeferred() + const started = createDeferred() + sdk.interceptors.state.add(async (state) => { + started.resolve() + await waiting.promise + return state + }) + const deliver = rs.spyOn(sdk.api.experience, 'upsertProfile') + const handoff = createContentHandoff( + { profile: createProfile('api-issued-id') }, + { + cache: { scope: 'private-request' }, + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }, + ) + const initialization = sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' }) + await started.promise + sdk[method]() + waiting.resolve() + expect(await initialization).toEqual({ accepted: false }) + expect(sdk.states.profile.current).toBeUndefined() + expect(deliver).not.toHaveBeenCalled() + }, + ) + + it('keeps paired tracking inert in a snapshot runtime', async () => { + const runtime = createWebSnapshotRuntime() + expect( + await runtime.hydrateAndTrackCurrentPage({ cache: { scope: 'static' } }, { routeKey: '/' }), + ).toEqual({ accepted: false }) + }) + + it.each(['absent', 'rejected'])( + 'preserves ordinary online errors and allows another attempt when replay is %s', + async (replay) => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const failure = new Error('Experience unavailable') + const deliver = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockRejectedValueOnce(failure) + .mockResolvedValue({ + profile: createProfile('api-issued-id'), + changes, + selectedOptimizations, + }) + const handoff = createContentHandoff( + undefined, + replay === 'rejected' + ? { + cache: { scope: 'private-request' }, + replay: { + routeKey: '/ordinary', + events: [sdk.eventBuilder.buildPageView(), sdk.eventBuilder.buildPageView()], + }, + } + : {}, + ) + await expect(sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/ordinary' })).rejects.toBe( + failure, + ) + expect( + (await sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/ordinary' })).accepted, + ).toBe(true) + expect(deliver).toHaveBeenCalledTimes(2) + }, + ) + + it('does not treat unrelated admission errors as prepared validation rejection', async () => { + const sdk = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const failure = new Error('Admission guard failed') + const handoff = createContentHandoff(undefined, { + cache: { scope: 'private-request' }, + replay: { routeKey: '/paired', events: [sdk.eventBuilder.buildPageView()] }, + }) + const ordinaryPage = rs.spyOn(sdk, 'trackCurrentPage') + rs.spyOn(sdk, 'hasConsent').mockImplementation(() => { + throw failure + }) + await expect(sdk.hydrateAndTrackCurrentPage(handoff, { routeKey: '/paired' })).rejects.toBe( + failure, + ) + expect(ordinaryPage).not.toHaveBeenCalled() + }) + it('hydrates selection state without clearing existing profile continuity', async () => { const existingProfile = createProfile('existing-profile') const sdk = new ContentfulOptimization({ @@ -417,7 +892,7 @@ describe('hydrateOptimizationHandoff', () => { }), ), ).rejects.toThrow( - 'Profile state should not be included in public or static optimization caches.', + 'Profile state, identity and replay must not be included in public or static optimization caches.', ) expect(sdk.states.profile.current).toBeUndefined() @@ -508,19 +983,4 @@ describe('hydrateOptimizationHandoff', () => { await first expect(sdk.states.profile.current).toEqual(secondProfile) }) - - it('rejects analytics-only handoffs', async () => { - const sdk = new ContentfulOptimization(config) - - await expect( - Reflect.apply(hydrateOptimizationHandoff, undefined, [ - sdk, - { - cache: { scope: 'static' }, - hydration: 'analytics-only', - initialPageEvent: 'skip', - }, - ]), - ).rejects.toThrow('content optimization handoffs') - }) }) diff --git a/packages/web/web-sdk/src/handoff.ts b/packages/web/web-sdk/src/handoff.ts index 2efb7c019..dbafa53d4 100644 --- a/packages/web/web-sdk/src/handoff.ts +++ b/packages/web/web-sdk/src/handoff.ts @@ -4,19 +4,7 @@ * @packageDocumentation */ -import { - assertOptimizationCacheSafety, - batch, - hasOptimizationSelectionStateField, - mergeOptimizationSelectionState, - signals, - type OptimizationHandoff, - type OptimizationSelectionState, -} from '@contentful/optimization-core' -import { - preserveProfilelessHandoffDurableContinuity, - suppressDurableContinuityPersistence, -} from './storage/durableContinuityPersistence' +import type { LifecycleInterceptors, OptimizationHandoff } from '@contentful/optimization-core' /** * Browser content hydration policy for already-rendered optimized content. @@ -40,8 +28,6 @@ export type OptimizationHydrationMode = ContentOptimizationHydrationMode | 'anal export interface ContentOptimizationHandoff extends OptimizationHandoff { /** Initial content hydration mode. */ readonly hydration: ContentOptimizationHydrationMode - /** Whether the browser owns the initial page event for this route. */ - readonly initialPageEvent: 'emit' | 'skip' } /** @@ -52,8 +38,6 @@ export interface ContentOptimizationHandoff extends OptimizationHandoff { export interface AnalyticsOptimizationHandoff extends OptimizationHandoff { /** Analytics-only handoffs never control content presentation. */ readonly hydration: 'analytics-only' - /** Whether the browser owns the initial page event for this route. */ - readonly initialPageEvent: 'emit' | 'skip' } /** @@ -63,15 +47,6 @@ export interface AnalyticsOptimizationHandoff extends OptimizationHandoff { */ export type BrowserOptimizationHandoff = ContentOptimizationHandoff | AnalyticsOptimizationHandoff -type BrowserOptimizationHandoffState = NonNullable - -interface BrowserHandoffStateInterceptorRunner { - readonly run: ( - state: BrowserOptimizationHandoffState, - merge?: typeof mergeOptimizationSelectionState, - ) => Promise -} - /** * @internal */ @@ -80,209 +55,13 @@ export interface HandoffStateHydrationOptions { readonly suppressDurableContinuityPersistence?: boolean } -interface HydratedSignalUpdate { - readonly hasChanges: boolean - readonly hasProfile: boolean - readonly hasSelectedOptimizations: boolean - readonly options: HandoffStateHydrationOptions - readonly state: BrowserOptimizationHandoffState -} - -const CONTENT_STATE_RESET: OptimizationSelectionState = { - changes: undefined, - selectedOptimizations: undefined, -} - -/** - * Minimal Web SDK shape required for browser handoff hydration. - * - * @public - */ +/** Minimal Web SDK shape required for browser handoff hydration. @public */ export interface OptimizationHandoffHydrationTarget { - readonly interceptors: { - readonly state: unknown - } -} - -const CONTENT_HYDRATION_MODES: readonly ContentOptimizationHydrationMode[] = [ - 'preserve-server', - 'client-only-hidden-until-ready', -] - -let latestHandoffStateHydration = 0 - -function assertInitialPageEvent( - initialPageEvent: unknown, -): asserts initialPageEvent is 'emit' | 'skip' { - if (initialPageEvent === 'emit' || initialPageEvent === 'skip') return - - throw new TypeError('Optimization handoff requires initialPageEvent to be "emit" or "skip".') + readonly interceptors: Pick } -function assertContentHandoff( - handoff: ContentOptimizationHandoff, -): asserts handoff is ContentOptimizationHandoff { - if (CONTENT_HYDRATION_MODES.includes(handoff.hydration)) return - - throw new TypeError('hydrateOptimizationHandoff only accepts content optimization handoffs.') -} - -function hasBrowserHandoffStateInterceptorRunner( - value: unknown, -): value is BrowserHandoffStateInterceptorRunner { - if (value === null || typeof value !== 'object') return false - if (!('run' in value)) return false - - return typeof value.run === 'function' -} - -function shouldContinueHydration(options: HandoffStateHydrationOptions): boolean { - return options.isCurrent?.() !== false -} - -/** - * @internal - */ -export function shouldPreserveDurableContinuity(handoff: BrowserOptimizationHandoff): boolean { - return ( - (handoff.cache.scope === 'public-permutation' || handoff.cache.scope === 'static') && - handoff.state?.profile === undefined - ) -} - -function applyHydratedSignals({ - hasChanges, - hasProfile, - hasSelectedOptimizations, - options, - state, -}: HydratedSignalUpdate): void { - const { changes, profile, selectedOptimizations } = state - const { - changes: changesSignal, - experienceRequestState, - profile: profileSignal, - selectedOptimizations: selectedOptimizationsSignal, - } = signals - - const updateSignals = (): void => { - batch(() => { - if (hasChanges) changesSignal.value = changes - if (hasProfile) profileSignal.value = profile - if (hasSelectedOptimizations) selectedOptimizationsSignal.value = selectedOptimizations - - experienceRequestState.value = { status: 'success' } - }) - } - - if (options.suppressDurableContinuityPersistence === true) { - preserveProfilelessHandoffDurableContinuity() - suppressDurableContinuityPersistence(updateSignals) - return - } - - updateSignals() -} - -function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): void { - if (!shouldContinueHydration(options)) return - - applyHydratedSignals({ - hasChanges: true, - hasProfile: false, - hasSelectedOptimizations: true, - options, - state: CONTENT_STATE_RESET, - }) -} - -async function hydrateOptimizationHandoffStateInternal( - sdk: OptimizationHandoffHydrationTarget, - state: BrowserOptimizationHandoff['state'], - options: HandoffStateHydrationOptions = {}, -): Promise { - latestHandoffStateHydration += 1 - const hydration = latestHandoffStateHydration - - if (!state) { - applySuccessfulEmptyHandoffHydration(options) - return - } - - const hasChanges = hasOptimizationSelectionStateField(state, 'changes') - const hasProfile = hasOptimizationSelectionStateField(state, 'profile') - const hasSelectedOptimizations = hasOptimizationSelectionStateField( - state, - 'selectedOptimizations', - ) - if (!hasChanges && !hasProfile && !hasSelectedOptimizations) { - applySuccessfulEmptyHandoffHydration(options) - return - } - - const inputState = mergeOptimizationSelectionState(CONTENT_STATE_RESET, state) - - const stateInterceptors: unknown = sdk.interceptors.state - if (!hasBrowserHandoffStateInterceptorRunner(stateInterceptors)) { - throw new Error('Contentful Optimization SDK instance does not expose state hydration support.') - } - - const hydratedState = await stateInterceptors - .run(inputState, mergeOptimizationSelectionState) - .catch((error: unknown) => { - if (hydration === latestHandoffStateHydration) throw error - return undefined - }) - - if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return - if (!shouldContinueHydration(options)) return - - const mergedState = mergeOptimizationSelectionState(inputState, hydratedState) - - applyHydratedSignals({ - hasChanges: hasOptimizationSelectionStateField(mergedState, 'changes'), - hasProfile: hasOptimizationSelectionStateField(mergedState, 'profile'), - hasSelectedOptimizations: hasOptimizationSelectionStateField( - mergedState, - 'selectedOptimizations', - ), - options, - state: mergedState, - }) -} - -/** - * Hydrate a live Web SDK from public browser handoff state. - * - * @param sdk - Live Web SDK instance to hydrate. - * @param state - Public browser handoff state. - * - * @public - */ -export async function hydrateOptimizationHandoffState( - sdk: OptimizationHandoffHydrationTarget, - state: BrowserOptimizationHandoff['state'], - options: HandoffStateHydrationOptions = {}, -): Promise { - await hydrateOptimizationHandoffStateInternal(sdk, state, options) -} - -/** - * Hydrate a live Web SDK from a content-capable browser handoff. - * - * @param sdk - Live Web SDK instance to hydrate. - * @param handoff - Content handoff produced by server, static, or edge rendering. - * - * @public - */ -export async function hydrateOptimizationHandoff( - sdk: OptimizationHandoffHydrationTarget, - handoff: ContentOptimizationHandoff, -): Promise { - assertContentHandoff(handoff) - assertInitialPageEvent(handoff.initialPageEvent) - assertOptimizationCacheSafety(handoff) - await hydrateOptimizationHandoffStateInternal(sdk, handoff.state, { - suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), - }) -} +export { + hydrateOptimizationHandoff, + hydrateOptimizationHandoffState, + shouldPreserveDurableContinuity, +} from './handoff-internal' diff --git a/packages/web/web-sdk/src/lib/cookies.test.ts b/packages/web/web-sdk/src/lib/cookies.test.ts index 6094aa186..260a6ff30 100644 --- a/packages/web/web-sdk/src/lib/cookies.test.ts +++ b/packages/web/web-sdk/src/lib/cookies.test.ts @@ -114,6 +114,30 @@ describe('setCookie', () => { }) describe('removeCookie', () => { + it('uses the configured scope and policy for both writing and deletion', () => { + const written: string[] = [] + Object.defineProperty(document, 'cookie', { + configurable: true, + get: () => '', + set: (value: string) => { + written.push(value) + }, + }) + const attributes = { + domain: 'example.com', + path: '/shop', + sameSite: 'none', + secure: true, + } as const + setCookie('foo', 'bar', attributes) + removeCookie('foo', attributes) + + expect(written).toEqual([ + 'foo=bar; Path=/shop; Domain=example.com; SameSite=none; Secure', + 'foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/shop; Domain=example.com; SameSite=none; Secure', + ]) + }) + it('writes an expired cookie at the epoch', () => { let written = '' Object.defineProperty(document, 'cookie', { diff --git a/packages/web/web-sdk/src/lib/cookies.ts b/packages/web/web-sdk/src/lib/cookies.ts index a198751a2..e6208ff83 100644 --- a/packages/web/web-sdk/src/lib/cookies.ts +++ b/packages/web/web-sdk/src/lib/cookies.ts @@ -21,6 +21,12 @@ export interface CookieAttributes { * Determines the expiration date of the cookie as the number of days until the cookie expires. */ expires?: number + /** Cookie path; defaults to `/`. */ + path?: string + /** Cross-site cookie policy. */ + sameSite?: 'lax' | 'strict' | 'none' + /** Require HTTPS when writing the cookie. */ + secure?: boolean } export const getCookie = (name: string): string | undefined => { @@ -37,25 +43,29 @@ export const getCookie = (name: string): string | undefined => { return undefined } +function serializeCookieScope(attributes?: CookieAttributes): string { + let cookie = `; Path=${attributes?.path ?? '/'}` + if (attributes?.domain) { + cookie += `; Domain=${attributes.domain}` + } + if (attributes?.sameSite) cookie += `; SameSite=${attributes.sameSite}` + if (attributes?.secure) cookie += '; Secure' + return cookie +} + export const setCookie = (name: string, value: string, attributes?: CookieAttributes): void => { if (typeof document === 'undefined') return - let cookie = `${name}=${value}; Path=/` - + let cookie = `${name}=${value}${serializeCookieScope(attributes)}` if (typeof attributes?.expires === 'number' && Number.isFinite(attributes.expires)) { cookie += `; Expires=${new Date(Date.now() + attributes.expires * MS_IN_DAY).toUTCString()}` } - if (attributes?.domain) { - cookie += `; Domain=${attributes.domain}` - } document.cookie = cookie } -export const removeCookie = (name: string, attributes?: Pick): void => { +export const removeCookie = (name: string, attributes?: CookieAttributes): void => { if (typeof document === 'undefined') return - document.cookie = `${name}=; Expires=${EXPIRED_UTC}; Path=/${ - attributes?.domain ? `; Domain=${attributes.domain}` : '' - }` + document.cookie = `${name}=; Expires=${EXPIRED_UTC}${serializeCookieScope(attributes)}` } diff --git a/packages/web/web-sdk/src/preparedHandoffLimit.test.ts b/packages/web/web-sdk/src/preparedHandoffLimit.test.ts new file mode 100644 index 000000000..0d0ade0ab --- /dev/null +++ b/packages/web/web-sdk/src/preparedHandoffLimit.test.ts @@ -0,0 +1,86 @@ +import { signals } from '@contentful/optimization-core' +import type { + ExperienceEventArray, + OptimizationData, +} from '@contentful/optimization-core/api-schemas' +import ContentfulOptimization from './ContentfulOptimization' +import { ANONYMOUS_ID_COOKIE } from './constants' +import { removeCookie } from './lib/cookies' +import { deferred } from './test/helpers' + +describe('browser prepared handoff limit', () => { + it.each(['101 events', 'invalid schema', 'invalid order', '51 identifies'])( + 'falls back to an ordinary page for %s while retaining continuation identity', + async (scenario) => { + localStorage.clear() + removeCookie(ANONYMOUS_ID_COOKIE) + signals.online.value = true + signals.profile.value = undefined + const sdk = new ContentfulOptimization({ + spaceId: 'key_123', + environment: 'main', + defaults: { consent: true }, + queuePolicy: { offlineMaxEvents: 150 }, + }) + try { + window.dispatchEvent(new Event('offline')) + const response = deferred() + const upsert = rs + .spyOn(sdk.api.experience, 'upsertProfile') + .mockReturnValue(response.promise) + const ordinaryPage = rs.spyOn(sdk, 'page') + let events: ExperienceEventArray = [ + ...Array.from({ length: 100 }, () => sdk.eventBuilder.buildTrack({ event: 'campaign' })), + sdk.eventBuilder.buildPageView(), + ] + if (scenario === 'invalid schema') + events = [ + { ...sdk.eventBuilder.buildTrack({ event: 'campaign' }), originalTimestamp: 'invalid' }, + sdk.eventBuilder.buildPageView(), + ] + if (scenario === 'invalid order') + events = [sdk.eventBuilder.buildPageView(), sdk.eventBuilder.buildPageView()] + if (scenario === '51 identifies') + events = [ + ...Array.from({ length: 51 }, () => + sdk.eventBuilder.buildIdentify({ userId: 'customer' }), + ), + sdk.eventBuilder.buildPageView(), + ] + const buildPayload = rs.fn(() => ({ properties: { title: 'ordinary fallback' } })) + + await expect( + sdk.hydrateAndTrackCurrentPage( + { + cache: { scope: 'private-request' }, + profileId: 'continuation-id', + replay: { routeKey: '/products', events }, + }, + { routeKey: '/products', buildPayload }, + ), + ).resolves.toEqual({ accepted: true }) + expect(upsert).not.toHaveBeenCalled() + expect(ordinaryPage).toHaveBeenCalledExactlyOnceWith({ + properties: { title: 'ordinary fallback' }, + }) + expect(buildPayload).toHaveBeenCalledExactlyOnceWith({ isInitialEmission: true }) + window.dispatchEvent(new Event('online')) + const flushing = sdk.flush() + response.reject(new Error('Deferred test response')) + await flushing + expect(upsert).toHaveBeenCalledTimes(1) + const payload = upsert.mock.calls[0]?.[0] + expect(payload?.profileId).toBe('continuation-id') + expect(payload?.events).toMatchObject([ + { type: 'page', properties: { title: 'ordinary fallback' } }, + ]) + expect(events.map(({ messageId }) => messageId)).not.toContain( + payload?.events[0]?.messageId, + ) + } finally { + sdk.destroy() + rs.restoreAllMocks() + } + }, + ) +}) diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index 28819d1a8..28d1c5daa 100644 --- a/packages/web/web-sdk/src/runtime.ts +++ b/packages/web/web-sdk/src/runtime.ts @@ -27,7 +27,7 @@ export * from '@contentful/optimization-core/runtime' * * @internal */ -type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' | 'hydrateAndTrackCurrentPage' type ManagedEntryFetchMembers = | 'fetchContentfulEntries' | 'fetchContentfulEntry' @@ -96,5 +96,6 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp prefetchManagedEntries: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + hydrateAndTrackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) } diff --git a/packages/web/web-sdk/src/web-components/index.test.ts b/packages/web/web-sdk/src/web-components/index.test.ts index e93be1364..313eff0c0 100644 --- a/packages/web/web-sdk/src/web-components/index.test.ts +++ b/packages/web/web-sdk/src/web-components/index.test.ts @@ -225,6 +225,7 @@ function createSdk( enableElement: () => undefined, }, trackCurrentPage: resolveAccepted, + hydrateAndTrackCurrentPage: resolveAccepted, trackView: resolveAccepted, }