diff --git a/.prettierignore b/.prettierignore index d84394a73..8ca39c776 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ docs pnpm-lock.yaml +**/.angular/** +**/.cache/** **/.next/** **/next-env.d.ts **/ios/Pods/** diff --git a/documentation/authoring/blueprints/nextjs-app-router.md b/documentation/authoring/blueprints/nextjs-app-router.md index 7e3eecb75..c09799002 100644 --- a/documentation/authoring/blueprints/nextjs-app-router.md +++ b/documentation/authoring/blueprints/nextjs-app-router.md @@ -9,28 +9,24 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app. ## Quick-start contract -- **Outcome:** One Contentful entry renders its personalized variant in the raw server HTML and - remains stable after hydration. +- **Outcome:** One Contentful entry renders its personalized variant in raw server HTML and remains + stable after browser takeover. - **Verification:** Target all visitors, use View Source to find distinctive variant text, and confirm the same text remains after hydration. - **Reader shape:** An App Router application that already fetches a page and hands its entries to - app-owned components, with request-independent public chrome that can stay outside the private - request root. -- **Required artifacts:** Package install and browser-visible configuration; a server binding module - from `/app-router/server` that exposes the nested `request` component family; the - version-appropriate request handler with the verified narrow matcher; representative root-layout - and entry-renderer diffs using `optimization.request`; request-independent public chrome outside - the private request root and `Suspense`; a meaningful fallback; the page tracker and every - provider-dependent component inside the request root; the Contentful fetch constraints needed for - resolution; and a performable check of server HTML and hydration. On Next.js 15 and later, keep - `connection()` at the Cache Components private request boundary. On Next.js 13 to 14, omit the - import and call because that API is unavailable. Keep Next.js `Link` prefetch enabled. State that - bound Client Components use a separate `/app-router/client` binding when needed; do not add that - binding to the proof without a client-bound component. Do not add app-owned request caching, a - request shell, manual `headers()` or `cookies()` reads, URL parsing, route-key construction, - initial page-payload construction, or a performance option. + app-owned components, with request-independent public chrome outside the private request root. +- **Required artifacts:** Package install and browser-visible configuration; a client binding that + exports `RequestOptimizationRoot`; a server binding that injects that component into the nested + request family; the version-appropriate request handler with a narrow matcher; representative + root-layout and entry-renderer diffs; public chrome outside the private request root and `Suspense`; + a meaningful fallback; the single request root that coordinates browser route tracking; the + Contentful fetch constraints needed for resolution; and a server-HTML plus hydration check. On + Next.js 15 and later, keep `connection()` at the Cache Components private request boundary. On + Next.js 13 to 14, omit it. Keep Next.js `Link` prefetch enabled. Do not add app-owned request + caching, manual request reads, route-key construction, page-payload construction, or another page + tracker. - **Deliberate simplifications:** Consent is granted on server and browser only for the first proof; - the guide must point to the production consent section. + the guide points to the production consent section. - **Explainer switches:** profile point = omit; managed-fetch clause = include. - **Fact sources:** [setup and binding](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), @@ -38,104 +34,73 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app. [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution), [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), + [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay), [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), [fallback](../../internal/sdk-knowledge/shared/concepts.md#baseline-fallback). ## Milestone contract -- **Milestone 1:** Personalized first paint from one server render; independently useful without - post-load re-personalization. -- **Milestone 2:** Browser takeover and live updates after consent, identity, or profile changes. -- **Boundary:** Server-render-time behavior versus post-load browser behavior. +- **Milestone 1:** Personalized first paint from one server preview, followed by matching-route + delivery and a successful browser Experience commit. +- **Milestone 2:** Opt-in live updates after consent, identity, or profile changes. +- **Boundary:** Initial request preview and browser commit versus post-load re-personalization. - **Fact sources:** [runtime model](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), + [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay), [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates). ## 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 server, client, and request-family boundaries. | `/app-router/server`, `/app-router/client`, and router-neutral `/client`; separate runtime modules; inject the client `RequestOptimizationRoot` into the server request family; zero or more optional flat `request.initialExperienceEvents` inputs in application order before the SDK-appended page; visible no-JavaScript callout; no separate request tracker; SDK mechanism versus app policy. | [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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Choose who owns entry fetching | Required for first integration | Establish the app-owned-versus-SDK-managed entry boundary. | Executable manual and managed examples; `contentful: { client }`, request-root prefetch, and request `OptimizedEntry` together; object `managedEntry` alternative; managed cache/dedup ownership; fixed SDK keys versus app/model values; server-render versus handoff boundary. | [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding), [entry source](../../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 request family receives visitor context and where application policy enters. | No-argument forwarding handler; versioned filenames and narrow matcher; the handler performs no SDK/API, consent, profile, or cookie work; `consent.server`; route key, page payload, hydration, and private handoff; existing anonymous-ID input; browser-owned post-replay cookie persistence; no server preview cookie; no-JavaScript consequence; one cached request initializer. | [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), [runtime](../../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. | Renderer diff; skeleton union and narrowing; baseline behavior; shared initialization; managed-fetch overlap; dynamic-render and private-cache consequences; server selection is preview state until matching-route delivery receives a successful Experience response. | [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](../../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 server preview, private browser replay, and current-route ownership. | Request root applies accepted preview state in memory and makes the initial event decision in one operation; rendering proceeds before browser delivery completes; when the continuation cannot handle the route, the same call makes an ordinary page attempt; keep detailed fallback cases in Manual escape hatches; no replay prop/context/call; one root-owned route coordinator; server preview as Experience profile `POST type=preflight`, not CORS; recognizable non-preflight browser commit request and `events` order; durable persistence only after successful browser commit; reload/navigation duplicate check; before/after cookie observation. | [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Browser takeover and live updates | Required for first integration | Explain handoff-to-live behavior while marking re-personalization as opt-in. | Snapshot-to-live transition; the initial browser operation applies handoff state in memory and publishes the live runtime before delivery completes; only a later successful live Experience response establishes durable continuity; ordinary route-effect commit; opt-in controls; observable before/after state change; 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 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 automatic tracking behavior and browser-only boundaries explicit. | View/click/hover defaults, opt-outs, resolved-entry metadata, one realistic action check, and consent relationship. | [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [App Router components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks) | +| Consent, identity, profile, and reset | Common but policy-dependent | Replace the quick-start shortcut with application policy. | Server resolver, browser defaults/actions, app-owned consent record, profile cookie ownership, identity/reset example, and browser persistence after a successful Experience commit. | [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](../../internal/sdk-knowledge/web/react-web.md#consent--persistence) | +| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Early subscription seam, `messageId` dedupe, cleanup, failure containment, and guide link. | [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [Web observables](../../internal/sdk-knowledge/web/web.md#consent--persistence) | +| Merge tags and Custom Flags | Optional | Cover content-model-dependent personalization beyond entry replacement. | Complete Rich Text merge-tag example, hook alternative, observable Custom Flag example, and authoring links. | [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [React Web rendering](../../internal/sdk-knowledge/web/react-web.md#render--entry-resolution), [shared entry resolution](../../internal/sdk-knowledge/shared/concepts.md#entry-resolution) | +| Preview panel | Optional | Provide environment-gated authoring and debugging. | Installation and attach flow, readiness/environment/error handling, browser safety, and forced-variant check. | [React Web runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks), [Web 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. | Strategy table; private-slot composition; version-appropriate `connection()`; nested request family for private request rendering; top-level server family for static/public/analytics handoffs; client surfaces for browser-owned routes; supplemental-guide routing. | [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks), [runtime](../../internal/sdk-knowledge/web/nextjs-app-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 request-family path is understood. | API/ownership map; flat command inputs; App request config and Edge array-or-resolver boundaries versus array-only bound-manual and low-level server helpers; accepted low-level preview without a route key becomes a private state-only handoff followed by ordinary page tracking; replay-bearing handoff is one-shot; accepted page command owns matching browser delivery; mismatch, unusable payload, blocked page, or delivery failure falls through to an ordinary page attempt; no retain/retry; only successful browser commit establishes durable persistence; Edge has no preview-cookie write; handler remains forwarding-only. | [package and setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Caching and request deduplication | Advanced or production-only | Prevent profile-specific output and replay from entering shared caches. | Cache-safety table; private replay cannot enter public/static handoffs; request initialization versus managed fetch cache/dedup versus shared output caching; no duplicate awaits; supplemental-guide links; two-profile validation. | [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), [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-route policy into explicit production decisions. | Strict policy, blocked diagnostics, accepted navigation check, one route coordinator, replay/fallback behavior under denied consent, and consent withdrawal. | [consent](../../internal/sdk-knowledge/web/nextjs-app-router.md#consent--persistence), [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [React Web events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [Web failure](../../internal/sdk-knowledge/web/web.md#failure--fallback-behavior) | ## SDK-specific authoring overrides -- Put the request-handler filename/export choice in the quick start and repeat the failure symptom in - Troubleshooting; it can silently prevent request context from reaching the handoff. Facts: - [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks). -- Use the verified matcher first: `/`, `/page-two`, `/hidden-until-ready`, - `/static-shell-private-slot`, `/selection-handoff/:path*`, and `/analytics-only/:path*`. Keep the - handler no-argument and forwarding-only by default; response-capable trusted persistence is an - advanced opt-in. Facts: - [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks). -- Make the nested `optimization.request` family the private-request quick-start path. Do not ask the - application to call `createRequestHandoff()`, cache it, await it in multiple route segments, or - construct its request URL, route key, initial page payload, or shell. Facts: - [setup](../../internal/sdk-knowledge/web/nextjs-app-router.md#setup--initialization-and-binding). -- Put the dynamic-render consequence in the first-paint Core section, not only in caching. Facts: - [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks). -- Keep the request tracker inside the `Suspense` boundary Next.js requires. Do not describe that - rendering boundary as an SDK request-initialization workaround. Facts: - [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking). -- Prefer a private-slot composition: public request-independent chrome outside `Suspense`, a - meaningful fallback, and all provider-dependent UI inside the request root. On Next.js 15 and - later, keep `connection()` at the Cache Components private request boundary. On Next.js 13 to 14, - omit its import and call. Keep Next.js `Link` prefetch enabled. -- Keep “Browser takeover and live updates” in Core because the handoff model is required knowledge, - but distinguish that from enabling live updates, which is opt-in. Facts: - [components](../../internal/sdk-knowledge/web/nextjs-app-router.md#components--hooks). -- 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. -- 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 - version-appropriate `connection()` handling at the retained private request boundary. -- Scope verification by runtime. View Source proves server-rendered selection, the browser streams - prove local browser admission or blocking, and a reload followed by a prefetched `Link` navigation - checks duplicate first-page ownership. Do not claim that a browser current-value stream proves - server API delivery. -- Make browser capability sections executable: a router-neutral `/client` entry with per-entry - `liveUpdates` and a before/after identify/reset result; binding and per-entry interaction controls - with view/click/hover actions; a mounted consent/identity/profile/reset control; and a lifecycle-safe - analytics subscription with consent, `messageId` deduplication, cleanup, and failure containment. -- Define merge tags and Custom Flags before their examples. Show a complete Rich Text embedded-entry - renderer, the `useMergeTagResolver` alternative, and a subscribed `states.flag(name)` output with - an authored before/after value. -- Do not sketch an incomplete non-Cache-Components private-slot protocol. Route that reader directly - to the supplemental browser-owned personalization recipe. -- Define selected optimizations, Custom Flag changes, `permutationKey`, and `cacheVersion` before the - public-cache discussion, then require a two-browser-profile raw-HTML isolation check. The strict - policy example must show the exact `allowedEventTypes: []` seam, a blocked local diagnostic, and - an accepted local page event produced by a normal participating Next.js `Link` navigation after - consent. +- Put the request-handler filename/export choice in the quick start and repeat the silent failure + symptom in Troubleshooting. The handler only forwards sanitized request context. +- Make the injected client `RequestOptimizationRoot` plus nested server request family the private + request quick-start path. Do not add a separate request tracker or ask the app to build request + handoff inputs. +- Put the dynamic-render consequence in the first-paint Core section. +- Keep the private request root inside `Suspense`, with public request-independent chrome outside. + On Next.js 15 and later, keep `connection()` at the Cache Components private boundary. On Next.js + 13 to 14, omit it. Keep `Link` prefetch enabled. +- Use one sequence vocabulary: zero or more optional identify/track commands in application order, + the SDK-appended page, server preview state, one combined browser replay/page operation, + matching-route delivery, a successful Experience commit and persistence, then later routes. +- State that server preview does not write a new profile cookie. A successful browser Experience + response can persist continuity when consent permits. Put the no-JavaScript consequence in a + visible callout: server preview can still select HTML, but matching-route delivery and the new + browser cookie do not occur. +- Keep static, ISR, Edge, and analytics-only recipes in the supplemental rendering guide. +- Scope verification by runtime: View Source proves server preview selection; the recognizable + non-preflight browser `POST` and successful response prove the Experience commit; a normal `Link` + navigation checks the one route coordinator. ## Troubleshooting scope -| Reader symptom | Why it belongs | Fact sources | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Entries remain on baseline | Most common first-run ambiguity; connect authoring, consent, and payload shape. | [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior) | -| Authored variant never appears | Makes the all-visitors verification actionable. | [fallback](../../internal/sdk-knowledge/shared/concepts.md#baseline-fallback) | -| Entry component has a type error | The skeleton union or content-type narrowing is incomplete. | [rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution) | -| Server or browser sends duplicate/missing first page events | The handoff requires explicit event ownership. | [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | -| Request components report a missing forwarded request URL | The request handler or proxy did not supply the required request context. | [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior) | -| Live entries do not change after identity/reset | Distinguishes the default locked render from opt-in live updates. | [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | -| View/click/hover interaction events are missing | Exposes consent/profile prerequisites and per-entry tracking opt-outs. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking) | -| Server and browser use different profiles | Cookie scope and readability are cross-runtime integration work. | [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership) | -| Server code sees browser globals or personalized HTML is stale | These expose the defining server/client and cache boundaries. | [runtime quirks](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | +| Reader symptom | Why it belongs | Fact sources | +| ------------------------------------------------------------ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Entries remain on baseline | Common authoring, consent, or payload ambiguity. | [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior) | +| Request components report a missing forwarded request URL | Handler file/export/matcher failure. | [fallback](../../internal/sdk-knowledge/web/nextjs-app-router.md#failure--fallback-behavior) | +| Browser route events are duplicated | An app mounted a second tracker or direct page call. | [events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking) | +| Server preview renders but browser identity does not persist | Separates preview from replay, consent, and JavaScript behavior. | [identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership) | +| Live entries do not change after identity/reset | Distinguishes locked first paint from opt-in live updates. | [live updates](../../internal/sdk-knowledge/shared/concepts.md#live-updates) | +| Personalized HTML is cached for the wrong visitor | Exposes the private/public cache boundary. | [runtime](../../internal/sdk-knowledge/web/nextjs-app-router.md#version--runtime-quirks) | ## Link roles @@ -143,12 +108,5 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-app-router-app. - [The Next.js rendering supplemental guide](../../guides/rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md). - [The analytics-forwarding supplemental guide](../../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md). - [Optimization handoff and cache-safe rendering](../../concepts/optimization-handoff-and-cache-safe-rendering.md). -- [Entry personalization and variant resolution](../../concepts/entry-personalization-and-variant-resolution.md). -- [Locale handling](../../concepts/locale-handling-in-the-optimization-sdk-suite.md). -- [Profile synchronization](../../concepts/profile-synchronization-between-client-and-server.md). -- [Consent management](../../concepts/consent-management-in-the-optimization-sdk-suite.md). -- [Interaction tracking](../../concepts/interaction-tracking-in-web-sdks.md). -- [Contentful personalization authoring](https://www.contentful.com/developers/docs/personalization/). -- [Contentful Custom Flags authoring](https://www.contentful.com/help/personalization/experiences/custom-flags/). - [The maintained App Router reference implementation](../../../implementations/nextjs-sdk_app-router/README.md). -- [The maintained Pages Router reference implementation for comparison](../../../implementations/nextjs-sdk_pages-router/README.md). +- [The maintained Pages Router reference implementation](../../../implementations/nextjs-sdk_pages-router/README.md). diff --git a/documentation/authoring/blueprints/nextjs-pages-router.md b/documentation/authoring/blueprints/nextjs-pages-router.md index a6ba5bb7e..5fcc93d57 100644 --- a/documentation/authoring/blueprints/nextjs-pages-router.md +++ b/documentation/authoring/blueprints/nextjs-pages-router.md @@ -9,68 +9,77 @@ guide: ../../guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-ap ## Quick-start contract -- **Outcome:** One entry renders its personalized variant in server HTML and hydrates without a - baseline flash. +- **Outcome:** One entry renders its personalized variant in server HTML and remains stable through + browser takeover. - **Verification:** Target all visitors, find variant text in View Source, and confirm it remains after hydration. - **Reader shape:** A Pages Router page whose `getServerSideProps` fetches entries and passes them to app-owned components. - **Required artifacts:** Package install; browser binding; server binding with - `bindNextjsPagesRouterServerOptimization`; its returned `createRequestHandoff` helper; `_app.tsx` - diff; `getServerSideProps` diff; entry-renderer diff; verification. -- **Deliberate simplifications:** Consent is granted for the first proof; the quick start uses the - page's existing manual baseline fetch, so managed-entry client configuration, descriptor types, - and prefetch inputs begin in the Fetching Contentful entries section. + `bindNextjsPagesRouterServerOptimization`; its returned `createRequestHandoff`; `_app.tsx` root + diff with `routeKey` and lazy `buildPagePayload`; `getServerSideProps` diff; entry-renderer diff; + verification. Use one root-owned route coordinator and no separate page tracker. +- **Deliberate simplifications:** Consent is granted for the first proof; manual baseline fetching + leads, so managed-entry configuration begins in the Fetching Contentful entries section. - **Explainer switches:** profile point = omit; managed-fetch clause = include. - **Fact sources:** [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding), [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), + [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), + [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay), [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior). ## Milestone contract -- **Milestone 1:** Server-resolved first paint and matching hydration. -- **Milestone 2:** Opt-in browser re-personalization after hydration. -- **Boundary:** Serialized request handoff versus live browser-owned state. -- **Fact sources:** [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks). +- **Milestone 1:** Server-previewed first paint followed by matching-route delivery and a successful + browser Experience commit. +- **Milestone 2:** Opt-in browser re-personalization after takeover. +- **Boundary:** Serialized request preview/replay handoff versus later live browser state. +- **Fact sources:** [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), + [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay). ## 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 browser/server module split. | Import-path table; client and server binding modules; bridge from quick start; browser root owns current-route coordination. | [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. | `ManagedEntryDescriptor`; `contentful.client` in both bindings; request prefetch plus matching object `managedEntry`; fixed SDK keys versus app/model values; shared source helper; query and default slug-field behavior; exact lookup failures; real entry ID; 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 request preview reaches `_app.tsx`. | Bind server SDK; call returned `createRequestHandoff`; complete props merge; failure handling; zero or more already-resolved, flat request-derived `initialExperienceEvents` inputs in application order followed by the SDK page; state that this Pages helper has no resolver callback; existing-cookie input; no server preview `Set-Cookie`; successful browser Experience commit/persistence; no middleware/proxy; visible no-JavaScript callout. | [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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| The bound root and page events | Required for first integration | Install preview state, deliver the staged continuation, and track later routes. | One `OptimizationRoot` in `_app.tsx` with handoff, path-plus-search route key, and full-URL lazy page builder; no separate tracker; hydration applies accepted preview state in memory; current-page tracking waits for hydration and attempts browser delivery once; when the continuation cannot handle the route, the same call makes an ordinary page attempt; keep detailed fallback in Manual escape hatches; no replay prop/call; server preview as Experience profile `POST type=preflight`, not CORS; recognizable non-preflight browser commit request and `events` order; durable persistence only after successful browser commit; cookie and reload/navigation checks. | [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) | +| Personalizing entries | Required for first integration | Complete Milestone 1 at the renderer boundary. | Render-prop diff; skeleton union and 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; distinguish memory-only initial handoff hydration and matching-route delivery from opt-in live re-resolution; only a successful live Experience response establishes durable continuity. | [components](../../internal/sdk-knowledge/web/nextjs-pages-router.md#components--hooks), [React Web runtime](../../internal/sdk-knowledge/web/react-web.md#version--runtime-quirks) | +| Entry interaction tracking | Common but policy-dependent | Explain default browser interactions and consent. | Defaults, opt-outs, resolved-entry metadata, and one action check. | [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 consent cookie, server resolver, browser actions, browser-owned profile persistence after a successful Experience commit, and reset. | [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 before route-effect replay, `messageId` dedupe, and 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, and 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 table and links to static, ISR, Edge, and analytics-only recipes; public/static handoffs cannot carry replay. | [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. | API map; Pages and lower-level server inputs are already-resolved arrays, while App request config and Edge are resolver boundaries; accepted preview without route key becomes private state-only handoff; replay-bearing handoff is one-shot; accepted page command owns matching browser delivery; mismatch, unusable payload, blocked page, or delivery failure falls through to an ordinary page attempt; no retain/retry; successful browser commit establishes durable persistence; direct Node server-only methods commit on server under a different ownership model. | [package and setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Caching and request policy | Advanced or production-only | Keep personalized state, replay, and HTML out of shared caches. | Cache-safety table, request failure policy, replay private-only boundary, and links to handoff concept and supplemental 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; one root-owned route coordinator; replay/fallback behavior under denied consent; accepted navigation 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 -- State plainly that Pages Router server work happens in `getServerSideProps`, not middleware or a - proxy. Facts: [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks). -- Treat the props merge and Experience API failure path as teaching requirements, not incidental notes. - Facts: [setup](../../internal/sdk-knowledge/web/nextjs-pages-router.md#setup--initialization-and-binding). -- 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. +- State that Pages Router request preview happens in `getServerSideProps`, not middleware or a + proxy, and does not write preview identity to the response. +- Treat props merge and Experience API failure as teaching requirements. +- Use one sequence vocabulary: zero or more optional identify/track commands in application order, + the SDK-appended page, server preview state, a continuation staged by full browser hydration, + matching-route delivery, a successful Experience commit and persistence, then later routes. +- Keep one root-owned route coordinator. Do not show a separate `NextPagesAutoPageTracker` or + another page call in mainline integration guidance. +- State in a visible callout that without JavaScript, server-previewed HTML can render, but + matching-route delivery and the new browser profile cookie do not occur. +- Keep static, ISR, Edge, and analytics-only recipes in the supplemental rendering guide. ## Troubleshooting scope -| Reader symptom | Why it belongs | Fact sources | -| --------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Every entry stays on baseline | Common missing-props or payload symptom. | [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior) | -| Page returns 500 instead of baseline | Server helper failure must be handled by the app. | [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior) | -| Entry type error or missing/duplicate first page event | Common renderer and handoff mistakes. | [rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution), [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | -| Live content, profile continuity, or cached HTML is wrong | Covers the defining runtime boundaries. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks), [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | +| Reader symptom | Why it belongs | Fact sources | +| ------------------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Every entry stays on baseline | Common missing-props or payload symptom. | [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior) | +| Page returns 500 instead of baseline | Server preview failure must be handled by app. | [fallback](../../internal/sdk-knowledge/web/nextjs-pages-router.md#failure--fallback-behavior) | +| Browser route events duplicate | A second tracker or direct page call is mounted. | [events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking) | +| Server preview renders but browser identity does not persist | Separates preview, replay, consent, and JavaScript. | [identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | +| Live content or cached HTML is wrong | Covers live and cache boundaries. | [runtime](../../internal/sdk-knowledge/web/nextjs-pages-router.md#version--runtime-quirks) | ## Link roles diff --git a/documentation/authoring/blueprints/node.md b/documentation/authoring/blueprints/node.md index 42e7c006e..67252cb7d 100644 --- a/documentation/authoring/blueprints/node.md +++ b/documentation/authoring/blueprints/node.md @@ -31,26 +31,29 @@ guide: ../../guides/integrating-the-node-sdk-in-a-node-app.md ## Section map -| Section | Category | Purpose | Must teach or show | Fact sources | -| ------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Install and initialize the Node SDK | Required for first integration | Establish the process-level instance before request work. | Complete initialization module; environment ownership; one-instance rule. | [setup](../../internal/sdk-knowledge/node/node.md#setup--initialization-and-binding) | -| Bind request context and locale | Required for first integration | Teach the request-scoped inputs every later call consumes. | Request-to-context adapter; locale precedence; ownership statement. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking), [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | -| Apply consent policy | Common but policy-dependent | Replace the quick-start shortcut with application policy. | Boolean and split-axis forms; blocked result; app-owned policy seam. | [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | -| Evaluate route requests with `page()` | Required for first integration | Explain the main request evaluation and its result envelope. | Request example; result-field glossary; accepted-versus-blocked branch. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | -| Identify known users | Common but policy-dependent | Place authentication identity in the request sequence. | Identify-before-page sequence and app-owned traits example. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | -| Persist profile identity between requests | Common but policy-dependent | Make continuity explicit for a stateless SDK. | Read/write lifecycle; consent gate; cookie ownership and hybrid note. | [identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership), [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | -| Fetch and resolve Contentful entries | Required for first integration | Turn request selections into rendered content. | Manual path and imperative managed ID/direct-object slug paths; concrete route-parameter slug example; SDK property names versus app/model values; `entryQuery` and default slug field; isolated exact not-found/duplicate templates with placeholder explanation; real `sys.id`; slug prefetch handoff nests the descriptor under `managedEntry`, retains fetched `sys.id` as `entryId`, and matches observable source values; skeleton/fallback/cache boundaries. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [fallback](../../internal/sdk-knowledge/node/node.md#failure--fallback-behavior) | -| Resolve merge tags | Optional | Cover profile-backed Rich Text substitution. | Guard/resolver example and fallback behavior. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | -| Read Custom Flags | Optional | Cover authored flag changes without implying event emission. | Flag read example and side-effect boundary. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | -| Track server-side interactions and business events | Optional | Separate server-owned tracking from browser interactions. | `track()` and `trackView()` examples; profile requirement. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | -| Forward optimization context to analytics | Optional | Hand off to the supplemental workflow. | Context fields and supplemental-guide link. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | -| Share continuity with the Web SDK | Optional | Explain the cross-runtime profile handoff. | Shared-cookie contract; duplicate-page ownership; reference link. | [identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership) | -| Control pre-consent event admission and request options | Advanced or production-only | Expose strict policy and diagnostics after the default flow. | Empty allow-list; blocked callback; request-scoped options. | [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence), [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | -| Keep caches safe for personalized rendering | Advanced or production-only | Prevent visitor-specific output from entering shared caches. | Cache-safety table and cache-key boundary. | [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | +| Section | Category | Purpose | Must teach or show | Fact sources | +| ------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Install and initialize the Node SDK | Required for first integration | Establish the process-level instance before request work. | Complete initialization module; environment ownership; one-instance rule. | [setup](../../internal/sdk-knowledge/node/node.md#setup--initialization-and-binding) | +| Bind request context and locale | Required for first integration | Teach the request-scoped inputs every later call consumes. | Request-to-context adapter; locale precedence; ownership statement. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking), [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | +| Apply consent policy | Common but policy-dependent | Replace the quick-start shortcut with application policy. | Boolean and split-axis forms; blocked result; app-owned policy seam. | [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | +| Evaluate route requests with `page()` | Required for first integration | Explain the main request evaluation and its result envelope. | Request example; result-field glossary; accepted-versus-blocked branch. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | +| Identify known users | Common but policy-dependent | Place authentication identity in the request sequence. | Identify-before-page sequence and app-owned traits example. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | +| Persist profile identity between requests | Common but policy-dependent | Make continuity explicit for a stateless SDK. | Read/write lifecycle; consent gate; cookie ownership and hybrid note. | [identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership), [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence) | +| Fetch and resolve Contentful entries | Required for first integration | Turn request selections into rendered content. | Manual path and imperative managed ID/direct-object slug paths; concrete route-parameter slug example; SDK property names versus app/model values; `entryQuery` and default slug field; isolated exact not-found/duplicate templates with placeholder explanation; real `sys.id`; slug prefetch handoff nests the descriptor under `managedEntry`, retains fetched `sys.id` as `entryId`, and matches observable source values; skeleton/fallback/cache boundaries. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual), [fallback](../../internal/sdk-knowledge/node/node.md#failure--fallback-behavior) | +| Resolve merge tags | Optional | Cover profile-backed Rich Text substitution. | Guard/resolver example and fallback behavior. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | +| Read Custom Flags | Optional | Cover authored flag changes without implying event emission. | Flag read example and side-effect boundary. | [rendering](../../internal/sdk-knowledge/node/node.md#render--entry-resolution) | +| Track server-side interactions and business events | Optional | Separate server-owned tracking from browser interactions. | `track()` and `trackView()` examples; profile requirement. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | +| Forward optimization context to analytics | Optional | Hand off to the supplemental workflow. | Context fields and supplemental-guide link. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking) | +| Share continuity with the Web SDK | Optional | Explain the cross-runtime preview and browser continuation. | Contrast direct Node server-only commits with Node-plus-Web pairing; zero or more optional flat identify/track inputs in application order followed by the SDK page; show both command shapes; `createRequestHandoffFromPreview()`; app-owned handoff transport; define route key as path plus search and page URL as the full event URL before the example; server preview as Experience profile `POST type=preflight`, not CORS; accepted/blocked result; full Web hydration applies preview state in memory and stages one continuation; current-page tracking waits for hydration; accepted page command owns matching browser delivery; mismatch, unusable payload, blocked page, or delivery failure falls through to an ordinary page attempt; successful browser commit establishes durable persistence; separate reusable `trackCurrentRoute()` from one-time hydration and verify it twice after one hydration; cookie observation; visible no-JavaScript callout; reference link. | [events](../../internal/sdk-knowledge/node/node.md#events--tracking), [identifiers](../../internal/sdk-knowledge/node/node.md#identifier-ownership), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay), [handoff](../../internal/sdk-knowledge/shared/concepts.md#optimization-handoff) | +| Control pre-consent event admission and request options | Advanced or production-only | Expose strict policy and diagnostics after the default flow. | Empty allow-list; blocked callback; request-scoped options. | [consent](../../internal/sdk-knowledge/node/node.md#consent--persistence), [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | +| Keep caches safe for personalized rendering | Advanced or production-only | Prevent visitor-specific output from entering shared caches. | Cache-safety table and cache-key boundary. | [runtime](../../internal/sdk-knowledge/node/node.md#version--runtime-quirks) | ## SDK-specific authoring overrides - Use a server `process.env` note rather than the recipe's browser-visible environment convention. +- In the early runtime-routing summary, define the handoff's private replay as the SDK-owned, + route-bound continuation and a successful Experience commit as the non-preflight browser profile + response; do not defer these terms to the optional continuity section. - 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..140c19e02 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; accepted-route dedupe; one-tracker rule; paired sequence is zero or more optional flat identify/track inputs followed by the SDK page; concise hybrid consequence only: hydration applies preview state in memory, current-page tracking attempts browser delivery once, and the same call makes an ordinary page attempt when the continuation cannot handle the route; successful browser commit establishes durable persistence; route detailed fallback to the optional before-initial-page section and Next.js guides; visible no-JavaScript callout. | [events](../../internal/sdk-knowledge/web/react-web.md#events--tracking), [page events](../../internal/sdk-knowledge/shared/concepts.md#page-events) | +| 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 browser work before root-owned page tracking. | Put matching-route bypass and consent behavior before the callback example; adaptable owned-root `beforeInitialPage` alternative to the normal router tracker; after-live invocation; `identify`/`screen`/`track`; await returned work; app-owned path-plus-search `routeKey` versus full page URL from lazy `buildPagePayload`; timeout/error reporting; sole-page-owner rule; compare the staged continuation with the route the root is about to track immediately before the sequence runs; a match skips the callback, while navigation during asynchronous hydration follows mismatch behavior and the callback/page path; handoff state applies in memory; accepted page command owns browser delivery; current-page tracking waits for hydration, then uses same-call ordinary-page fallback for mismatch, unusable payload, blocked page, or delivery failure; consent blocking does not run the callback afterward; replay-less validation inline and server-handoff validation deep-linked to exact Next.js sections; framework hybrids put paired prefix commands in server `initialExperienceEvents`. | [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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| 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/blueprints/web.md b/documentation/authoring/blueprints/web.md index 2d782d31a..9a4845d16 100644 --- a/documentation/authoring/blueprints/web.md +++ b/documentation/authoring/blueprints/web.md @@ -29,23 +29,23 @@ guide: ../../guides/integrating-the-web-sdk-in-a-web-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 stateful browser instance. | Complete reusable singleton; config ownership; duplicate-instance warning. | [setup](../../internal/sdk-knowledge/web/web.md#setup--initialization-and-binding) | -| The SDK lifecycle: create, emit, resolve | Required for first integration | Teach the ordering that prevents silent baseline output. | Lifecycle diagram or ordered example; accepted-event checkpoint. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | -| Fetching Contentful entries | Required for first integration | Explain manual and managed entry sources. | Manual path and imperative managed ID/direct-object slug paths; `getEntry()` for a single ID versus `getEntries()` for slug lookup and eligible ID batches; `entryQuery` and default slug field; query precedence; isolated exact not-found/duplicate templates with placeholder explanation; real `sys.id`; slug prefetch handoff nests the descriptor under `managedEntry`, retains fetched `sys.id` as `entryId`, and matches observable effective source values; locale/include/client ownership. | [rendering](../../internal/sdk-knowledge/web/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. | Resolve/render example; result fields; baseline ID distinction and fallback. | [rendering](../../internal/sdk-knowledge/web/web.md#render--entry-resolution) | -| Page and route events | Required for first integration | Maintain selections across initial load and SPA navigation. | Initial page example; stable route-key dedupe; hybrid skip note. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking) | -| Consent and privacy handoff | Common but policy-dependent | Replace the quick-start consent shortcut. | Consent state transitions; app-owned record; denied behavior. | [consent](../../internal/sdk-knowledge/web/web.md#consent--persistence) | -| State subscriptions, locale changes, and re-rendering | Common but policy-dependent | Implement Milestone 2 for an imperative runtime. | Subscription cleanup; re-resolve trigger; locale ownership. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | -| Entry interaction tracking | Common but policy-dependent | Explain browser view/click/hover instrumentation. | Fixed view/hover timing; opt-outs; clickable-path override. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking) | -| Identity, profile, and reset | Common but policy-dependent | Integrate login and logout with browser state. | Identify/reset example and consent distinction. | [consent](../../internal/sdk-knowledge/web/web.md#consent--persistence) | -| Web Components entry rendering | Optional | Offer the declarative alternative to the class API. | Registration and manual/managed examples; ID and `content-type`/`slug`/`slug-field` sources; `entryQuery`; manual precedence and managed ambiguity; real `sys.id` tracking; producer `hidden` in original HTML retained while open/loading, then removed at selected/baseline/fallback commitment; selected empty stays hidden; live `[]` yields baseline content/tracking. | [components](../../internal/sdk-knowledge/web/web.md#components--hooks), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | -| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Merge-tag and flag examples with event behavior. | [rendering](../../internal/sdk-knowledge/web/web.md#render--entry-resolution) | -| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Subscription seam and supplemental-guide link. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking) | -| Preview panel | Optional | Add environment-gated authoring tooling. | Separate package; attach example; required input. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | -| Hybrid Node SSR and browser continuity | Advanced or production-only | Preserve profile and event ownership across runtimes. | Cookie and first-event contract; reference link. | [identifiers](../../internal/sdk-knowledge/web/web.md#identifier-ownership) | -| Strict consent, storage, and delivery controls | Advanced or production-only | Expose production privacy and delivery posture. | Empty allow-list; storage/queue settings; blocked diagnostics. | [consent](../../internal/sdk-knowledge/web/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 stateful browser instance. | Complete reusable singleton; config ownership; duplicate-instance warning. | [setup](../../internal/sdk-knowledge/web/web.md#setup--initialization-and-binding) | +| The SDK lifecycle: create, emit, resolve | Required for first integration | Teach the ordering that prevents silent baseline output. | Lifecycle diagram or ordered example; accepted-event checkpoint. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | +| Fetching Contentful entries | Required for first integration | Explain manual and managed entry sources. | Manual path and imperative managed ID/direct-object slug paths; `getEntry()` for a single ID versus `getEntries()` for slug lookup and eligible ID batches; `entryQuery` and default slug field; query precedence; isolated exact not-found/duplicate templates with placeholder explanation; real `sys.id`; slug prefetch handoff nests the descriptor under `managedEntry`, retains fetched `sys.id` as `entryId`, and matches observable effective source values; locale/include/client ownership. | [rendering](../../internal/sdk-knowledge/web/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. | Resolve/render example; result fields; baseline ID distinction and fallback. | [rendering](../../internal/sdk-knowledge/web/web.md#render--entry-resolution) | +| Page and route events | Required for first integration | Maintain selections across initial load and SPA navigation. | Initial page example; stable accepted-route dedupe; one current-route tracker; concise hybrid consequence only: hydration applies state in memory, current-page tracking attempts browser delivery once, and the same call makes an ordinary page attempt when the continuation cannot handle the route; route detailed fallback to the advanced hybrid section. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking), [page events](../../internal/sdk-knowledge/shared/concepts.md#page-events) | +| Consent and privacy handoff | Common but policy-dependent | Replace the quick-start consent shortcut. | Consent state transitions; app-owned record; denied behavior. | [consent](../../internal/sdk-knowledge/web/web.md#consent--persistence) | +| State subscriptions, locale changes, and re-rendering | Common but policy-dependent | Implement Milestone 2 for an imperative runtime. | Subscription cleanup; re-resolve trigger; locale ownership. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | +| Entry interaction tracking | Common but policy-dependent | Explain browser view/click/hover instrumentation. | Fixed view/hover timing; opt-outs; clickable-path override. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking) | +| Identity, profile, and reset | Common but policy-dependent | Integrate login and logout with browser state. | Identify/reset example and consent distinction. | [consent](../../internal/sdk-knowledge/web/web.md#consent--persistence) | +| Web Components entry rendering | Optional | Offer the declarative alternative to the class API. | Registration and manual/managed examples; ID and `content-type`/`slug`/`slug-field` sources; `entryQuery`; manual precedence and managed ambiguity; real `sys.id` tracking; producer `hidden` in original HTML retained while open/loading, then removed at selected/baseline/fallback commitment; selected empty stays hidden; live `[]` yields baseline content/tracking. | [components](../../internal/sdk-knowledge/web/web.md#components--hooks), [entry source](../../internal/sdk-knowledge/shared/concepts.md#entry-source-boundary-managed-or-manual) | +| Merge tags and Custom Flags | Optional | Cover content-model-dependent reads. | Merge-tag and flag examples with event behavior. | [rendering](../../internal/sdk-knowledge/web/web.md#render--entry-resolution) | +| Analytics forwarding | Optional | Hand off to the supplemental workflow. | Subscription seam and supplemental-guide link. | [events](../../internal/sdk-knowledge/web/web.md#events--tracking) | +| Preview panel | Optional | Add environment-gated authoring tooling. | Separate package; attach example; required input. | [runtime](../../internal/sdk-knowledge/web/web.md#version--runtime-quirks) | +| Hybrid Node SSR and browser continuity | Advanced or production-only | Preserve profile and event ownership across runtimes. | Node server preview plus private Web handoff; flat identify/track inputs in application order followed by the SDK page; Experience profile `POST type=preflight` versus CORS; accepted/blocked result; app-owned serialization and transport; route key versus page URL; memory-only hydration; accepted page command owns matching browser delivery; mismatch, unusable payload, blocked page, or delivery failure falls through to an ordinary page attempt; successful browser commit establishes durable persistence; expose reusable `trackCurrentRoute()`, call the hydration helper once, then invoke only the route call again for duplicate suppression; cookie observation; no-JavaScript callout; direct Node server-only boundary. | [identifiers](../../internal/sdk-knowledge/web/web.md#identifier-ownership), [handoff](../../internal/sdk-knowledge/web/web.md#setup--initialization-and-binding), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Strict consent, storage, and delivery controls | Advanced or production-only | Expose production privacy and delivery posture. | Empty allow-list; storage/queue settings; blocked diagnostics. | [consent](../../internal/sdk-knowledge/web/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..d77530632 100644 --- a/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md +++ b/documentation/authoring/migration-blueprints/experience-js-nextjs-app-router.md @@ -12,32 +12,32 @@ guide: ../../guides/migrating-experience-js-next-to-nextjs-app-router.md - **Use when:** A Next.js App Router app carries legacy Next.js, ESR, SSR plugin, or React experience.js wiring. - **Target result:** The app uses the App Router server binding's nested request family for request - context, server first paint, and page tracking, with the client binding only for bound Client - Components. + preview and first paint, plus its injected client request root to hydrate and stage the browser + continuation for ordinary route tracking. - **Guide file:** `documentation/guides/migrating-experience-js-next-to-nextjs-app-router.md` - **Write after:** `choosing-a-nextjs-migration-path-from-experience-js.md`. -- **First verification:** One dynamic App Router route renders an all-visitors variant and verifies - both accepted and denied-consent event paths. +- **First verification:** One dynamic App Router route renders an all-visitors variant, delivers its + private replay in the browser, and verifies denied-consent behavior. ## Migration route -| Legacy surface | Target route | Detail owner | -| -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Legacy Next provider and tracker | App Router server request family and forwarding handler | [App Router blueprint](../blueprints/nextjs-app-router.md) | -| SSR/ESR profile helpers | App Router request context and profile cookie | [App Router identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership) | -| React render hooks/components | App Router `OptimizedEntry` and browser takeover | [App Router rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution) | -| Legacy plugins | Supplemental plugin migration | `migrating-experience-js-plugins-and-preview.md` | +| Legacy surface | Target route | Detail owner | +| -------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Legacy Next provider and tracker | App Router request family and injected client request root | [App Router blueprint](../blueprints/nextjs-app-router.md) | +| SSR/ESR profile helpers | Server preview plus private browser replay | [App Router identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership) | +| React render hooks/components | App Router `OptimizedEntry` and browser takeover | [App Router rendering](../../internal/sdk-knowledge/web/nextjs-app-router.md#render--entry-resolution) | +| Legacy plugins | Supplemental plugin migration | `migrating-experience-js-plugins-and-preview.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 request evaluation to zero or more optional commands plus the SDK page, forced server preview state, a continuation staged during full browser hydration, matching-route delivery, successful Experience commit/persistence, and a visible no-JavaScript consequence while consent remains app-owned. | Request context, identifiers, consent, replay, and route 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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| 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, cache safety, and deep-link to the integration guide's exact browser commit, duplicate-route, and continuity-cookie checks. | 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..22cae7948 100644 --- a/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md +++ b/documentation/authoring/migration-blueprints/experience-js-nextjs-pages-router.md @@ -11,32 +11,32 @@ guide: ../../guides/migrating-experience-js-next-to-nextjs-pages-router.md - **Use when:** A Pages Router app uses `@ninetailed/experience.js-next`, SSR plugin behavior, or legacy React surfaces. -- **Target result:** The app uses the Pages Router SDK for server data functions, provider wiring, - page tracking, and target entry resolution. +- **Target result:** The app uses the Pages Router SDK for server preview, full browser hydration, + root-owned route tracking that commits a staged continuation, 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, + delivers its private replay in the browser, and verifies denied-consent behavior. ## Migration route -| Legacy surface | Target route | Detail owner | -| ------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | -| Next provider/tracker | Pages Router provider and page tracker | [Pages Router blueprint](../blueprints/nextjs-pages-router.md) | -| SSR plugin profile continuity | Pages Router `createRequestHandoff` and profile cookie | [Pages identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | -| React components and hooks | Pages Router `OptimizedEntry` and React Web hooks | [Pages rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | -| Third-party plugin integrations | Supplemental plugin migration | `migrating-experience-js-plugins-and-preview.md` | +| Legacy surface | Target route | Detail owner | +| ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Next provider/tracker | Pages Router root-owned route coordination | [Pages Router blueprint](../blueprints/nextjs-pages-router.md) | +| SSR plugin profile continuity | Pages Router request preview and private browser replay | [Pages identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | +| React components and hooks | Pages Router `OptimizedEntry` and React Web hooks | [Pages rendering](../../internal/sdk-knowledge/web/nextjs-pages-router.md#render--entry-resolution) | +| Third-party plugin integrations | Supplemental plugin migration | `migrating-experience-js-plugins-and-preview.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 root props, define `routeKey` as path-plus-search match/dedup identity and `buildPagePayload` as the full page URL event-data seam. | 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 `createRequestHandoff` in `getServerSideProps`; use zero or more optional commands plus the SDK page, preview state, a staged continuation, matching-route delivery, successful Experience commit/persistence, and a visible no-JavaScript consequence with one root-owned browser route coordinator. | Pages events, identifiers, consent, and replay. | [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), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| 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, cache boundaries, and use the integration guide's exact browser commit, duplicate-route, and continuity-cookie checks. | 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-nextjs-router-choice.md b/documentation/authoring/migration-blueprints/experience-js-nextjs-router-choice.md index 13c60badf..bce012c07 100644 --- a/documentation/authoring/migration-blueprints/experience-js-nextjs-router-choice.md +++ b/documentation/authoring/migration-blueprints/experience-js-nextjs-router-choice.md @@ -15,8 +15,8 @@ guide: ../../guides/choosing-a-nextjs-migration-path-from-experience-js.md - **Target result:** The reader picks the right target migration guide before changing code. - **Guide file:** `documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md` - **Write after:** None. -- **First verification:** The reader can name the target router path, first-page-event owner, - cookie/consent owner, and whether request-bound personalization can be dynamic. +- **First verification:** The reader can name the target router path, server-preview and browser- + replay owners, cookie/consent owner, and whether request-bound personalization can be dynamic. ## Migration route @@ -28,12 +28,12 @@ guide: ../../guides/choosing-a-nextjs-migration-path-from-experience-js.md ## Section plan -| Section | Purpose | Must route to | Fact sources | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Identify the current Next.js integration | Classify router, SSR plugin, ESR helpers, and profile cookie usage before target selection. | Legacy Next.js, SSR, ESR, and identifier facts. | [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) | -| Choose App Router, Pages Router, or manual hybrid | Give a default target by actual app router and supported target SDK package. | Target Next.js package guides and Node/Web fallback. | [App Router package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [Pages Router package](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points), [Node package](../../internal/sdk-knowledge/node/node.md#package--entry-points), [Web package](../../internal/sdk-knowledge/web/web.md#package--entry-points) | -| Route SSR and first page event ownership | Prevent duplicate or missing page evaluation when moving from legacy SSR plugin/tracker behavior. | Target guide sections for request context and first-event ownership. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [App events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [Pages events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [page events](../../internal/sdk-knowledge/shared/concepts.md#page-events) | -| Route cookie and profile continuity | Keep continuity decisions explicit across legacy `ntaid` and target app-owned persistence. | Target identifier sections. | [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence), [App identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [Pages identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | +| Section | Purpose | Must route to | Fact sources | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Identify the current Next.js integration | Classify router, SSR plugin, ESR helpers, and profile cookie usage before target selection. | Legacy Next.js, SSR, ESR, and identifier facts. | [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) | +| Choose App Router, Pages Router, or manual hybrid | Give a default target by actual app router and supported target SDK package. | Target Next.js package guides and Node/Web fallback. | [App Router package](../../internal/sdk-knowledge/web/nextjs-app-router.md#package--entry-points), [Pages Router package](../../internal/sdk-knowledge/web/nextjs-pages-router.md#package--entry-points), [Node package](../../internal/sdk-knowledge/node/node.md#package--entry-points), [Web package](../../internal/sdk-knowledge/web/web.md#package--entry-points) | +| Route SSR preview and browser replay ownership | Replace legacy server commit plus browser skip logic with target preview state, full handoff hydration that stages one continuation, matching-route delivery, a successful browser Experience commit/persistence owned by one route coordinator, and a visible no-JavaScript consequence. | Target request-handoff and route-tracking sections. | [Next.js SSR ESR](../../internal/migration-knowledge/experience-js.md#nextjs-ssr-and-esr), [App events](../../internal/sdk-knowledge/web/nextjs-app-router.md#events--tracking), [Pages events](../../internal/sdk-knowledge/web/nextjs-pages-router.md#events--tracking), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| Route cookie and profile continuity | Keep continuity explicit across legacy `ntaid`, app-owned consent, and target browser-owned SDK cookie persistence. | Target identifier sections. | [identifiers](../../internal/migration-knowledge/experience-js.md#identifiers-and-persistence), [App identifiers](../../internal/sdk-knowledge/web/nextjs-app-router.md#identifier-ownership), [Pages identifiers](../../internal/sdk-knowledge/web/nextjs-pages-router.md#identifier-ownership) | ## 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..471d78968 100644 --- a/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md +++ b/documentation/authoring/migration-blueprints/experience-js-node-ssr-esr.md @@ -11,32 +11,32 @@ guide: ../../guides/migrating-experience-js-node-ssr-and-esr.md - **Use when:** A server integration uses `@ninetailed/experience.js-node`, SSR plugin helpers, ESR helpers, or a manual server-to-browser handoff. -- **Target result:** The server uses the Node SDK for request-scoped evaluation and hands browser - continuity to the appropriate Web or framework SDK. +- **Target result:** The server uses direct Node event calls for server-only routes or a + preview/private-replay handoff for routes that continue in a Web or framework SDK. - **Guide file:** `documentation/guides/migrating-experience-js-node-ssr-and-esr.md` - **Write after:** `choosing-a-nextjs-migration-path-from-experience-js.md` for Next.js apps; otherwise None. -- **First verification:** One accepted request and one denied-consent request prove event results, - rendered content, cache boundaries, and any manual Node/Web cookie handoff. +- **First verification:** One accepted request and one denied-consent request prove direct Node + results or Node/Web preview/replay, rendered content, browser persistence, and cache boundaries. ## 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 | Preview state plus a continuation staged by Web hydration for matching-route delivery and browser commit | [Node events](../../internal/sdk-knowledge/node/node.md#events--tracking) | +| ESR helper preflight behavior | Framework request preview/replay where possible | 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 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 a framework SDK when available; otherwise distinguish direct Node server commits from a Node-plus-Web sequence of optional commands plus the SDK page, preview state, app-owned handoff transport, staged continuation, matching-route delivery, successful browser Experience commit/persistence, and the no-JavaScript consequence in a visible callout. | 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 events](../../internal/sdk-knowledge/node/node.md#events--tracking), [replay](../../internal/sdk-knowledge/shared/concepts.md#experience-preflight-and-private-replay) | +| 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, rendered content, cache safety, and use the Node guide's exact browser commit, duplicate-route, and continuity-cookie checks for paired routes. | 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/concepts/core-state-management.md b/documentation/concepts/core-state-management.md index cd0ca8561..9bc0eb3e2 100644 --- a/documentation/concepts/core-state-management.md +++ b/documentation/concepts/core-state-management.md @@ -391,13 +391,11 @@ runtime: | iOS and Android native | The JS bridge uses signal effects to push Core snapshots to Swift or Kotlin. Native handlers write consent and profile-continuity values to `UserDefaults` or SharedPreferences according to persistence consent. | Native public state is republished from bridge snapshots. Native storage is platform-owned, and the JS bridge remains the source for live Core state transitions. | | Node/stateless | The SDK has no shared state store. `forRequest()` binds consent, locale, and profile for one request. | The host application decides whether and where to persist returned profile continuity. | -Profileless static and public content or analytics handoff hydration applies selection state to live -Web memory without overwriting durable continuity. `private-request` handoffs, and profile-backed -handoffs that pass cache safety, follow normal Web persistence behavior when persistence consent -allows. -For content handoffs, an undefined or empty Web handoff state still marks -`experienceRequestState` as `success` and clears stale selected optimizations and changes while -preserving the existing profile unless the handoff explicitly includes `profile`. +Every full browser handoff applies its state to live Web memory without writing durable continuity, +whether it is static, public, private, profile-backed, or replay-bearing. A later successful live +Experience response can persist continuity when persistence consent allows. + +A private request replay is also cache-sensitive. The combined browser operation hydrates preview state in memory, submits server-built events with live consent and ordinary queue policy, and makes one replay/page decision. Newer handoffs preserve earlier admitted journals while state publication keeps latest-wins arbitration. Recoverable hydration errors permit safe delivery. Empty content handoff state marks `experienceRequestState` successful and clears selected optimizations and changes while preserving the profile unless explicitly supplied. When durable persistence consent is `false` or unset, profile-continuity values are not loaded for initial state and are not written as durable continuity for responses. The SDK can still publish 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..6574eae26 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,7 @@ 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 server-rendered integrations, the server previews one initial Personalization batch without committing it. The browser root applies preview state in memory and submits replay through the normal queues with live consent and interceptors. Analytics follows the Personalization batch using the available profile. The SDK attempts an ordinary page only if replay has not accepted one; the route tracker owns later pages. Without a bound root, provider handoff, manual handoff, or persisted profile, automatic entry interactions need a browser Experience response to populate profile state. If the Web SDK must read `ctfl-opt-aid`, do not mark that cookie as `HttpOnly`. Configure `path`, `domain`, and `SameSite` so the server route and browser code refer to the same profile. diff --git a/documentation/concepts/interaction-tracking-in-web-sdks.md b/documentation/concepts/interaction-tracking-in-web-sdks.md index 9937588ca..b8e52b1bc 100644 --- a/documentation/concepts/interaction-tracking-in-web-sdks.md +++ b/documentation/concepts/interaction-tracking-in-web-sdks.md @@ -434,13 +434,7 @@ 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. +For private request handoffs, the combined browser operation publishes preview state in memory and attempts the admitted replay with browser consent and event interceptors. It makes one ordinary-page fallback only if no replay page was accepted. Newer handoffs preserve earlier journals. Static handoffs have no replay; private analytics-only handoffs can carry one. Mount one root or route tracker per browser runtime so accepted and in-flight route deduplication also governs later navigation and StrictMode effect replay. Profileless static or public handoffs preserve durable browser continuity. ## Delivery and flushing @@ -450,7 +444,8 @@ explicitly with `optimization.flush()`. Experience events are sent immediately when the browser is online. When the browser is offline, Experience events are queued up to the configured offline maximum and replayed when the online -signal becomes `true`. +signal becomes `true`. A supplied multi-event batch is admitted as one unit: if it cannot fit, the +queue rejects the batch without retaining only part of it. The Web SDK wires browser lifecycle events into this queue model: diff --git a/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md b/documentation/concepts/optimization-handoff-and-cache-safe-rendering.md index ef7876e13..30af18ccb 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) +- [Replay and initial page ownership](#replay-and-initial-page-ownership) - [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) @@ -38,17 +38,17 @@ from live updates, and how analytics-only markup can still carry Optimization tr ## Runtime support -| Runtime surface | Handoff role | -| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-nextjs/app-router/server` | Binds explicit-input App Router server components and helpers plus the nested request component family. | -| `@contentful/optimization-nextjs/app-router/client` | Binds App Router browser roots, entries, trackers, and explicit handoff helpers. | -| `@contentful/optimization-nextjs/pages-router` and `/pages-router/server` | Binds Pages Router roots, `getServerSideProps` request handoff helpers, and public permutation handoff helpers. | -| `@contentful/optimization-nextjs/edge` | Configures Edge runtime request handoff and public permutation handoff helpers. | -| `@contentful/optimization-nextjs/request-handler` | Forwards sanitized request context through pass-through responses and can perform response-capable server page work before App Router Server Components render. | -| `@contentful/optimization-nextjs/cache-middleware` | Rewrites pass-through Next.js proxy or middleware requests to the public permutation cache key produced by the same metadata helper used by handoffs. | -| `@contentful/optimization-nextjs/tracking-attributes` | Produces server, static, and edge `data-ctfl-*` tracking attributes for manual rendering paths. | -| `@contentful/optimization-react-web` | Consumes content handoffs in `OptimizationRoot` and analytics-only handoffs in `OptimizationAnalyticsRoot`. | -| `@contentful/optimization-web` | Hydrates content handoffs into a live browser SDK and analytics-only handoffs into a narrow analytics runtime. | +| Runtime surface | Handoff role | +| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/app-router/server` | Binds explicit-input App Router server components and helpers plus the nested request component family. | +| `@contentful/optimization-nextjs/app-router/client` | Binds App Router browser roots, entries, trackers, and explicit handoff helpers. | +| `@contentful/optimization-nextjs/pages-router` and `/pages-router/server` | Binds Pages Router roots, `getServerSideProps` request handoff helpers, and public permutation handoff helpers. | +| `@contentful/optimization-nextjs/edge` | Configures Edge runtime request handoff and public permutation handoff helpers. | +| `@contentful/optimization-nextjs/request-handler` | Forwards sanitized request context through pass-through responses. | +| `@contentful/optimization-nextjs/cache-middleware` | Rewrites pass-through Next.js proxy or middleware requests to the public permutation cache key produced by the same metadata helper used by handoffs. | +| `@contentful/optimization-nextjs/tracking-attributes` | Produces server, static, and edge `data-ctfl-*` tracking attributes for manual rendering paths. | +| `@contentful/optimization-react-web` | Consumes content handoffs in `OptimizationRoot` and analytics-only handoffs in `OptimizationAnalyticsRoot`. | +| `@contentful/optimization-web` | Hydrates content handoffs into a live browser SDK and analytics-only handoffs into a narrow analytics runtime. | ## Inputs and constraints @@ -65,10 +65,12 @@ 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 `hydration`, the browser presentation policy for already-rendered content. A +private request handoff can also carry a replay envelope. The envelope is not general handoff state: +it is a one-shot browser-delivery instruction for the previewed server event batch. -- `hydration` - the browser presentation policy for already-rendered content. -- `initialPageEvent` - whether the browser emits or skips the first page event for this route. +The application owns serialization and transport of the handoff between its server render and browser +entry point. The SDK consumes the supplied handoff; it does not choose an application transport. 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 +102,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. @@ -168,10 +170,10 @@ invalidation labels. When supplied to the Next.js helpers or middleware metadata include commas. App Router Cache Components can pass short custom tags to `cacheTag()`. Pages Router ISR and Edge runtime public routes can omit tags unless the app wires tag invalidation. For public permutation middleware, existing middleware or proxy rewrites, redirects, or other -terminal responses are returned unchanged. The request-context handler is different: it preserves -an existing rewrite response while still applying SDK request context and eligible profile-cookie -persistence. Pass-through responses keep flowing through the Optimization rewrite or -request-context path. +terminal responses are returned unchanged. The request-context handler is context-only: it preserves +an existing rewrite response while applying SDK request context, but it does not perform a server +event request or write profile cookies. Pass-through responses keep flowing through the Optimization +rewrite or request-context path. For selected-optimization shape, content model, variant-index, and fallback details, see [Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md). For @@ -200,36 +202,35 @@ content for stable first paint and still keep live updates off. Turn live update content must react to consent, identity, profile, or preview changes after hydration. Preview state can force live re-resolution for authoring flows. -When Web or React Web hydrates a profileless `static` or `public-permutation` content or analytics -handoff, the handoff state can affect live browser memory for that page, but the SDK preserves -existing durable browser profile continuity by suppressing durable continuity persistence for that -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. +Every full browser handoff applies its state in live memory only during hydration, regardless of +cache scope, profile state, or replay. Hydration does not update durable continuity. A later +successful live Experience response can persist continuity when persistence consent allows. + +## Replay and initial page ownership + +For a private Node or Next.js request, the server previews the supplied `identify` and `track` +commands, then appends one SDK-created `page` command. Prefix commands are flat objects: +`{ type: 'identify', userId, traits? }` and `{ type: 'track', event, properties? }`. Preview +evaluates the resulting state but does not commit those events or write a server preview cookie. The +private handoff carries that batch for browser replay. The caller documents the intended command +order; replay does not add versions, schemas, canonicalization, or general order and count +enforcement. + +The combined browser operation applies preview state in memory, then submits the server-built Personalization batch with live consent and ordinary event interceptors. Analytics follows that batch using its returned or initially known profile. Input order is retained within Personalization; cross-transport interleaving and intermediate profiles are not reconstructed. Event locale remains metadata and does not split the request. A successful live Experience response can persist continuity when persistence consent permits it. + +Server-built events retain their IDs, timestamps, channel, library metadata, request context, and +server interceptor changes. The browser does not regenerate those fields. Browser event interceptors +can still alter payloads through ordinary queue policy. Analytics is built on the server without +server delivery, then committed from the browser after Personalization. + +Repeated calls with the same handoff share one completion. Distinct handoffs retain their admitted journals even when newer state arrives. A mismatch, unusable replay, blocked page, or delivery failure permits one ordinary-page attempt only when no page was accepted. Later Analytics failure cannot duplicate an accepted page. Recoverable hydration errors permit safe browser delivery; teardown stops work that has not started. + +The server produces wire events and the browser root owns their initial replay/page decision. Keep one root or route tracker per browser runtime. A root with `beforeInitialPage` skips that callback after an accepted matching page replay; otherwise it runs the callback before the ordinary page attempt. Preview-backed content renders independently of this delivery. When a Next.js request has no route identity, preview state can still hydrate but the handoff has no page replay. + +Static and public-permutation handoffs do not carry a private replay. A private-request +analytics-only handoff can carry one and follows the same one-shot continuation rules. Without a +replay, the browser route tracker emits the current page when its normal consent and deduplication +rules allow. ## Analytics-only handoff and tracking attributes @@ -242,11 +243,11 @@ 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 carry private replay without providing content resolution. Mount one +analytics route owner per browser runtime. Its combined operation hydrates state and owns initial +delivery, while the ordinary route tracker deduplicates later pages. Newer hydration controls state +publication but preserves earlier admitted journals. A runtime lifetime guard stops work that has +not started after teardown. 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 @@ -257,7 +258,8 @@ markup remains private to the request. Profile state is visitor-specific. Request-backed selected optimizations, Custom Flag changes, merge tag values, and rendered personalized HTML can all depend on that profile. If that state enters a shared public cache, another visitor can receive the wrong variant, wrong Custom Flag state, wrong -merge-tag output, or a page-event handoff that was created for a different profile. +merge-tag output, or a replay envelope that was created for a different profile. Replay is valid +only for a private request handoff and must never enter a static or public-permutation cache. Use request-backed handoffs for private request rendering. Use public permutation handoffs for cacheable app-owned permutations. Cache raw Contentful baseline entries according to your diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 1eb18e72b..0956c9532 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -101,9 +101,8 @@ 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** - Server request helpers preview state before the browser commits the paired + replay. The preview is not the durability path for a profile ID you intend to continue. - **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 +303,9 @@ 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. +Request helpers use preview internally so the browser can commit the paired replay. Browser handoff +hydration applies that state in memory only; a later successful live Experience response is the +durable-continuity path. Do not treat a preview response as persisted server profile continuity. ## Browser-side mechanics @@ -510,17 +509,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 is treated as a commit | A preview evaluates the batch but does not persist it or write a preview cookie. | Let the browser attempt the one-shot paired replay when browser consent permits it; if it cannot be used, ordinary current-page tracking continues. Keep the replay private to that request. | +| 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/guides/choosing-a-nextjs-migration-path-from-experience-js.md b/documentation/guides/choosing-a-nextjs-migration-path-from-experience-js.md index fff0da647..c8524d406 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 @@ -35,7 +35,7 @@ Gather these inputs: - Whether the app renders through `app/`, `pages/`, or both. - Use of `@ninetailed/experience.js-next`, `@ninetailed/experience.js-next-esr`, SSR plugin helpers, route trackers, or `ntaid`. -- Where the first page event is emitted today: server, browser tracker, or both. +- Whether legacy code commits the first page on the server, browser, or both. - Where visitor identity is persisted and whether the browser must continue the same profile. - Whether the target route can be per-request dynamic. @@ -47,12 +47,23 @@ Use these terms consistently: - ESR means legacy edge-side rendering helpers from `@ninetailed/experience.js-next-esr`. - Manual Node/Web hybrid means the app uses the Node SDK on a custom server boundary and the Web or React Web SDK in the browser. +- Server preview means the server evaluates zero or more optional `identify`/`track` commands in + application-supplied order, followed by the SDK-appended `page` command. It returns preview state + without committing the sequence. The handoff's **private replay** is the SDK-owned, route-bound + continuation for the initial browser route. A **successful Experience commit** is the non-preflight + browser profile response; only that response can persist browser continuity. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but matching-route delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. ## Migration path 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. Replace server commit plus browser-skip logic with target server preview, private replay, and one + browser route coordinator. 4. Decide how profile continuity moves from `ntaid` to the target `ctfl-opt-aid` policy. 5. Follow the selected runtime migration guide. @@ -89,36 +100,32 @@ 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. +private replay, and route tracking that a manual hybrid would otherwise need to rebuild. -### Route SSR and first page event ownership +### Route SSR preview and browser replay ownership -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 commit page events on the first route and route changes while SSR helpers +also commit the first request. The target framework adapters instead create preview state on the +server, apply preview state in memory, and commit the admitted replay in the browser. -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. +In the target App Router path, the no-argument request handler forwards the original URL and sanitized context. The server binding's nested `optimization.request` family previews the optional commands followed by the SDK page in one request. Inject the client `RequestOptimizationRoot` and mount the nested `optimization.request.OptimizationRoot`. It applies private preview state in memory, owns the initial replay/page decision, and tracks later routes while rendering remains independent of delivery. -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'`. +In the target Pages Router path, bind the server SDK with `bindNextjsPagesRouterServerOptimization(config)` and call `createRequestHandoff(context, options)` inside `getServerSideProps`. Pass the handoff, stable route key, and lazy page builder to one `OptimizationRoot` in `_app.tsx`. The root hydrates preview state and makes the initial replay/page decision through ordinary queues and route deduplication. It owns later routes; do not add a separate tracker. -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. +The legacy `initialPageEvent` option is compatibility-only and inert. Remove explicit emit/skip +plumbing during migration instead of using it to coordinate the server and browser. ### Route cookie and profile continuity Legacy continuity commonly used `ntaid`. Target Web, React Web, and Next.js browser/framework SDKs use `ctfl-opt-aid` for the SDK-owned anonymous profile cookie. In a manual Node/Web hybrid, the Node -SDK only exports the `ANONYMOUS_ID_COOKIE` constant; app code must read, write, and clear that -cookie and pass the profile ID through `forRequest({ profile })`. Decide whether migration resets -visitor identity or whether the app reads the legacy cookie and writes the target continuity value -as a one-time operational handoff. +SDK exports the `ANONYMOUS_ID_COOKIE` constant; app code reads an existing cookie and passes the +profile ID through `forRequest({ profile })`. A hybrid route previews the initial sequence in Node, +creates a private replay handoff, and lets full Web hydration stage it for ordinary current-page +tracking. A successful browser Experience response commits the sequence and can write the target +cookie when persistence consent permits. A server-only Node route can commit events directly and +persist the returned profile ID in app code. Decide whether migration resets visitor identity or +performs a one-time operational handoff from the legacy cookie. The target consent record remains app-owned. Do not reuse `__nt-consent__` as if it were an SDK contract. @@ -126,18 +133,24 @@ 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 contains zero or more optional identify/track events in application-supplied + order, followed by the SDK-appended page. +- For the chosen router, run the exact browser commit, duplicate-route, and continuity-cookie checks + in the [App Router guide](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#the-bound-root-and-page-events) + or [Pages Router guide](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md#the-bound-root-and-page-events). +- Observe one successful browser Experience commit for the initial route, no duplicate request for + the same path plus search, and `ctfl-opt-aid` only after that response when persistence consent + permits it. - 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 | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser page events duplicate | Remove the legacy tracker, direct page call, and inert `initialPageEvent` plumbing; keep one target browser root as route coordinator. | +| Server variant renders but no target cookie appears | Run the selected guide's browser Experience commit check; confirm JavaScript ran and persistence consent is true. | +| 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/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md index cbda75435..65d0d147a 100644 --- a/documentation/guides/choosing-the-right-sdk.md +++ b/documentation/guides/choosing-the-right-sdk.md @@ -30,14 +30,26 @@ runtime-specific setup around providers, hooks, screen or route tracking, persis tooling, and platform defaults. Use lower-level packages only when you are building SDK layers, tooling, tests, or first-party integrations that need shared SDK primitives or raw API access. +A **server preview** evaluates zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. It returns preview state +without committing the sequence. The handoff's **private replay** is the SDK-owned, route-bound +continuation for the initial browser route. A **successful Experience commit** is the non-preflight browser profile +response; only that response can persist browser continuity. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but matching-route delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. + For mixed server and browser applications, use the adapter when one exists. A Next.js App Router app installs `@contentful/optimization-nextjs`; `/app-router/server` is its normal Server Component -import subpath. `/app-router/client` owns the Client Component binder and the direct -`NextAppAutoPageTracker` export; use its binder when the app needs bound Client Components. These -are entrypoints in one package, not separate packages to install. +import subpath. `/app-router/client` owns the Client Component binder and the client request root +that installs preview state and owns the initial replay/page decision. These are entrypoints in one package, not separate packages to install. Next.js Pages Router apps install the same package and use its `/pages-router` entrypoints. Non-Next.js server-rendered apps can combine `@contentful/optimization-node` on the server with -`@contentful/optimization-web` or `@contentful/optimization-react-web` in the browser. +`@contentful/optimization-web` or `@contentful/optimization-react-web` in the browser. Those manual +hybrids create preview state in Node and use a successful browser Experience request as the commit; +direct Node event calls remain the server-only ownership model. After choosing App Router or Pages Router, use [Render personalized Next.js routes with static, ISR, and edge handoffs](./rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md) @@ -74,8 +86,8 @@ Use this table to choose the primary package and the next integration guide: | Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides request-scoped profile evaluation, event emission, managed fetching and prefetching by ID or content type and slug, entry resolution, and Node caching guidance. | [Integrate the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) | | Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent, anonymous ID persistence, managed fetching and prefetching by ID or content type and slug, interaction tracking, event delivery, and Web Components. | [Integrate the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) | | React browser app outside Next.js integration | `@contentful/optimization-react-web` | It adds React providers, hooks, route tracking, optimized entry rendering from `entryId` or a content-type/slug `managedEntry`, interaction tracking, and live updates to the Web SDK. | [Integrate the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) | -| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Use `/app-router/server` for Server Components; `/app-router/client` owns the client binder and direct App Router tracker. | [Integrate the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | -| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs/pages-router` plus `/pages-router/server` | Its `/pages-router` components and `/pages-router/server` request handoff helper pass browser handoff through `pageProps` and avoid duplicate initial page events. | [Integrate the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | +| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | Use `/app-router/server` for request preview and Server Components, then inject the `/app-router/client` request root to hydrate preview state and own the initial replay/page decision. | [Integrate the Optimization Next.js SDK in a Next.js App Router app](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md) | +| Next.js Pages Router app with `getServerSideProps` personalization | `@contentful/optimization-nextjs/pages-router` plus `/pages-router/server` | Its server helper creates preview state in `getServerSideProps`; one browser root installs that state, delivers the private replay for the matching route, and tracks later routes. | [Integrate the Optimization Next.js SDK in a Next.js Pages Router app](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md) | | Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution. The entry-source subpath manages `baselineEntry`, `entryId`, or content-type/slug `managedEntry`; adapters own rendering, tracking, and runtime policy. | [Build a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) | | React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrate the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) | | Native iOS app built with SwiftUI | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrate the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) | diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md index 8fdc3a2e5..dd786d42b 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 @@ -50,6 +50,8 @@ page context with `forRequest()`. Your app keeps ownership of its Contentful cli sessions, cookies, identity, routing, caching, and rendering — the Node SDK holds no per-visitor state between requests. +On a paired server/browser route, Node produces preview state for the server render. The handoff's **private replay** contains server-built events for browser delivery. Web applies preview state in memory and owns the initial replay/page decision. A successful **browser commit** is the non-preflight Experience profile response; only that response can establish durable persistence when consent permits it. + The examples use Express, but the same request-scoped flow applies to any Node request handler. If you also want the browser to continue personalization after the server renders, see [Share continuity with the Web SDK](#share-continuity-with-the-web-sdk). For a browser-only app, use @@ -603,12 +605,17 @@ function clearOptimizationIdentity(res: Response): void { } ``` -`ANONYMOUS_ID_COOKIE` is an SDK-defined constant that resolves to the cookie name `ctfl-opt-aid`; -import it rather than hardcoding the string, and do not rename it — the browser Web SDK reads the -same name. Use this shared cookie when the same app also runs the Web SDK in the browser, and do not +`ctfl-opt-aid` is the exact SDK-owned cookie name exposed by the `ANONYMOUS_ID_COOKIE` constant. +Import the constant rather than hardcoding or renaming the string. The Node SDK does not write the +cookie; your app owns its server lifecycle, while the browser Web SDK reads the same name. Do not mark it `HttpOnly` in a hybrid Node + Web SDK app because browser-side SDK code must read it. In a server-only app, a session store or a stricter cookie policy can be valid. +The `persistProfile()` pattern above belongs to a direct Node-owned event flow. For an initial route +that continues through Web, read an existing cookie on the server but do not persist the server +preview result. The browser establishes durable persistence after a successful browser commit, as described in +[Share continuity with the Web SDK](#share-continuity-with-the-web-sdk). + For the lower-level mechanics, see [Profile synchronization between client and server](../concepts/profile-synchronization-between-client-and-server.md). @@ -1029,19 +1036,144 @@ guidance. **Integration category:** Optional 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. +Use the Node SDK alone when the server owns the whole response. In that server-only model, +`page()`, `identify()`, and `track()` are delivered from Node, and your application persists the +returned profile ID when `canPersistProfile` is `true`. + +A Node-plus-Web route uses one paired sequence: zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. The server preview sends +that sequence in an Experience profile `POST` with `type=preflight`. This is an SDK transport mode, +not a browser CORS preflight. It can select HTML without committing the events or a new profile +cookie. A page command blocked by consent produces `{ accepted: false }`, so the example below +returns no continuation. +`createRequestHandoffFromPreview()` binds the preview state and sequence to the route in an +SDK-owned private handoff. Private means request-scoped data that must not enter a public cache. +Each optional command is a flat input: identify uses `{ type: 'identify', userId, traits? }`, and +custom tracking uses `{ type: 'track', event, properties? }`. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but browser delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. + +Your application owns the server-to-browser serialization and transport for the handoff. Transport +the app-owned wrapper below through the same SSR data channel you use for other startup state. Keep +its `routeKey` separate from `pageUrl`: the route key is the path plus search string used to match +the private replay and deduplicate page delivery, while `pageUrl` is the full URL recorded as +event data. + +**Adapt this to your use case:** create a private handoff on the server. `userId`, `eventName`, +`routeKey`, and the page properties come from your request and application policy. + +```ts +import type { + CoreStatelessRequest, + InitialExperienceCommandInput, +} from '@contentful/optimization-node/core-sdk' +import { createRequestHandoffFromPreview } from '@contentful/optimization-node' +import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' + +async function createBrowserContinuation( + requestOptimization: CoreStatelessRequest, + routeKey: string, + pageUrl: string, + userId?: string, + eventName?: string, +): Promise< + { readonly handoff: ContentOptimizationHandoff; readonly routeKey: string } | undefined +> { + const events: InitialExperienceCommandInput[] = [] + if (userId !== undefined) events.push({ type: 'identify', userId }) + if (eventName !== undefined) events.push({ type: 'track', event: eventName }) + + try { + const preview = await requestOptimization.previewInitialExperience({ + events, + page: { properties: { url: pageUrl } }, + }) + + if (!preview.accepted) return undefined + + return { + handoff: { + ...createRequestHandoffFromPreview({ preview, routeKey }), + hydration: 'preserve-server', + }, + routeKey, + } + } catch (error) { + console.warn('Optimization preview failed; rendering the baseline.', error) + return undefined + } +} +``` + +`previewInitialExperience()` leaves API, interceptor, and event-schema failures observable. Catch it +at the route boundary, log it through your application diagnostics, and render the baseline with no +handoff. Keep cache and privacy checks fail-closed: do not serialize request-derived state into a +public or static response as a fallback. + +**Adapt this to your use case:** parse the app-transported continuation before browser startup, +hydrate it on the live Web instance, and use the same current-route call for initial and later +tracking. The call has only ordinary route inputs; replay remains inside the SDK. + +```ts +import ContentfulOptimization from '@contentful/optimization-web' +import { type ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' + +export async function startBrowserRuntime( + optimization: ContentfulOptimization, + continuation: + | { readonly handoff: ContentOptimizationHandoff; readonly routeKey: string } + | undefined, +) { + const delivery = optimization.hydrateAndTrackCurrentPage(continuation?.handoff, { + routeKey: continuation?.routeKey ?? `${window.location.pathname}${window.location.search}`, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) + void delivery.catch((error: unknown) => { + console.warn('Initial event delivery failed.', error) + }) +} + +export async function trackCurrentRoute( + optimization: ContentfulOptimization, + routeKey = `${window.location.pathname}${window.location.search}`, +): Promise { + await optimization.trackCurrentPage({ + routeKey, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) +} +``` + +`hydrateAndTrackCurrentPage()` applies preview state in memory and owns the initial delivery decision. A matching replay submits the server-built Personalization batch with live consent and ordinary interceptors, then sends Analytics through its ordinary queue. A mismatch, unusable payload, blocked page, or delivery failure permits one ordinary page only if no page was accepted. Recoverable hydration failure permits safe delivery; later Analytics failure cannot duplicate an accepted page. Repeated handoff calls share completion, and newer handoffs preserve earlier admitted events. Use `trackCurrentPage()` for later routes. + +When a cache-safe browser handoff cannot hydrate, discard it and continue with the same ordinary +page attempt. A cache-safety error remains fail-closed: do not apply the supplied handoff state or +replay. + +The server reads an existing `ANONYMOUS_ID_COOKIE` value into `forRequest({ profile })`, but it does +not write the preview's profile ID. A successful browser commit can establish durable persistence +and write `ctfl-opt-aid` when browser persistence consent permits it. Keep that cookie +browser-readable for later server requests. -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. +Verify the paired flow in the browser developer tools Network panel. Load the server-rendered route +and find one browser `POST` ending in `/profiles` or `/profiles/:id` with no `type=preflight` +query parameter. The separate server preview is an Experience profile `POST` with +`type=preflight`, not a CORS preflight. In the browser request body, inspect the `events` array and +confirm zero or more optional identify/track events appear in your supplied order, followed by the +page event. A successful response is the browser commit. Call `startBrowserRuntime()` once, then +call only `trackCurrentRoute(optimization)` again without changing path or search. The second route +call must not produce another profile `POST`; do not repeat `startBrowserRuntime()`, because that +helper hydrates the handoff. Finally, inspect browser cookies and confirm `ctfl-opt-aid` appears +only when persistence consent allows durable persistence. -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. +The Node SDK does not provide browser live updates or a preview UI. Keep those concerns in the Web +SDK or app-owned Contentful preview tooling. The [Node SSR + Web SDK reference implementation](../../implementations/node-sdk+web-sdk/README.md) -shows cookie sharing with `ANONYMOUS_ID_COOKIE` plus browser-side follow-up tracking and entry +shows the preview-to-replay handoff, browser-owned cookie persistence, follow-up tracking, and entry resolution. ## Advanced integrations @@ -1137,6 +1269,8 @@ Before releasing a Node SDK integration, verify these points: cannot be found. - Duplicate tracking prevention: server-rendered exposures, browser follow-up tracking, and third-party forwarding have one owner per event in your tracking plan. +- Node/Web paired routes: perform the initial replay, duplicate-request, and continuity-cookie + checks in [Share continuity with the Web SDK](#share-continuity-with-the-web-sdk). - Privacy and governance constraints: profile IDs, full profile objects, selected optimizations, changes, and analytics payloads are forwarded only to approved destinations. - Local validation path: run the server against mock or test credentials, load a route that calls @@ -1165,5 +1299,5 @@ snippets: `identify()`, `resolveOptimizedEntry()`, `getMergeTagValue()`, raw Contentful entry caching, and single-locale CDA requests. - [Node SSR + Web SDK Vanilla](../../implementations/node-sdk+web-sdk/README.md): consent-aware - cookie sharing with `ANONYMOUS_ID_COOKIE` for Node and Web SDK continuity, plus browser-side - follow-up tracking and entry resolution. + server preview, private browser replay, browser-owned profile persistence, follow-up tracking, + and entry resolution. 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..d7afedb4c 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 @@ -55,6 +55,19 @@ Before starting, attach a variant to that entry through an experience that targe Without an authored variant, a working integration still renders the baseline and cannot prove the personalization path. +A **server preview** sends zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page`, in an Experience profile `POST` +with `type=preflight`. This is an SDK transport mode, not a browser CORS preflight. An accepted +preview returns in-memory state and a private, route-bound continuation; a consent-blocked page +returns no replay. Browser delivery happens through current-page tracking. A successful **browser +commit** is the non-preflight Experience profile response; only that response can establish durable +persistence. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but browser delivery and a new +> `ctfl-opt-aid` cookie do not occur. `ctfl-opt-aid` is the exact SDK-owned cookie name. + 1. Install the package and keep `contentful` app-owned. **Copy this:** @@ -76,43 +89,65 @@ personalization path. NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT=master ``` -3. Bind one app-local server Optimization module. This binding shares one configured helper set for - the app. Its nested `optimization.request` family initializes the active request before any - request-bound component renders. - 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 - `preserve-server` hydration mode tells the browser to keep the server-rendered result while its - live runtime starts. +3. Bind the client request root in a Client Component module. It applies server preview state in memory and owns initial browser delivery and later route tracking. Preview rendering proceeds while events are pending. The consent defaults are a quick-start shortcut; replace them with application policy. **Adapt this to your use case:** ```tsx - // lib/optimization.ts - import { bindNextjsAppRouterServerOptimization } from '@contentful/optimization-nextjs/app-router/server' - import { contentfulClient } from './contentful' + // lib/optimization-client.ts + 'use client' - export const optimization = bindNextjsAppRouterServerOptimization({ + import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization-nextjs/app-router/client' + + const clientOptimization = bindNextjsAppRouterClientOptimization({ spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', locale: 'en-US', - contentful: { client: contentfulClient }, consent: { - server: { events: true, persistence: true }, clientDefaults: { consent: true, persistenceConsent: true }, }, - request: { hydration: 'preserve-server' }, }) + export const { RequestOptimizationRoot: ClientRequestOptimizationRoot } = clientOptimization + ``` + +4. Bind one app-local server Optimization module and inject the client request root. The nested + `optimization.request` family initializes the active request before any request-bound component + renders. `consent.server.events` allows the server preview, while + `consent.server.persistence` allows durable persistence after a successful browser commit. + `preserve-server` keeps the server-rendered result while the live browser runtime starts. Use the + same Contentful environment in both bindings. + + **Adapt this to your use case:** + + ```tsx + // lib/optimization.ts + import { bindNextjsAppRouterServerOptimization } from '@contentful/optimization-nextjs/app-router/server' + import { contentfulClient } from './contentful' + import { ClientRequestOptimizationRoot } from './optimization-client' + + export const optimization = bindNextjsAppRouterServerOptimization( + { + spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, + environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', + locale: 'en-US', + contentful: { client: contentfulClient }, + consent: { + server: { events: true, persistence: true }, + clientDefaults: { consent: true, persistenceConsent: true }, + }, + request: { hydration: 'preserve-server' }, + }, + { request: { OptimizationRoot: ClientRequestOptimizationRoot } }, + ) + export const { - NextAppAutoPageTracker: RequestNextAppAutoPageTracker, OptimizationRoot: RequestOptimizationRoot, OptimizedEntry: RequestOptimizedEntry, } = optimization.request ``` -4. Forward the original request URL so the request family can initialize. Use the +5. Forward the original request URL so the request family can initialize. Use the handler name for your Next.js version: Next.js 16 uses `proxy.ts` with `proxy`, and Next.js 13 to 15 uses `middleware.ts` with `middleware`. The body is the same. If the filename or export name does not match the Next.js version, Next.js does not run the handler and request context is not @@ -144,7 +179,7 @@ personalization path. } ``` -5. Split your shell into three responsibilities before adding the request root: +6. Split your shell into three responsibilities before adding the request root: - `AppShellChrome` renders request-independent navigation and keeps normal Next.js `Link` prefetch enabled. - `AppShellBody` contains UI that reads the Optimization provider. @@ -185,9 +220,10 @@ personalization path. } ``` -6. Wrap the request-dependent part of the route in the nested request root. Keep public, - request-independent chrome outside both the root and `Suspense`. Put the page tracker and every - component that reads the Optimization provider inside the root. The meaningful fallback keeps +7. Wrap the request-dependent part of the route in the nested request root. Keep public, + request-independent chrome outside both the root and `Suspense`. The root is the single route + coordinator, so do not add a separate page tracker. Put every component that reads the + Optimization provider inside it. The meaningful fallback keeps public navigation available while private content loads. On Next.js 15 and later with Cache Components, `connection()` marks the private slot as request-time work; keep its import and call at that boundary so the public shell remains separate from visitor-specific rendering. On Next.js 13 @@ -206,7 +242,7 @@ personalization path. +// Next.js 15+ with Cache Components. Omit this import on Next.js 13 to 14. +import { connection } from 'next/server' +import { Suspense } from 'react' - +import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '@/lib/optimization' + +import { RequestOptimizationRoot } from '@/lib/optimization' +async function PrivateRequestSlot({ children }: { children: React.ReactNode }) { + // Next.js 15+ with Cache Components. Omit this call on Next.js 13 to 14. @@ -214,7 +250,6 @@ personalization path. + + return ( + - + + {children} + + ) @@ -232,7 +267,7 @@ personalization path. } ``` -7. Wrap the entry where it becomes output. A **render prop** is the function child +8. Wrap the entry where it becomes output. A **render prop** is the function child `{(entry) => ...}`; it lets you render the resolved entry with your existing component. This shortcut assumes the baseline and every eligible variant use the `hero` content type. If a variant can use another content type, follow the skeleton-union and narrowing path in @@ -257,7 +292,7 @@ personalization path. } ``` -8. Verify the result. In Contentful, target the experience to all visitors and give the variant a +9. Verify the result. In Contentful, target the experience to all visitors and give the variant a distinctive text value. Run the app, open View Source, and find that variant text in the raw HTML. Then load the page normally and confirm the same text remains after hydration. @@ -331,167 +366,80 @@ The App Router server and client bindings centralize SDK configuration for route binding once in its own runtime-specific module. A binding creates reusable configured components; the nested `optimization.request` family keeps each visitor request's work and state separate. -The quick start uses only the server binding and request handler. The remaining paths support the -advanced route strategies taught later: +The quick start uses a server binding, an injected client request root, and a request handler. The +remaining paths support the advanced route strategies taught later: -| Import path | Use | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-nextjs/app-router/server` | Server binding; its nested `request` components personalize per visitor. Top-level roots accept app-supplied handoffs; the entry consumes its documented props and stored handoff state, while the tracker consumes its documented event-ownership prop | -| `@contentful/optimization-nextjs/app-router/client` | A configured component family for Client Components | -| `@contentful/optimization-nextjs/cache-middleware` | Rewrites routes for app-defined personalization choices that are safe to share publicly | -| `@contentful/optimization-nextjs/client` | Browser hooks, providers, and entry controls | -| `@contentful/optimization-nextjs/edge` | Request and handoff helpers for the Edge runtime | -| `@contentful/optimization-nextjs/request-handler` | Forwards the request URL and, in advanced trusted flows, compact server context | -| `@contentful/optimization-nextjs/tracking-attributes` | Adds tracking attributes when the browser tracks server-rendered markup without re-resolving it | +| Import path | Use | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-nextjs/app-router/server` | Server binding; its nested `request` components personalize per visitor. Top-level roots accept app-supplied handoffs | +| `@contentful/optimization-nextjs/app-router/client` | Configured Client Components, including the client request root injected into the server binding | +| `@contentful/optimization-nextjs/cache-middleware` | Rewrites routes for app-defined personalization choices that are safe to share publicly | +| `@contentful/optimization-nextjs/client` | Browser hooks, providers, and entry controls | +| `@contentful/optimization-nextjs/edge` | Request and handoff helpers for the Edge runtime | +| `@contentful/optimization-nextjs/request-handler` | Forwards the sanitized request URL; it does not call the SDK or persist a profile | +| `@contentful/optimization-nextjs/tracking-attributes` | Adds tracking attributes when the browser tracks server-rendered markup without re-resolving it | The package root is not an import path. Server Components use the server binding. A **bound Client Component** uses components created by the client binding rather than importing a router-neutral component directly. Create that separate binding only when a bound Client Component needs one; router-neutral hooks and per-entry browser controls continue to use `/client`. -**Adapt this to your use case:** keep browser-only binding code in a Client Component module, and -match the server binding's public configuration values. - -```tsx -// lib/optimization-client.ts -'use client' - -import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization-nextjs/app-router/client' - -export const clientOptimization = bindNextjsAppRouterClientOptimization({ - spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, - environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', - locale: 'en-US', -}) -``` - -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. - -```diff - // lib/optimization-client.ts - 'use client' - --import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization-nextjs/app-router/client' -+import { -+ bindNextjsAppRouterClientOptimization, -+ type BeforeInitialPageOptions, -+} from '@contentful/optimization-nextjs/app-router/client' +The quick-start client binding exports the request root used for private server handoffs. Other +bound Client Components can come from that same module. Router-neutral hooks and per-entry browser +controls continue to come from `/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) -+ if (userId !== null) await identify({ userId }) -+ await track({ event: CLIENT_READY_EVENT }) -+ }, -+ 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', -+ beforeInitialPage, - }) -+ -+export const { -+ RequestOptimizationRoot: ClientRequestOptimizationRoot, -+} = clientOptimization -``` +When the first request must identify a signed-in visitor or record an application event before the +page view, put those commands in the server binding's `request.initialExperienceEvents`. An +**initial Experience command** is an optional `identify` or `track` input that precedes the page. +The resolver receives SDK-derived `requestUrl` and `routeKey`; it can return zero or more commands +directly or as a Promise. Your application supplies them in their intended order, and the SDK +appends its `page` command. -The client binding with before-initial-page work exposes two content roots for different owners: - -- `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. - -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. +**Adapt this to your use case:** add request-derived commands to the existing server binding. +`app-user-id` and `client_ready` are app-owned query and event names in this example; replace them +with values from your authenticated request and event taxonomy. ```diff // lib/optimization.ts - import { bindNextjsAppRouterServerOptimization } from '@contentful/optimization-nextjs/app-router/server' -+import { ClientRequestOptimizationRoot } from './optimization-client' - import { contentfulClient } from './contentful' - - export const optimization = bindNextjsAppRouterServerOptimization({ - // your existing server config --}) -+}, { -+ request: { OptimizationRoot: ClientRequestOptimizationRoot }, -+}) - - export const { -- NextAppAutoPageTracker: RequestNextAppAutoPageTracker, - OptimizationRoot: RequestOptimizationRoot, - OptimizedEntry: RequestOptimizedEntry, - } = optimization.request -``` - -**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. - -```diff - // app/(request)/layout.tsx --import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '@/lib/optimization' -+import { RequestOptimizationRoot } from '@/lib/optimization' - - -- - {children} - + export const optimization = bindNextjsAppRouterServerOptimization( + { + // existing config + request: { + hydration: 'preserve-server', ++ initialExperienceEvents: ({ requestUrl }) => { ++ const url = new URL(requestUrl) ++ const userId = url.searchParams.get('app-user-id') ++ ++ return [ ++ ...(userId ? [{ type: 'identify' as const, userId }] : []), ++ { type: 'track' as const, event: 'client_ready' }, ++ ] ++ }, + }, + }, + { request: { OptimizationRoot: ClientRequestOptimizationRoot } }, + ) ``` -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. +The server preview evaluates the commands and SDK-appended page once. An accepted preview puts its +state and route-bound continuation in the private handoff; a consent-blocked page does not. +Hydration applies preview state in memory, and the first current-page call attempts browser delivery +once. If the continuation cannot handle that route, the same call makes an ordinary page attempt. +When a cache-safe handoff cannot hydrate or replay, the bound root discards it and makes that same +ordinary page attempt. +The automatic request family also degrades API, command-resolver, interceptor, and event-schema +failures to a profileless private baseline handoff, so the route can render and the browser makes its +normal page attempt. It does not soften invalid cache scope or cache-safety failures: those remain +fail-closed. +When the forwarded request URL is missing or malformed, the fallback omits server route inputs; the +injected client root or request page tracker derives the real browser route instead of emitting a +synthetic page event. +Keep this data in the private request handoff; public and static handoffs cannot carry it. The +[manual escape-hatch section](#manual-server-and-client-escape-hatches) covers the detailed fallback +cases. + +The server preview does not write a new `ctfl-opt-aid` cookie. A successful browser commit can +establish durable persistence when persistence consent permits. The binding config separates policy from mechanism: @@ -567,9 +515,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. Keep the request root as the only route coordinator while +applying the managed-entry changes. **Adapt this to your use case:** @@ -599,7 +546,6 @@ the managed-entry changes. return ( - + - + + {(entry) =>

{String(entry.fields.headline)}

} +
@@ -654,20 +600,17 @@ separate initialization and handoff state. Use the no-argument `createNextjsOptimizationContextHandler()` from the quick start for the default forwarding-only path. It sanitizes SDK-owned forwarded context and supplies the request URL header; -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 request family performs request preview. The handler performs no Experience API, consent, +profile, or cookie-persistence work. Keep the matcher narrow to the routes that use Optimization +request context. The SDK-owned anonymous ID cookie is `ctfl-opt-aid`. It stores the 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 both server and browser code can read it; do not use the SDK anonymous ID cookie as your consent -record. +record. Request preview can read an existing anonymous ID, but it does not write a preview cookie; +a successful browser Experience response can write continuity when persistence consent permits. ### Personalizing first paint on the server @@ -749,17 +692,15 @@ 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 -`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 -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 +provider the server preview and browser startup mode. The injected client request root derives the +route key and page payload, hydrates the live SDK, and owns browser delivery plus later route +tracking. Do not mount a second page tracker or +call the page API for the same route. Keep the root inside the Next.js-required `Suspense` boundary; +that boundary is a platform rendering requirement, not request-initialization plumbing. + +On this request-family path, the initial page command's `page.url` comes from the SDK-forwarded +request URL, and later browser page events use the root'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`, `utm_source` becomes `source`, `utm_medium` becomes `medium`, `utm_term` becomes `term`, and `utm_content` becomes `content`. Missing parameters stay absent. @@ -767,84 +708,40 @@ complete campaign source and maps it into `context.campaign`: `utm_campaign` bec ordered precedence available to lower-level or manual page calls, see [Campaign attribution in event context](../concepts/core-state-management.md#campaign-attribution-in-event-context). -Keep request-independent public chrome outside both `Suspense` and the request root. Put the tracker, -provider-dependent shell body, and all other SDK-dependent UI inside the root. Use a meaningful +Keep request-independent public chrome outside both `Suspense` and the request root. Put the +provider-dependent shell body and all other SDK-dependent UI inside the root. Use a meaningful fallback for the private slot so the public navigation and page context remain available while it 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. +The server request resource previews zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. An accepted preview puts +its state and one route-bound continuation in the private handoff. The browser root applies state in +memory and owns one initial replay/page decision; preview rendering proceeds during delivery. +If the continuation cannot handle the current route, the same call makes an ordinary page attempt. +No replay prop, React context value, or replay-specific method is part of the integration. -> [!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. +For browser delivery, the browser checks live consent and sends the server-built events through ordinary +interceptors and queues, using the Experience sequence as one batch, followed by Analytics through the normal browser queues. A successful browser commit can +establish durable persistence when persistence consent permits it. The hydrated preview remains +available for browser presentation while delivery completes. 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 -[Manual server and client escape hatches](#manual-server-and-client-escape-hatches) before using -these inputs. Use `optimization.OptimizationAnalyticsRoot` for analytics-only handoffs. +take an app-created route key and lazy page-payload builder. The root owns current-route tracking; +do not add a second tracker. 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 private 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. +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. `onStatesReady` +runs after hydration but before the route effect, so subscribers can observe locally accepted events +from browser delivery. Use browser Network tooling for commit evidence. **Adapt this to your use case:** @@ -886,28 +783,31 @@ export function OptimizationEventDiagnostics() { + - {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} - {children} ``` -The observer mounts in the browser after the root publishes its live SDK. Its accepted stream is a -local signal that the browser SDK admitted an event; it does not prove that a server event ran or -that either API received an event. Use three separate checks: +The observer mounts after hydration and before the root route effect. Its accepted stream is a local +signal that the browser SDK accepted an event; it does not prove API delivery. Use these separate +checks: - **Server render:** Use the quick-start View Source check to prove that the selected variant reached the raw server HTML. -- **Browser admission:** Trigger a tracked action and inspect the browser console for +- **Browser commit:** Inspect the browser Network panel for one browser `POST` whose path + ends in `/profiles` or `/profiles/:id` and whose URL has no `type=preflight` query parameter. The + separate server preview is an Experience profile `POST` with `type=preflight`, not a CORS + preflight. In the browser request body, inspect the `events` array: zero or more optional + identify/track events appear in your supplied order, followed by the page event. Treat a + successful response as the browser commit and durable-persistence point. +- **Browser acceptance:** Trigger a later 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. +- **Duplicate page prevention:** Reload the route and confirm one browser commit. Follow + a normal Next.js `Link` to another participating route and confirm one page event for that + navigation. More than one request for either route usually means app code mounted another tracker + or called the page API directly. -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. +The server preview and browser delivery are separate Experience profile `POST` requests. The +browser observer cannot prove either network outcome by itself. ### Browser takeover and live updates @@ -1007,7 +907,7 @@ setting for one entry. Keep the development observer from [The bound root and page events](#the-bound-root-and-page-events) mounted. Scroll the entry into view, hover over its wrapper, and click it. The browser console must show locally accepted `component`, `component_hover`, and `component_click` event types, or a blocked -record that names the denied method. This check proves local browser admission, not API receipt. +record that names the denied method. This check proves local browser acceptance, not API receipt. Analytics-only server/static/edge markup imports `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes` so the browser analytics runtime observes the @@ -1115,18 +1015,14 @@ also calls `resetUser()`. Resetting alone preserves consent and does not erase y ## Optional integrations -Looking for optional before-initial-page work? Because it changes request-root and first-page -ownership, its atomic setup starts in [How the SDK fits your app](#how-the-sdk-fits-your-app), and -its behavior and verification continue in -[The bound root and page events](#the-bound-root-and-page-events). - ### Analytics forwarding **Integration category:** Optional `onStatesReady` is the binding callback that receives the live browser SDK's observable state -surface before child auto-page effects run. `states.eventStream` exposes the most recent locally accepted -event and later accepted events; it is not a durable history. Each event's `messageId` is its unique +surface after handoff hydration and before the route effect makes its ordinary current-page call. +`states.eventStream` exposes the most recent locally accepted event and later accepted events; it is +not a durable history. Each event's `messageId` is its unique delivery identifier. The optional `event.optimization` field is stream-only attribution, and its `resolvedEntry` is the Contentful entry selected for that interaction. Its `sys.id` is that selected entry's ID, which the example passes downstream as `resolvedEntryId`. @@ -1371,9 +1267,6 @@ 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. - **Adapt this to your use case:** ```diff @@ -1382,8 +1275,6 @@ panel but keep `RequestNextAppAutoPageTracker` omitted. + - {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} - {children} ``` @@ -1460,16 +1351,15 @@ 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 injected client request root from the quick start. That root owns +the route; do not add a separate tracker. **Follow this pattern:** ```tsx // app/static-shell-private-slot/PrivateRequestSlot.tsx import { AppShellBody } from '@/components/AppShell' -import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '@/lib/optimization' +import { RequestOptimizationRoot } from '@/lib/optimization' // Next.js 15+ with Cache Components. Omit this import on Next.js 13 to 14. import { connection } from 'next/server' @@ -1479,7 +1369,6 @@ export async function PrivateRequestSlot() { return ( - @@ -1526,11 +1415,31 @@ 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. +Lower-level request handoff helpers follow the same sequence as the bound path: callers supply zero +or more flat `identify`/`track` inputs in application order, and the SDK appends `page`. The +App Router request config is a resolver boundary: `request.initialExperienceEvents` can be an +array or a resolver that receives `{ requestUrl, routeKey }`. The Edge helper likewise accepts an +array or a resolver that receives the Edge request snapshot. By contrast, the bound manual +`optimization.createRequestHandoff(...)` helper and the lower-level `/server` +`createNextjsRequestHandoff(...)` helper accept only an already-resolved command array. + +The public bound `optimization.createRequestHandoff(...)` helper converts operational consent and +preview failures to the same profileless private baseline handoff as the automatic request family. +The lower-level `/server` `createNextjsRequestHandoff(...)` helper remains strict, so catch its +rejection at your application boundary when that route needs baseline fallback. + +An accepted lower-level server preview produces a private handoff with preview state. When the helper derives a route key, it also carries a page replay. Without a route key, the browser applies state and attempts an ordinary page. The browser root owns both forms through one initial operation. Only a successful live browser response can establish durable persistence when consent permits it. + +For a replay-bearing handoff, a matching replay whose page command is accepted owns the route and +starts browser delivery. A route mismatch, unusable event payload, blocked page command, or +browser-delivery failure consumes the one-shot replay and falls through to an ordinary page attempt +in the same call. The SDK does not retain or retry that replay. + +The request handler remains forwarding-only even if compatibility options are +passed. It does not evaluate consent, call the Experience API, forward profile data, or persist a +cookie. + +Edge request handoffs leave preview identity unpersisted on the response. The browser root hydrates preview state and makes one replay/page decision. An accepted replay page suppresses ordinary fallback, including after later Analytics failure. A successful browser Experience response can establish durable persistence when consent permits it. Lower-level resolver calls keep selections as the optional second positional argument: `resolveOptimizedEntry(entry, selectedOptimizations)`. Managed fetch calls accept an ID or a @@ -1589,12 +1498,10 @@ 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 +root remains the only route coordinator. A blocked server preview carries no replay state, so the +browser makes its normal current-page attempt under browser consent. 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. @@ -1613,14 +1520,12 @@ 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 -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. +Keep `OptimizationEventDiagnostics` inside `RequestOptimizationRoot`. 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. A second page event for the same route usually means app code +mounted another tracker or called the page API directly. 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 +1538,14 @@ 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. +- Perform the server-render and browser commit checks in + [The bound root and page events](#the-bound-root-and-page-events). +- Reload once, then navigate with a normal `Link`; confirm one browser commit for the initial route + and one page event for the later route, with no duplicate request for either route. +- Before the browser commit, inspect cookies and confirm the server preview wrote no new + `ctfl-opt-aid`; after the successful browser response, confirm the cookie appears only when + persistence consent permits it. Repeat with JavaScript disabled if the application supports it + and confirm previewed HTML remains while browser delivery and the new cookie are absent. - 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 +1573,8 @@ 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 browser page events | App code mounted another tracker or calls the page API beside the request root | Keep the injected request root as the only route coordinator; remove the extra tracker or manual page call | +| Server variant renders but profile continuity is absent | JavaScript did not run, the browser commit failed, or browser persistence consent is false | Run the browser commit check above; the server preview does not write a new `ctfl-opt-aid` cookie | | 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..c195ed05e 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 @@ -50,6 +50,19 @@ This quick start assumes a Pages Router page already fetches a Contentful entry appears in View Source and stays stable after hydration. Consent is granted only to prove the wiring; replace it in [Consent, identity, profile, and reset](#consent-identity-profile-and-reset). +A **server preview** sends zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page`, in an Experience profile `POST` +with `type=preflight`. This is an SDK transport mode, not a browser CORS preflight. An accepted +preview returns in-memory state and a private, route-bound continuation; a consent-blocked page +returns no replay. Browser delivery happens through current-page tracking. A successful **browser +commit** is the non-preflight Experience profile response; only that response can establish durable +persistence. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but browser delivery and a new +> `ctfl-opt-aid` cookie do not occur. `ctfl-opt-aid` is the exact SDK-owned cookie name. + 1. Install the package and keep `contentful` app-owned. **Copy this:** @@ -67,15 +80,14 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- // lib/optimization.ts import { bindNextjsPagesRouterOptimization } from '@contentful/optimization-nextjs/pages-router' - export const { NextPagesAutoPageTracker, OptimizationRoot, OptimizedEntry } = - bindNextjsPagesRouterOptimization({ - 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 }, - }, - }) + export const { OptimizationRoot, OptimizedEntry } = bindNextjsPagesRouterOptimization({ + 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 }, + }, + }) ``` 3. Bind the server helper for `getServerSideProps`. The server entry point is separate from the @@ -100,27 +112,23 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- }) export async function getContentfulOptimization(context: GetServerSidePropsContext) { - const routeKey = context.resolvedUrl || context.req.url || '/' - return { handoff: await createRequestHandoff(context, { cache: { scope: 'private-request' }, hydration: 'preserve-server', - pagePayload: { properties: { path: routeKey } }, + pagePayload: {}, // the request context supplies the full page URL }), } } ``` -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`. It hydrates a request handoff, owns the initial replay/page decision, and tracks later routes. Do not add a separate page tracker. The `routeKey` is the path plus search string used to match and deduplicate pages; `window.location.href` supplies the full event URL. **Adapt this to your use case:** ```diff // pages/_app.tsx - +import { NextPagesAutoPageTracker, OptimizationRoot } from '@/lib/optimization' + +import { OptimizationRoot } from '@/lib/optimization' import type { AppProps } from 'next/app' +import { useRouter } from 'next/router' @@ -132,11 +140,10 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- return ( - + ({ properties: { path: routeKey } })} + + buildPagePayload={() => ({ properties: { url: window.location.href } })} + handoff={handoff} + routeKey={routeKey} + > - + + + ) @@ -319,13 +326,11 @@ extends the app-owned `getContentfulOptimization` wrapper with the descriptors f + context: GetServerSidePropsContext, + prefetchManagedEntries: readonly ManagedEntryDescriptor[] = [], +) { - const routeKey = context.resolvedUrl || context.req.url || '/' - return { handoff: await createRequestHandoff(context, { cache: { scope: 'private-request' }, hydration: 'preserve-server', - pagePayload: { properties: { path: routeKey } }, + pagePayload: {}, + prefetchManagedEntries, }), } @@ -408,8 +413,10 @@ 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`, previews zero or more optional `identify`/`track` commands in +application-supplied order followed by the SDK-appended `page` command, and returns a serializable +private browser `handoff`. An accepted preview includes in-memory state and a route-bound +continuation; a consent-blocked page includes neither. Configure `consent.server` explicitly. If it is omitted, Pages Router request consent resolves to `false`. @@ -418,29 +425,71 @@ 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 -is no middleware or proxy requirement for the Pages Router path. +The SDK-owned anonymous profile cookie is `ctfl-opt-aid`. The server helper can read an existing +value for continuity, but it does not write preview identity to the response. A successful browser +commit can establish durable persistence when persistence consent permits. 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 this path. + +Use `initialExperienceEvents` when request-derived identify or custom track commands must precede +the page. The Pages Router server helper accepts an already-resolved command array, not a callback. +Build the array from `getServerSideProps` context and application state before calling the helper. +Supply zero or more flat `identify`/`track` inputs in their intended order. The SDK appends its +`page` command. This differs from the App Router request config and the Edge helper, which can +resolve commands at their framework-owned request boundaries. + +**Adapt this to your use case:** add initial commands to the existing handoff call. `signedInUserId` +and `checkout_started` come from your application identity and event taxonomy. + +```ts +async function createSignedInHandoff(context: GetServerSidePropsContext, signedInUserId?: string) { + return await createRequestHandoff(context, { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + initialExperienceEvents: [ + ...(signedInUserId ? [{ type: 'identify' as const, userId: signedInUserId }] : []), + { type: 'track', event: 'checkout_started' }, + ], + pagePayload: {}, + }) +} +``` + +The server preview evaluates the optional commands and SDK-appended page for the first paint. +Hydration applies accepted preview state in memory, and the first current-page call attempts browser +delivery once. If the continuation cannot handle that route, the same call makes an ordinary page +attempt. When a cache-safe handoff cannot hydrate or replay, the bound root discards it and makes +that same ordinary page attempt. The +[manual escape-hatch section](#manual-server-and-client-escape-hatches) covers the detailed fallback +cases. + +The bound `createRequestHandoff()` also converts Experience API, server-consent resolver, +interceptor, and event-schema failures into a profileless private baseline handoff. The page keeps +rendering and the browser makes its normal page attempt. Invalid cache scope and cache-safety +failures remain fail-closed. ### The bound root and page events **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. - -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 -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 +The bound `OptimizationProvider` handles content SDK context, handoff, hydration mode, and managed- +entry prefetch for a subtree. Use the bound `OptimizationRoot` in `_app.tsx` because it also owns +current-route tracking. Pass a stable `routeKey` and lazy `buildPagePayload`; those props do not +belong on `OptimizationProvider`. Do not mount `NextPagesAutoPageTracker` beside the root. + +Keep the two route inputs distinct. `routeKey` is the path-and-search identity used for +deduplication; the lazy payload builder supplies the full browser URL as page-event data. On the +server, `createRequestHandoff()` derives the full request page URL from the Pages Router context, so +`pagePayload: {}` leaves that value intact. + +Campaign inputs follow the preview and browser route boundary. The `pagePayload` passed to +`createRequestHandoff` shapes the page command in the server preview and private replay. The root's +`buildPagePayload` shapes ordinary current-page events, including later route emissions. For +either payload seam, `campaign` is an optional top-level object with `name`, `source`, `medium`, `term`, and `content` fields, while `url` is nested under the optional `properties` object. When those inputs are absent, the server request, browser page provider, or -router tracker supplies `page.url` for the event it owns. +root route coordinator supplies `page.url` for the event it owns. For each event, the SDK chooses one whole campaign source in order: top-level `campaign`, then a `properties.url` containing at least one supported UTM parameter, then `page.url`. An explicit empty @@ -450,121 +499,9 @@ 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. +The private handoff carries the preview state used for HTML and server-built events for browser delivery. The root applies state in memory and makes one replay/page decision. A matching replay page suppresses ordinary fallback; otherwise the SDK attempts one ordinary page. No application replay receipt or error branch is needed. Newer handoffs preserve earlier admitted events. -```diff - // lib/optimization.ts --import { bindNextjsPagesRouterOptimization } from '@contentful/optimization-nextjs/pages-router' -+import { -+ bindNextjsPagesRouterOptimization, -+ type BeforeInitialPageOptions, -+} 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) -+ if (userId !== null) await identify({ userId }) -+ await track({ event: CLIENT_READY_EVENT }) -+ }, -+ onError: (error) => console.warn('Before-initial-page work failed.', error), -+} satisfies BeforeInitialPageOptions -+ -+export const { OptimizationRoot, OptimizedEntry } = - bindNextjsPagesRouterOptimization({ - spaceId: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID!, - environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', - locale: 'en-US', -+ beforeInitialPage, - // your existing browser config - }) -``` - -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. - -**Adapt this to your use case:** keep the handoff, current route key, lazy payload builder, and page -component from your existing `_app.tsx`. - -```diff - // pages/_app.tsx --import { NextPagesAutoPageTracker, OptimizationRoot } from '@/lib/optimization' -+import { OptimizationRoot } from '@/lib/optimization' - - ({ properties: { path: routeKey } })} - 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. +For browser delivery, the browser checks live consent and submits the server-built events through ordinary interceptors and queues, using one Personalization batch followed by Analytics through the normal queues. A successful browser Experience response can establish durable continuity when persistence consent permits it. Preview content renders while delivery is pending. Keep the handoff private: public and static handoffs cannot carry replay. Use the accepted and blocked event streams introduced in [Analytics forwarding](#analytics-forwarding) for a development-only ordering check. @@ -573,8 +510,8 @@ Use the accepted and blocked event streams introduced in complete event records and removes both subscriptions when the root tears down. ```diff - bindNextjsPagesRouterOptimization({ - // your existing browser config and beforeInitialPage +bindNextjsPagesRouterOptimization({ + // your existing browser config + onStatesReady: (states) => { + if (process.env.NODE_ENV !== 'development') return + @@ -590,22 +527,19 @@ complete event records and removes both subscriptions when the root tears down. + blocked.unsubscribe() + } + }, - }) +}) ``` -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 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. +`onStatesReady` runs after hydration and before the root route effect, so its subscribers can observe +locally accepted events from browser delivery. In the browser Network panel, find one +browser `POST` whose path ends in `/profiles` or `/profiles/:id` and whose URL has no +`type=preflight` query parameter. The separate server preview is an Experience profile `POST` with +`type=preflight`, not a CORS preflight. In the browser request body, inspect the `events` array: +zero or more optional identify/track events appear in your supplied order, followed by the page +event. Treat a successful response, not a local stream event, as the browser commit and +durable-persistence point. Then navigate once and use the streams to confirm one later page result. +Two page events for one route usually mean app code mounted another tracker or called the page API +directly. The streams prove local SDK acceptance or blocking, not API delivery. ### Personalizing entries @@ -728,10 +662,6 @@ bindNextjsPagesRouterServerOptimization({ ## Optional integrations -Looking for optional before-initial-page work? Because it changes first-page ownership, its -setup and verification live in -[The bound root and page events](#the-bound-root-and-page-events). - ### Analytics forwarding **Integration category:** Optional @@ -844,7 +774,6 @@ export async function getStaticProps({ params }) { selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }), }, hero: resolvedHero.isEmptyVariant ? null : resolvedHero.entry, @@ -873,6 +802,20 @@ escape hatches are `/server` for direct Node request control with `configureNextjsServerOptimization(...)` configures a stateless server runtime; it is not a request-isolation context. Manual flows still pass `handoff` to a React root. +The bound Pages Router `createRequestHandoff(context, options)`, the bound manual App Router +`createRequestHandoff(options)`, and the lower-level `/server` +`createNextjsRequestHandoff(...)` accept only already-resolved `initialExperienceEvents` arrays. +Resolve application inputs before calling those helpers. The App Router request config and Edge +request helper are the framework resolver boundaries that can accept callbacks. + +An accepted low-level server preview without a derived route key produces a private, state-only +handoff. Hydration applies that state in memory, then the browser performs ordinary current-page +tracking. When a handoff does carry replay, a matching replay whose page command is accepted owns +the route and starts browser delivery. A route mismatch, unusable event payload, blocked page +command, or browser-delivery failure consumes the one-shot replay and falls through to an ordinary +page attempt in the same call. The SDK does not retain or retry that replay, and only a successful +browser commit can establish durable persistence. + Lower-level resolver calls keep selections as the optional second positional argument: `resolveOptimizedEntry(entry, selectedOptimizations)`. Managed fetch calls accept an ID or a source object shaped as `{ contentType, slug, slugField?, entryQuery? }`. The ID overload receives @@ -919,9 +862,10 @@ 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. +return `false` from `consent.server` until your app-owned consent record is accepted. A blocked +server preview carries no replay state, so the browser root makes its normal current-page attempt +under browser consent. Use blocked-event diagnostics to verify denied events are dropped at the SDK +boundary, and keep the root as the only route coordinator. ## Production checks @@ -930,9 +874,14 @@ 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. +- Perform the browser commit check in + [The bound root and page events](#the-bound-root-and-page-events). +- Reload once, then navigate once; confirm one browser commit for the initial route and one page + event for the later route, with no duplicate request for either route. +- Before the browser commit, inspect cookies and confirm the server preview wrote no new + `ctfl-opt-aid`; after the successful browser response, confirm the cookie appears only when + persistence consent permits it. Repeat with JavaScript disabled if the application supports it + and confirm previewed HTML remains while browser delivery and the new cookie are absent. - 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 +890,15 @@ 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, 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 browser page events | App code mounted another tracker or calls the page API beside the root | Keep `OptimizationRoot` as the only route coordinator; remove the extra tracker or manual page call | +| Server variant renders but profile continuity is absent | JavaScript did not run, the browser commit failed, or browser persistence consent is false | Run the browser commit check above; the server preview does not write a new `ctfl-opt-aid` cookie | +| 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..20e109842 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 @@ -272,11 +272,6 @@ Use `useOptimization()` under the provider when a component needs the SDK instan outside `OptimizationRoot` or `OptimizationProvider`, and the provider-owned path withholds children until the SDK is ready. -Set `api.preflight = true` only for dry-run Experience API requests that aggregate a fresh profile -state on the server without persisting it, for example when validating configuration or exercising -targeting rules from a debug tool. It changes Experience delivery for the whole SDK instance, so -leave it off in normal application builds. - ### Consent and privacy-policy handoff **Integration category:** Common but policy-dependent 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..c7b7b0718 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 @@ -661,13 +661,9 @@ evaluate route-based experiences, so most integrations emit one on first load an change. React Web ships auto page trackers for common routers; each dedupes consecutive route keys, 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 -with the -[before-initial-page root](#run-work-before-the-initial-page-decision). +Use one route owner for each root. The common path mounts one router tracker. An owned root with +`beforeInitialPage` uses its built-in route coordinator instead, so replace the component tracker +with the [before-initial-page root](#run-work-before-the-initial-page-decision). 1. In normal tracker mode, mount one tracker inside `OptimizationRoot` and inside the router context it reads. Use the tracker that matches your router. @@ -749,10 +745,12 @@ 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"`). +For a server handoff, the server sends optional inputs followed by the SDK page in one Experience profile `POST` with `type=preflight`. This is an SDK transport mode, not browser CORS preflight. The root applies preview state in memory and owns the initial replay/page decision without delaying preview rendering. A successful **browser commit** is the non-preflight Experience response; only that response can establish durable continuity. Configure server preview in the corresponding Next.js guide. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but browser delivery and a new +> `ctfl-opt-aid` cookie do not occur. `ctfl-opt-aid` is the exact SDK-owned cookie name. ### Consent and privacy handoff @@ -928,31 +926,29 @@ behavior, see **Integration category:** Optional -Use `beforeInitialPage` when an owned `OptimizationRoot` must finish returned identity or custom -Experience event work before that root makes its initial page decision. This is an alternative to -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. +Use `beforeInitialPage` in a browser-owned or replay-less React root when identity or custom +Experience event work must finish before the root tracks the current page. This is an alternative +to the router tracker in [Page events and route tracking](#page-events-and-route-tracking), not an +addition to it. The root becomes the sole route owner for its subtree. Next.js request integrations +put zero or more optional `identify`/`track` commands in server `initialExperienceEvents` instead; +the SDK appends the page command. -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 -methods that stay bound when destructured: +After the owned runtime is live, the callback runs once per mounted root. The SDK-provided +`BeforeInitialPageClient` exposes three bound methods: - `identify` supplies the visitor ID and traits your app is allowed to send. - `screen` records a screen-view Experience event. - `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 the work the root waits for. +Return the work the root must await. An `async` callback returns one Promise automatically, so await +each initial operation inside it. Work started without being returned can finish after the page. Supplying `beforeInitialPage` changes the root's required props: `routeKey` and -`buildPagePayload` are required. `initialPagePayload`, an eager page data object computed before -later route changes, is not accepted. The lazy builder matters because the route can change while -callback work is pending. Your app owns `routeKey`: make it a stable identity for the current route -and update it when the router changes. Your app also owns `buildPagePayload`; keep it lazy so the -root reads current route data after the callback instead of capturing an eager initial payload. +`buildPagePayload` are required. Your app owns both values: update the path-plus-search `routeKey` +when the router changes, and keep the payload builder lazy so it reads the full page URL after the +callback. + +The initial operation hydrates preview state in memory, then registers `onStatesReady` subscriptions before emitting replay events. Preview-backed content remains visible while event delivery is pending. A matching page replay supplies the initial event work. Otherwise `beforeInitialPage` runs before the ordinary page attempt; callback completion gates that attempt rather than rendering. The operation reads current router inputs after asynchronous hydration and callback work. A newer handoff does not cancel earlier event delivery. If replay partially succeeds and then fails, an already accepted page prevents a duplicate fallback. **Adapt this to your use case:** replace the normal tracker in the router root from the earlier section. `app-user-id` and `client_ready` are app-owned identifiers in this example; replace them @@ -991,7 +987,7 @@ code remains yours. + ({ properties: { path: routeKey } })} ++ buildPagePayload={() => ({ properties: { url: window.location.href } })} + beforeInitialPage={beforeInitialPage} + onStatesReady={(states) => { + if (!import.meta.env.DEV) return @@ -1012,53 +1008,25 @@ code remains yours. ) - } +} ``` -The watchdog timeout bounds how long the root waits for returned callback work: - -| `maxWaitMs` value | Result | -| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Omitted | The root waits up to 3,000 ms. | -| Any positive finite number | The root waits up to that many milliseconds. | -| `0`, a negative number, `NaN`, `Infinity`, or `-Infinity` | Rendering synchronously throws `TypeError('beforeInitialPage.maxWaitMs must be a positive finite number.')` before the provider, callback, page, `onError`, or watchdog runs. | - -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 and does not retry the same route -immediately. - -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 -makes the direct page attempt. A page rejection also ends the initial sequence. The watchdog stops -waiting but does not cancel the callback or a request it already sent. Work started without being -returned is fire-and-forget activity and 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 -> [Live updates](#live-updates) only when a late replacement is the intended experience. +The root waits up to 3,000 ms by default; set a positive finite `maxWaitMs` when your startup budget +differs. Callback errors, rejected work, and timeouts reach `onError`, then the root continues to its +ordinary current-page call. The inline development-only `onStatesReady` observer logs locally accepted and blocked events and unsubscribes from both streams during cleanup. Set the example identity first with -`localStorage.setItem('app-user-id', 'guide-user')`, then reload. The callback's identify and -`client_ready` results must appear, as accepted or blocked calls, before at most one initial `page` -result. Navigate once after readiness and confirm one later `page` result. An initial `page` before +`localStorage.setItem('app-user-id', 'guide-user')`, then reload. In this replay-less example, the +callback's identify and `client_ready` results appear, as accepted or blocked calls, before one +initial `page` result. Navigate once and confirm one later `page` result. An initial `page` before the callback results or two initial page results usually means the normal tracker is still mounted. -These streams prove local SDK admission or blocking, not API delivery. Remove the observer after -this development check. +These streams prove local SDK acceptance or blocking, not API delivery. Remove the observer after +this development check. This check applies to the replay-less example above. For a server handoff, +use the matching browser Network and duplicate-route checks in the +[App Router guide](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#the-bound-root-and-page-events) +or +[Pages Router guide](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md#the-bound-root-and-page-events). `beforeInitialPage` belongs only to an owned content `OptimizationRoot`. An injected `OptimizationProvider` and `OptimizationAnalyticsRoot` do not accept it. Direct Web and Node @@ -1329,7 +1297,8 @@ The provider always renders its children — they are never withheld or unmounte `sdk` and no `handoff`, children render against the live injected SDK from the first render; `onStatesReady` alone does not add a snapshot phase. When a server, static, or edge renderer passes a content `handoff`, children render against that snapshot first and the provider hydrates the live SDK -from the same state after React commits. +from the same state after React commits. Handoff state is applied only in memory; a later successful +live Experience response is what can establish durable continuity. React Web validates handoff cache safety before children render from the initial snapshot runtime. If a public or static handoff contains profile state, the provider fails before showing @@ -1400,10 +1369,15 @@ Run these checks before release: `withAllLocales` / `locale=*` payloads to `OptimizedEntry` or the resolver hooks. - Confirm default-on vs opt-in startup matches policy, `allowedEventTypes` matches the pre-consent posture, and revoking consent blocks non-allowed events. -- Confirm the first page event and route-change page events deliver. In normal tracker mode, mount - one tracker per router tree. In `beforeInitialPage` mode, mount no tracker and confirm the root's - direct attempt plus built-in emitter do not duplicate the initial route. Confirm Strict Mode - remounts do not duplicate either mode. +- Confirm current-page and route-change events deliver. In component-tracker mode, mount one tracker + per router tree. In `beforeInitialPage` mode, mount no tracker. Confirm a replay-less root runs the + callback before one initial page, while a continuation matching the route the root is about to + track skips the callback and delivers once through the built-in coordinator. For server handoffs, + use the exact + [App Router](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#the-bound-root-and-page-events) + or + [Pages Router](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md#the-bound-root-and-page-events) + browser checks. Confirm Strict Mode remounts do not duplicate either mode. - Confirm baseline fallback renders when the Experience API fails, variants are missing, links are unresolved, or a payload is all-locale — and that `OptimizedEntry` stops showing loading after resolution settles or the 5-second reveal. 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..338e5029d 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 @@ -573,16 +573,24 @@ A **page event** signals that a page or route was viewed. The Experience API use evaluate route-based experiences and to return current selections, so most integrations emit one on first load and on every route change. +On a hybrid server-rendered route, server preview provides provisional state and server-built events for the browser. Call `hydrateAndTrackCurrentPage()` once for the initial replay/page decision and use `trackCurrentPage()` for later routes. Preview-backed content need not await delivery. Only a successful live Experience response can establish durable continuity. The [hybrid integration section](#hybrid-node-ssr-and-browser-continuity) covers this flow. + +> [!NOTE] +> +> On a hybrid server-rendered route without JavaScript, previewed HTML can still render, but +> browser delivery and a new `ctfl-opt-aid` cookie do not occur. `ctfl-opt-aid` is the exact +> SDK-owned cookie name. + 1. Call `page()` after SDK initialization for a multi-page app or the first SPA route. It returns `{ accepted, data }`; `{ accepted: false }` means consent or an SDK guard blocked the event. 2. In SPAs, use `trackCurrentPage({ routeKey, buildPayload })` on route changes. It deduplicates - consecutive identical route keys (a manual `page()` always emits when consent permits it). + consecutive accepted 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 - [Hybrid Node SSR and browser continuity](#hybrid-node-ssr-and-browser-continuity)). +4. In hybrid apps, hydrate the server's private handoff before starting the same + `trackCurrentPage()` route loop. The first call attempts browser delivery for the current route + or makes an ordinary page attempt. 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 context, whose URL is the current browser URL unless page input overrides it. The SDK writes @@ -611,7 +619,9 @@ for the supported parameters and full precedence rules. const result = await optimization.page() ``` -**Adapt this to your use case:** an SPA route tracker with stable route keys, wired to your router. +**Adapt this to your use case:** an SPA route tracker wired to your router. The `routeKey` is the +path plus search string used for deduplication; `properties.url` is the full page URL recorded as +event data. ```ts function getRouteKey(): string { @@ -1195,40 +1205,92 @@ 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. -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. -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. - -**Follow this pattern:** build the shared anonymous-id `Set-Cookie` on the server. +Use direct Node event methods only for server-only routes. Calls such as `page()` and `identify()` +commit on the server, and the application owns persistence of their returned profile ID. For a +route that continues in Web, the paired sequence is zero or more optional `identify`/`track` +commands in application-supplied order, followed by the SDK-appended `page` command. + +Follow this flow: + +1. Read an existing SDK-owned `ctfl-opt-aid` cookie and bind it as the request profile. The server + uses it as input; it does not write a new cookie from preview output. +2. Call the Node request client's `previewInitialExperience()` with the optional commands in their + intended order. Identify uses the flat input `{ type: 'identify', userId, traits? }`. Custom + tracking uses the flat input `{ type: 'track', event, properties? }`. The SDK appends the page + command. The server preview sends an Experience profile `POST` with `type=preflight`; this is an + SDK transport mode, not a browser CORS preflight. A page command blocked by consent produces + `{ accepted: false }`. +3. Build a private handoff with `createRequestHandoffFromPreview()`. Transport the handoff and its + stable route key through your application's existing SSR data channel, then initialize Web with + the same space, environment, locale, and consent policy. The application owns serialization and + transport. +4. Call `hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload })` on the live Web instance. It applies preview state in memory and attempts replay with browser context, consent, and interceptors. Render preview-backed content while its promise is pending. +5. Use `trackCurrentPage({ routeKey, buildPayload })` for later navigation. The initial operation attempts one ordinary page after mismatch, blocked page, unusable replay, or failure only if replay accepted no page. Later Analytics failure preserves page acceptance. + +The continuation wrapper below is produced by the Node-side flow in the +[Node SDK guide](./integrating-the-node-sdk-in-a-node-app.md#share-continuity-with-the-web-sdk). +Its `routeKey` is the path plus search string used to match the private replay and deduplicate +page delivery, while `window.location.href` supplies the full page URL. `renderCurrentRoute()` in +the pattern is your app-owned renderer. + +**Adapt this to your use case:** start the combined operation with the serialized private handoff, +render from available preview data, and use ordinary route tracking for later navigation. ```ts -import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-web/constants' +import ContentfulOptimization from '@contentful/optimization-web' +import { type ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' + +async function continueServerPreview( + optimization: ContentfulOptimization, + continuation: + | { readonly handoff: ContentOptimizationHandoff; readonly routeKey: string } + | undefined, +): Promise { + const delivery = optimization.hydrateAndTrackCurrentPage(continuation?.handoff, { + routeKey: continuation?.routeKey ?? `${window.location.pathname}${window.location.search}`, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) + void delivery.catch((error: unknown) => { + console.warn('Initial event delivery failed.', error) + }) + + await renderCurrentRoute() +} -function buildAnonymousIdSetCookie(id: string | undefined): string { - if (!id) return `${ANONYMOUS_ID_COOKIE}=; Max-Age=0; Path=/` - // Browser code must be able to read this cookie for Web SDK continuity — no HttpOnly. - return `${ANONYMOUS_ID_COOKIE}=${id}; Path=/; SameSite=Lax` +export async function trackCurrentRoute( + optimization: ContentfulOptimization, + routeKey = `${window.location.pathname}${window.location.search}`, +): Promise { + await optimization.trackCurrentPage({ + routeKey, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) } ``` -`ANONYMOUS_ID_COOKIE` re-exports the core constant and equals `'ctfl-opt-aid'`. For the lower-level -mechanics, see +A successful browser commit can establish durable persistence and write the browser-readable +`ctfl-opt-aid` cookie when persistence consent permits it; your server reads that value on later +requests. On consent denial, call `consent(false)` and `reset()` according to your policy. Treat +the HTML and private replay handoff as visitor-specific output that must not enter a public cache. + +Verify the paired flow in the browser developer tools Network panel. Load the server-rendered route +and find one browser `POST` ending in `/profiles` or `/profiles/:id` with no `type=preflight` +query parameter. The separate server preview is an Experience profile `POST` with +`type=preflight`, not a CORS preflight. In the browser request body, inspect the `events` array and +confirm zero or more optional identify/track events appear in your supplied order, followed by the +page event. A successful response is the browser commit. Call `continueServerPreview()` once, then +call only `trackCurrentRoute(optimization)` again without changing path or search. The second route +call must not produce another profile `POST`; do not repeat `continueServerPreview()`, because +that helper hydrates the handoff. Finally, inspect browser cookies and confirm `ctfl-opt-aid` +appears only when persistence consent allows durable persistence. + +For lower-level continuity mechanics, see [Profile synchronization between client and server](../concepts/profile-synchronization-between-client-and-server.md). -If you hydrate a browser handoff with `hydrateOptimizationHandoff()` from -`@contentful/optimization-web/handoff`, cache safety is enforced before state is published. -Profileless `static` and `public-permutation` handoffs publish selected optimizations and Custom -Flag changes to live browser state without overwriting durable profile continuity in browser -storage. `private-request` handoffs, and profile-backed handoffs that pass cache safety, follow -normal persistence behavior when persistence consent allows. +Public and static handoffs cannot carry private replay. Browser handoff state is always applied in +memory during hydration. Profileless `static` and `public-permutation` handoffs hydrate selected +optimizations and Custom Flag changes without overwriting durable browser profile continuity; only +a later successful live Experience response can establish new durable continuity. ### Strict consent, storage, and delivery controls @@ -1279,6 +1341,9 @@ Before release, verify these behaviors in the target deployment: subscriptions register once per app root, `messageId` dedupe is applied before forwarding, the resolved (not baseline) entry id is used for tracking, and element tracking is not enabled twice for the same node. +- **Hybrid continuity** — for Node/Web routes, perform the browser-delivery, + duplicate-request, and continuity-cookie checks in + [Hybrid Node SSR and browser continuity](#hybrid-node-ssr-and-browser-continuity). - **Privacy and governance** — profile identifiers, traits, forwarded fields, `localStorage` usage, the `ctfl-opt-aid` cookie, and retention match the app's approved policy. - **Local validation path** — compare the app against the Web SDK reference implementation and run @@ -1320,8 +1385,8 @@ pnpm test:e2e:web-sdk consent, identify/reset, nested entries, Rich Text merge tags, Custom Flags, and interaction tracking. - [Node SDK SSR + Web SDK Vanilla JS reference implementation](../../implementations/node-sdk+web-sdk/README.md): - Hybrid server/browser continuity with shared anonymous-id cookies, consent-aware persistence, and - browser-side Web SDK takeover. + Hybrid server preview, private browser replay, consent-aware browser persistence, and Web SDK + takeover. Use the [Web SDK package README](../../packages/web/web-sdk/README.md) for package orientation, and the generated 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..1c2eb2354 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,11 +19,23 @@ 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 -binding supports bound Client Components. Legacy Next provider, tracker, SSR plugin, ESR helper, +context, server first paint, entry resolution, and browser handoff. Its injected client request root +delivers the private replay for the matching route and owns browser route tracking. A separate client binding +also supports other 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. +A **server preview** evaluates zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. It returns preview state +without committing the sequence. The handoff's **private replay** is the SDK-owned, route-bound +continuation for the first browser route. A **successful Experience commit** is the non-preflight browser profile +response; only that response can persist browser continuity. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but matching-route delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. + Start with the [Next.js App Router integration guide](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md). @@ -63,15 +75,16 @@ Do not carry forward package-root or ESR helper assumptions. The target App Rout `@contentful/optimization-nextjs/client` for browser hooks and per-entry controls. Legacy ESR middleware and selector files are not supported public import surfaces. -Remove legacy tracker and provider wiring before adding `optimization.request.OptimizationRoot` and -`optimization.request.NextAppAutoPageTracker`, so the request family owns server state handoff and -browser tracking once. +Remove legacy tracker and provider wiring before adding the injected client request root and +`optimization.request.OptimizationRoot`, so the request family owns server preview, private replay, +and browser tracking once. ### Install and bind the App Router SDK -Create one server binding with `bindNextjsAppRouterServerOptimization` from -`@contentful/optimization-nextjs/app-router/server`, then use its nested `optimization.request` -components for ordinary per-visitor routes. Configure the no-argument +Create a client binding with `bindNextjsAppRouterClientOptimization`, export its +`RequestOptimizationRoot`, and inject that component into one server binding created with +`bindNextjsAppRouterServerOptimization`. Use the nested `optimization.request` components for +ordinary per-visitor routes. Configure the no-argument `createNextjsOptimizationContextHandler()` from `@contentful/optimization-nextjs/request-handler` as shown in [Request context and the profile cookie](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#request-context-and-the-profile-cookie): @@ -85,16 +98,17 @@ many render surfaces. ### Replace SSR/ESR profile continuity -Move profile continuity to the App Router request context and target SDK cookie behavior. The target -profile cookie is `ctfl-opt-aid`; it must be browser-readable so browser takeover can continue the -same visitor. The app still owns the consent record and the server consent resolver. +Move profile continuity to App Router request preview and private browser replay. The target profile +cookie is `ctfl-opt-aid`; it must be browser-readable so later server requests can continue the same +visitor. The app still owns the consent record and the server consent resolver. + +Every `optimization.request` wrapper shares one SDK initializer for the active request. It derives the URL, route key, page payload, hydration mode, and private handoff once. Supply optional `request.initialExperienceEvents` commands in Personalization input order; the SDK appends its page for one forced preflight. Mount only `optimization.request.OptimizationRoot`. It applies state in memory and owns initial delivery and later routes. Do not add another tracker or application handoff, route-key, or page-payload plumbing. -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 -`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 -request-family path. +The browser checks live consent and submits the server-built events through its ordinary interceptors and queues, sending the Personalization sequence as one +normal batch. The server preview does not write a new profile cookie. A successful browser +Experience response commits the sequence and can persist `ctfl-opt-aid` when browser persistence +consent permits it. Remove legacy `initialPageEvent` props during migration; the compatibility +input is inert. ### Replace server-rendered personalization @@ -126,10 +140,13 @@ preview, and live updates use the React Web runtime behind the App Router SDK: Verify server HTML, hydration, and browser takeover together: - The request handler runs for the personalized route. -- Server rendering uses the nested `optimization.request` root, entry, and tracker components. +- Server rendering uses the nested `optimization.request` root and entry 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. +- Run the integration guide's + [browser Experience commit and duplicate-route checks](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#the-bound-root-and-page-events), + then observe one matching-route commit, no duplicate request for the same route, and + `ctfl-opt-aid` only after a successful response when persistence consent permits it. - Personalized HTML and resolved outputs are not shared across visitors through caching. ## Validate the migration @@ -137,17 +154,20 @@ Verify server HTML, hydration, and browser takeover together: - Search for remaining `@ninetailed/experience.js-next`, `@ninetailed/experience.js-next-esr`, and legacy React imports. - Verify one all-visitors variant on a dynamic App Router route. +- Run the integration guide's + [browser commit, duplicate-route, and continuity-cookie checks](./integrating-the-optimization-sdk-in-a-nextjs-app-router-app.md#the-bound-root-and-page-events). - Verify denied consent and accepted consent event paths. - Verify preview and analytics forwarding only after the core route works. ## 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 | Keep the injected request root as the only route coordinator; remove legacy `initialPageEvent` props, extra trackers, and direct page calls. | +| Server variant renders but no profile cookie appears | Run the browser Experience commit check; confirm JavaScript ran and browser persistence consent is true. | ## 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..f0ecabb20 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,18 @@ 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. +evaluate the request for rendering; the browser root installs preview state and owns the initial replay/page decision, and continues with React Web behavior. + +A **server preview** evaluates zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. It returns preview state +without committing the sequence. The handoff's **private replay** is the SDK-owned, route-bound +continuation for the first browser route. A **successful Experience commit** is the non-preflight browser profile +response; only that response can persist browser continuity. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but matching-route delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. Start with the [Next.js Pages Router integration guide](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md). @@ -29,7 +39,7 @@ Gather these inputs: - Provider and tracker placement in `_app.tsx`. - Every `getServerSideProps` path that uses SSR plugin behavior or `ntaid`. - Legacy React components, hooks, flags, and mapper-dependent entries. -- Consent cookie, profile cookie, and initial page-event behavior. +- Consent cookie, profile cookie, and any legacy `initialPageEvent` or route-skip behavior. - Any analytics, privacy, preview, or insights plugins. ## Migration path @@ -59,19 +69,20 @@ Use the target guide to create both bindings: - Client binding helper from `@contentful/optimization-nextjs/pages-router`. - Server binding helper from `@contentful/optimization-nextjs/pages-router/server`. -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. +`routeKey` is the path-plus-search identity used to match and deduplicate page delivery, while +`buildPagePayload` supplies the full page URL as event data. -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. +Mount the target `OptimizationRoot` in `_app.tsx`, passing +`pageProps.contentfulOptimization.handoff`, a stable `routeKey`, and a lazy `buildPagePayload`. The +`contentfulOptimization` wrapper is app-owned; its `handoff` field is the SDK +`BrowserOptimizationHandoff` returned by `createRequestHandoff(context, options)`. The handoff can +contain browser consent defaults, request-preview state, managed entries, and an SDK-owned private +replay. The root owns current-route tracking, so remove the legacy tracker and do not add a separate +`NextPagesAutoPageTracker`. If migrated components will use ``, configure the server binding with the app's `contentful` client. Pass `prefetchManagedEntries` descriptors—entry IDs or objects containing -`entryId` and optional `entryQuery`—in the `options` passed to +`contentType`, `slug`, optional `slugField`, and optional `entryQuery`—in the `options` passed to `createRequestHandoff(context, options)`. The helper fetches those baselines and adds them to `handoff.entries` before the props reach the root. The Pages Router client binding does not fetch managed entries by itself. @@ -84,15 +95,15 @@ 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 and previews zero or more optional +`initialExperienceEvents` identify/track commands in the order you supply them. The SDK appends its +page command. The preview is forced and does not write a new anonymous-ID cookie. The returned +private handoff carries the preview state for server rendering and its route-bound continuation. + +Pass the handoff to `OptimizationRoot`. It applies preview state in memory and owns the initial replay/page decision, then uses ordinary current-route tracking for later navigation. Replay submits the server-built Personalization batch with live consent and ordinary interceptors, followed by Analytics through the normal queue. Only a successful browser Experience response can write `ctfl-opt-aid` when persistence consent permits it. -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. +Remove legacy `initialPageEvent` props during migration. The target type retains that input only for +compatibility, and it is inert. ### Replace personalized rendering @@ -124,8 +135,15 @@ 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. +- Run the integration guide's + [browser Experience commit check](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md#the-bound-root-and-page-events): find one browser `POST` ending in `/profiles` or `/profiles/:id` + with no `type=preflight`; the separate server preview uses `type=preflight`. In the browser + request body's `events` array, observe zero or more optional identify/track events in application- + supplied order, followed by the page event. +- Reload the same path plus search and navigate once; observe no duplicate request for the initial + route and one page event for the later route. +- Inspect cookies before and after the successful browser response; observe no server-preview write + and `ctfl-opt-aid` only when persistence consent permits it. - A target `OptimizedEntry` renders a variant or baseline. - Personalized results are not cached outside the request boundary. @@ -134,17 +152,20 @@ 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. +- Run the integration guide's + [browser commit, duplicate-route, and continuity-cookie checks](./integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md#the-bound-root-and-page-events). - 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 | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Browser page events duplicate | Keep `OptimizationRoot` as the only route coordinator; remove the legacy tracker, `initialPageEvent` prop, and direct page calls. | +| `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)`. | +| Server variant renders but no profile cookie appears | Run the browser Experience commit check; confirm JavaScript ran and browser persistence consent is true. | +| 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..6bbcb0782 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,20 @@ 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. Direct Node event calls commit on the server. A route that continues +in a browser SDK instead creates preview state on the server and delivers a private replay for +the matching browser route. + +A **server preview** evaluates zero or more optional `identify`/`track` commands in +application-supplied order, followed by the SDK-appended `page` command. It returns preview state +without committing the sequence. The handoff's **private replay** is the SDK-owned, route-bound +continuation for one browser route. A **successful Experience commit** is the non-preflight browser profile +response; only that response can persist browser continuity. + +> [!NOTE] +> +> Without JavaScript, previewed server HTML can still render, but matching-route delivery and a new +> browser `ctfl-opt-aid` cookie do not occur. Follow the [Node SDK integration guide](./integrating-the-node-sdk-in-a-node-app.md) unless a Next.js adapter owns the route. @@ -48,8 +61,8 @@ Gather these inputs: Identify which server code owns each responsibility: - Creates or reads an anonymous profile ID. -- Emits the first page event. -- Persists profile continuity. +- Commits events on a server-only route, or previews the initial sequence for a browser route. +- Delivers private replay and persists profile continuity in the browser path. - Resolves Contentful entries before rendering. - Hands state to the browser. @@ -65,21 +78,25 @@ shape is `forRequest({ consent })`; a migration request usually adds `locale`, ` profile ID, and `eventContext` carries URL, user-agent, referrer, query, and other page data the SDK cannot infer from your server framework. -Accepted request-bound `page()` or `identify()` results carry the profile, selected optimizations, -and flag changes for that request. Consent-blocked events return blocked results or diagnostics -without throwing. +On a server-only route, accepted request-bound `page()`, `identify()`, or `track()` calls commit from +Node and carry the profile, selected optimizations, and flag changes for that request. Persist the +returned profile ID only when `canPersistProfile` is true. Consent-blocked events return blocked +results or diagnostics without throwing. ### Replace SSR and ESR handoff Use a framework SDK when available. For Next.js, prefer the App Router or Pages Router migration -guide so the adapter owns request state, provider handoff, page-event dedupe, and cookie behavior. +guide so the adapter owns request preview, private browser replay, route dedupe, and browser cookie +behavior. + +For a manual Node/Web hybrid, read an existing `ctfl-opt-aid` into `forRequest({ profile: { id } })` and call `previewInitialExperience()` with optional commands. The SDK appends its page and preflights one batch. `createRequestHandoffFromPreview()` packages private preview state and replay. Your app owns serialization and transport. In Web, call `hydrateAndTrackCurrentPage(handoff, { routeKey, buildPayload })` once, then `trackCurrentPage()` for later routes. Preview rendering need not await delivery. -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. +The browser checks live consent and submits the server-built events through its ordinary interceptors and queues, sending the Personalization sequence as one +normal batch. The preview does not write a profile cookie. A successful browser Experience response +commits the sequence and can write the SDK-owned `ctfl-opt-aid` cookie when persistence consent +permits it. Keep that cookie browser-readable for the next server request. Do not migrate hybrid +code by calling Node `page()` and trying to suppress the browser with the legacy +`initialPageEvent` option; that option is inert compatibility input. ### Replace server content resolution @@ -99,7 +116,11 @@ Verify the request boundary: - Accepted events return profile data when consent allows them. - Blocked events do not throw and surface diagnostics. -- The app persists the profile only when persistence consent allows it. +- Direct Node routes persist returned profile identity only when persistence consent allows it. +- For Node/Web routes, perform the browser Experience commit, duplicate-route, and continuity-cookie + checks in the Node guide's + [Share continuity with the Web SDK](./integrating-the-node-sdk-in-a-node-app.md#share-continuity-with-the-web-sdk) + section. - Entry resolution uses request selections. - Browser takeover uses a compatible target SDK path. @@ -107,17 +128,22 @@ Verify the request boundary: - Search for `@ninetailed/experience.js-node`, SSR plugin imports, ESR helper imports, and `ntaid`. - Verify one accepted request and one denied-consent request. +- For a Node/Web route, run the Node guide's + [paired-flow checks](./integrating-the-node-sdk-in-a-node-app.md#share-continuity-with-the-web-sdk) + and observe one matching-route browser commit, no duplicate request for the same path plus search, + and `ctfl-opt-aid` only after a successful response when persistence consent permits it. - Verify a resolved server entry falls back to baseline with no selection. - Verify cache keys do not share personalized output across visitors. ## Troubleshooting -| Symptom | Check | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Server event methods are missing | Call `forRequest()` first; event methods live on the request-bound client. | -| Non-sticky interaction tracking throws | Bind a request profile ID or use the event flow that derives one before sending Insights interactions. | -| Browser takeover starts a different visitor | Persist and pass the target anonymous ID according to the Web or framework SDK guide. | -| Personalized HTML leaks between visitors | Remove shared caching around request-specific responses and rendered output. | +| Symptom | Check | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Server event methods are missing | Call `forRequest()` first; event methods live on the request-bound client. | +| Non-sticky interaction tracking throws | Bind a request profile ID or use the event flow that derives one before sending Insights interactions. | +| Browser takeover starts a different visitor | Read existing continuity into Node, then run the linked browser commit and cookie checks. | +| Server variant renders but no cookie appears | Run the browser Experience commit check; confirm JavaScript ran and persistence consent is true. | +| Personalized HTML leaks between visitors | Remove shared caching around request-specific responses and rendered output. | ## Related guides 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..8553ca3ad 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 @@ -33,15 +33,27 @@ Vocabulary used below: other app-owned source. - **Hydration** is the first browser render over existing markup. `liveUpdates` is later browser re-resolution after startup. +- A handoff's **private replay** carries server-built events for one browser operation. The initial + Personalization commands retain input order in one batch; Analytics follows with the available + profile. The combined operation hydrates provisional state and attempts an ordinary page only + when replay accepted none. A successful **browser commit** is the non-preflight Experience + response; only that response can establish durable continuity. Public and static handoffs cannot + contain private replay. The Edge private-request section covers detailed fallback behavior. - **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 - SDK-owned exact values. Route keys, payload `properties`, environment variable names, and helper + `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 cache metadata. +> [!NOTE] +> +> For a private request handoff without JavaScript, previewed output can still render, but +> browser delivery and a new `ctfl-opt-aid` cookie do not occur. `ctfl-opt-aid` is the exact +> SDK-owned cookie name. + Here, edge-side rendering (ESR) means a Next.js Edge route owns the response before it reaches the browser. The public SDK entrypoint for Edge handoff state is `@contentful/optimization-nextjs/edge`; HTML or JSON rendering stays application-owned. @@ -161,7 +173,6 @@ export default async function SegmentPage({ params }: { params: Promise<{ segmen selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) return ( @@ -215,7 +226,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. +The browser root owns current-route tracking. Public and static handoffs carry selected state, not +private replay instructions. 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 @@ -246,11 +258,7 @@ The public/static handoff helpers serialize the selected optimizations, changes, metadata your application supplies. They do not call the Experience API or derive selections from route, cookie, header, locale, or cache-key inputs. -When the browser hydrates a profileless `static` or `public-permutation` handoff, the SDK applies -the selected optimizations and Custom Flag changes to live browser state for that page without -overwriting durable browser profile continuity. `private-request` handoffs, and profile-backed -handoffs that pass cache safety, keep the normal persistence behavior when persistence consent -allows. +All browser handoff state is applied in memory. Profileless `static` or `public-permutation` handoffs apply selections and Custom Flag changes without overwriting durable browser profile continuity. Private replay also uses provisional state; the combined operation owns its delivery and page fallback. Only a successful live Experience response can establish durable continuity. Each registry record needs enough information to fetch, resolve, hand off, and cache one public output: @@ -338,13 +346,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. 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 +371,6 @@ const handoff = createPublicPermutationHandoff({ selectedOptimizations: permutation.selectedOptimizations, changes: permutation.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) ``` @@ -407,10 +411,10 @@ Define `useOptimizationConsent()` as an app-owned client hook that reads your co returns `{ events, persistence }` booleans for Optimization event delivery and profile-cookie persistence. -This example intentionally combines two client entrypoints from the same installed package. -`/app-router/client` owns the App Router navigation tracker; router-neutral `/client` owns the root -and entry because this component supplies browser configuration and consent directly. Both consume -the nearest Optimization React provider. +This example uses the router-neutral `/client` root and entry because the component supplies browser +configuration and consent directly. `NextAppAutoPageTracker` is the one route coordinator. It +derives the App Router path and search state, emits the first accepted page event, and tracks later +navigation. Keep it inside `Suspense` because it reads `useSearchParams()`. **Adapt this to your use case:** @@ -434,11 +438,9 @@ export function BrowserOwnedHero({ hero }) { environment={process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'} hydration="client-only-hidden-until-ready" locale="en-US" - routeKey="/landing" - buildPagePayload={() => ({ properties: { path: '/landing' } })} > - + {(resolvedHero) => } @@ -454,7 +456,8 @@ emit; `persistenceConsent` controls whether the browser can store SDK profile co For this browser-owned route, set your app-owned consent record to allow Optimization events during the proof, then verify in the rendered page or browser devtools after hydration, not in View Source. -`NextAppAutoPageTracker` owns the first page event because this route has no server handoff. +The tracker emits the current page after the SDK becomes live. Confirm one accepted page event on +initial load and one on navigation. Do not add another page tracker or direct page call beside it. ### SSG customer-owned static permutation @@ -487,7 +490,6 @@ export default async function StaticSegmentPage() { changes: selection.changes, cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', }) return ( @@ -532,7 +534,6 @@ const handoff = createPublicPermutationHandoff({ selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'preserve-server', - initialPageEvent: 'emit', }) ``` @@ -641,7 +642,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 +661,33 @@ 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. -`app-consent` is a reader-owned consent cookie name. Configure `consent.server` explicitly; if it is -omitted, Edge request consent resolves to `false`. +recipe. The helper reads request cookies and headers and returns a private request handoff for the +paired flow. The sequence is zero or more optional `identify`/`track` commands in application-supplied +order, followed by the SDK-appended `page` command. `app-consent` is a reader-owned consent cookie +name, and `edge_response_rendered` below is an app-owned event name. Configure `consent.server` +explicitly; if it is omitted, Edge request consent resolves to `false`. + +`createEdgeRequestHandoff()` accepts either an already-resolved command array or a resolver that +receives the Edge request snapshot with `url`, `headers`, and optional `cookies`. Each command is +a flat input, such as `{ type: 'identify', userId, traits? }` or +`{ type: 'track', event, properties? }`. The App Router request config offers a similar +framework-owned resolver boundary. The Pages Router helper and lower-level Next.js server helper +accept only already-resolved arrays. + +The Edge server preview sends the commands and SDK-appended page in an Experience profile `POST` +with `type=preflight`. This is an SDK transport mode, not a browser CORS preflight. An accepted +preview can carry state and replay; a consent-blocked page carries neither. + +The Edge request helper converts operational preview failures, including Experience API, +initial-command resolver, interceptor, and event-schema failures, into a profileless private +baseline handoff. The route can render and the browser makes its normal page attempt. Invalid cache +scope and cache-safety failures remain fail-closed. + +The app-owned `renderPersonalizedResponse()` must serialize both the private `handoff` and +`routeKey` into browser startup data. The browser parses that data, hydrates the handoff on its live +Web instance, then makes the ordinary `trackCurrentPage({ routeKey, buildPayload })` call for the +matching route. In the excerpt, `routeKey` is the path plus search string used for matching and +deduplication; `request.url` is the full page URL used as event context. **Reference excerpt:** @@ -687,29 +710,67 @@ const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ }) export async function GET(request: Request) { - const routeKey = new URL(request.url).pathname - const { handoff, persist } = await createEdgeRequestHandoff({ + const pageUrl = new URL(request.url) + const routeKey = `${pageUrl.pathname}${pageUrl.search}` + const { handoff } = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', - pagePayload: { properties: { path: routeKey } }, + initialExperienceEvents: ({ url }) => [ + { type: 'track', event: 'edge_response_rendered', properties: { url } }, + ], + pagePayload: { properties: { path: pageUrl.pathname, search: pageUrl.search } }, request, }) const response = await renderPersonalizedResponse({ handoff, routeKey }) response.headers.set('Cache-Control', 'private, no-store') - persist(response) return response } ``` -`renderPersonalizedResponse()` is your existing custom renderer that returns a `Response`. Keep the -response private because the handoff can include request profile state. +`renderPersonalizedResponse()` must expose the serialized handoff and route key to browser startup +code. Keep one-time hydration separate from the reusable route-tracking call so duplicate +suppression can be tested without hydrating again. + +**Adapt this to your use case:** + +```ts +import ContentfulOptimization from '@contentful/optimization-web' +import { type ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' + +export async function startEdgeBrowserRuntime( + optimization: ContentfulOptimization, + handoff: ContentOptimizationHandoff, + routeKey: string, +): Promise { + await optimization.hydrateAndTrackCurrentPage(handoff, { + routeKey, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) +} + +export async function trackCurrentRoute( + optimization: ContentfulOptimization, + routeKey = `${window.location.pathname}${window.location.search}`, +): Promise { + await optimization.trackCurrentPage({ + routeKey, + buildPayload: () => ({ properties: { url: window.location.href } }), + }) +} +``` + +`renderPersonalizedResponse()` is your existing renderer returning a `Response`. Keep that response private because its handoff can include visitor state and replay. Edge preview does not write a new profile cookie. The browser's combined operation hydrates state in memory and submits the server-built Personalization batch followed by Analytics. It attempts one ordinary page only if replay accepted no page. Newer handoffs preserve earlier admitted journals. Preview-backed rendering proceeds during delivery, and only a successful live response can establish durable continuity when consent permits it. + +Recoverable state hydration errors permit safe browser delivery through the combined operation. +Cache-safety errors remain fail-closed: do not apply the supplied state or replay. 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` -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: +path-and-search `routeKey` is the separate stable identity used for duplicate-event control. Because +`pagePayload` supplies `properties.path` and `properties.search` but not `properties.url`, 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 `campaign: {}` suppresses URL inference and produces empty attribution. The SDK never fills missing fields from a lower-priority source. The chosen URL maps into `context.campaign`: `utm_campaign` @@ -757,7 +818,6 @@ export default async function AnalyticsOnlyPage() { selectedOptimizations: segment.selectedOptimizations, changes: segment.changes, hydration: 'analytics-only', - initialPageEvent: 'emit', }) const trackingAttributes = getServerTrackingAttributes(hero, resolvedHero) @@ -783,8 +843,8 @@ If you build the analytics-only browser owner without React, import `initializeOptimizationAnalyticsRuntime(...)` and `hydrateOptimizationAnalyticsHandoff(...)` from `@contentful/optimization-web/analytics`. Initialize one analytics-only runtime for the page and hydrate each analytics-only handoff into it; the runtime does not expose content-resolution APIs and -is not an isolation context. When route changes can replace a handoff before hydration finishes, pass -the helper's `isCurrent` option so stale hydration stops before state or page tracking applies. +is not an isolation context. Use the helper's `isCurrent` option for runtime lifetime, so teardown stops work that has not +started. Newer handoffs preserve earlier admitted journals while state publication keeps latest-wins arbitration. ## Validate the integration @@ -793,7 +853,7 @@ the helper's `isCurrent` option so stale hydration stops before state or page tr `private-request`. - For customer-owned permutations, inspect the logged handoff and verify `handoff.state?.profile` is absent. That property is the optional per-visitor profile snapshot; a public or static handoff - cannot carry it safely. + cannot carry it safely. Also verify `handoff.replay` is absent; replay is private-request only. - For every `public-permutation` handoff, inspect the logged `handoff.cache.key` and verify it starts with encoded fields such as `permutation=...:version=...:` and changes when the segment, locale, selected optimization set, entry set, or app-owned cache version changes. If rendered @@ -811,6 +871,20 @@ the helper's `isCurrent` option so stale hydration stops before state or page tr `s-maxage=60` when the route returns `revalidate: 60`. - With `hydration: 'preserve-server'`, load the page normally and verify the same distinctive text remains after hydration. +- For an Edge request handoff, confirm the server response does not create a preview profile cookie, + then inspect the browser Network panel for one browser `POST` whose path ends in `/profiles` or + `/profiles/:id` and whose URL has no `type=preflight` query parameter. The separate Edge preview + is an Experience profile `POST` with `type=preflight`, not a CORS preflight. In the browser + request body, inspect the `events` array: zero or more optional identify/track events appear in + your supplied order, followed by the page event. Treat a successful response as the browser + commit, then confirm `ctfl-opt-aid` appears only when persistence consent permits durable + persistence. +- Call `startEdgeBrowserRuntime()` once, then call only + `trackCurrentRoute(optimization, routeKey)` again without changing the route key. The second + route call must not produce another browser profile `POST`. Do not repeat + `startEdgeBrowserRuntime()`, because it hydrates the handoff. +- Navigate to a different path or search string and observe one new page event. Each recipe names + one current-page owner, so do not mount a second tracker or direct page call beside it. - For browser-owned routes, skip View Source for the variant proof; verify the variant in the rendered page or browser devtools after hydration. - For a bound App Router path, mount the integration guide's diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md index 74422a0c8..68aea2e06 100644 --- a/documentation/internal/sdk-knowledge/native/android.md +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -80,8 +80,10 @@ imperative `.core` client. used by the Experience and Insights APIs and has a Kotlin-side default `"master"`; `logLevel` default `OptimizationLogLevel.error`; `locale`, `api` (`experienceBaseUrl`/`insightsBaseUrl`/`enabledFeatures`/`preflight`), `allowedEventTypes`, - `queuePolicy`, `defaults: StorageDefaults`, `onEventBlocked` all optional/nullable. - source: extern:environment default "master", logLevel default error, spaceId required — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#OptimizationConfig + `queuePolicy`, `defaults: StorageDefaults`, `onEventBlocked` all optional/nullable. `preflight` is + deprecated and retained only for mixed-version configuration serialization; the bridged stateful + Core runtime ignores it. + source: extern:environment default "master", logLevel default error, spaceId required, and preflight compatibility behavior — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt#OptimizationConfig; optimization-js-bridge#index.ts#BridgeConfig; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig - `config.toJSON(anonymousId)` serializes to the bridge `BridgeConfig` shape, omitting null URLs and empty sub-objects (`api`/`queuePolicy` skipped when empty; `defaults` object emitted only when non-empty); the bridge maps it into `CoreStatefulConfig` via `resolveStatefulDefaults`, defaulting diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md index 6352660b0..20a5b5fce 100644 --- a/documentation/internal/sdk-knowledge/native/ios.md +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -76,7 +76,9 @@ apps mostly use the view surface, UIKit apps mostly use the imperative `Optimiza `logLevel` default `.error`; `locale`, `api` (`experienceBaseUrl`/`insightsBaseUrl`/`enabledFeatures`/`preflight`), `allowedEventTypes`, `queuePolicy`, `defaults: StorageDefaults`, - `onEventBlocked` all optional. source: extern:environment default "master", logLevel default .error, spaceId required — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig + `onEventBlocked` all optional. `preflight` is deprecated and retained only for mixed-version + configuration serialization; the bridged stateful Core runtime ignores it. + source: extern:environment default "master", logLevel default .error, spaceId required, and preflight compatibility behavior — packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift#OptimizationConfig; optimization-js-bridge#index.ts#BridgeConfig; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig - `config.toJSON()` serializes to the bridge `BridgeConfig` shape, omitting nil URLs and empty sub-dicts; the bridge maps it into `CoreStatefulConfig` via `resolveStatefulDefaults`, defaulting `allowedEventTypes` to `DEFAULT_NATIVE_ALLOWED_EVENT_TYPES` and installing `queuePolicy` callbacks diff --git a/documentation/internal/sdk-knowledge/native/react-native.md b/documentation/internal/sdk-knowledge/native/react-native.md index 0cb5b0c32..38931be47 100644 --- a/documentation/internal/sdk-knowledge/native/react-native.md +++ b/documentation/internal/sdk-knowledge/native/react-native.md @@ -34,14 +34,15 @@ source root: `packages/react-native-sdk/src`; shared core: `packages/universal/c active singleton and injects it via `OptimizationProvider sdk={sdk}`). `initialize` is `async` because it reads AsyncStorage before constructing. source: react-native-sdk#components/OptimizationRoot.tsx#OptimizationRoot; react-native-sdk#ContentfulOptimization.ts#initialize -- Config type is `CoreStatefulConfig` (aliased as `OptimizationConfig`); `spaceId` is required, and +- Config behavior follows `CoreStatefulConfig` through the React Native `OptimizationConfig`; `spaceId` is required, and its API keys are `spaceId`, `environment?`, and `fetchOptions?` (from `api-client` `ApiConfig`); `locale?`, `logLevel?`, `contentful?`, `eventBuilder?` (from `CoreConfig`); `api?` (`experienceBaseUrl`, `insightsBaseUrl`, `enabledFeatures`, `ip`, `plainText`, `preflight`), `allowedEventTypes?`, `defaults?` (`consent`, `persistenceConsent`, `profile`, `changes`, `selectedOptimizations`), `getAnonymousId?`, `onEventBlocked?`, `queuePolicy?` (`flush`, - `offlineMaxEvents`, `onOfflineDrop`) — all from `CoreStatefulConfig`. - source: react-native-sdk#index.ts#OptimizationConfig; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#CoreBase.ts#CoreConfig; core-sdk#CoreApiConfig.ts#CoreStatefulApiConfig; core-sdk#StatefulDefaults.ts#StatefulDefaults; core-sdk#CoreStateful.ts#QueuePolicy; api-client#ApiClientBase.ts#ApiConfig + `offlineMaxEvents`, `onOfflineDrop`) — all from `CoreStatefulConfig`. `api.preflight` is retained + only for mixed-version compatibility and does not affect this stateful runtime. + source: react-native-sdk#index.ts#OptimizationConfig; core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig; core-sdk#CoreBase.ts#CoreConfig; core-sdk#StatefulDefaults.ts#StatefulDefaults; core-sdk#CoreStateful.ts#QueuePolicy; api-client#ApiClientBase.ts#ApiConfig - When `environment` is omitted, the API client uses `master` for Experience and Insights requests. The fallback lives in `api-client` `ApiClientBase`. source: api-client#ApiClientBase.ts#DEFAULT_ENVIRONMENT; api-client#ApiClientBase.ts#ApiConfig @@ -307,7 +308,7 @@ selectedOptimizations, changes }` payload, and this stateful SDK applies it to i source: react-native-sdk#handlers/createAppStateChangeListener.ts#createAppStateChangeListener; react-native-sdk#ContentfulOptimization.ts#ContentfulOptimization - 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 + `*.png` module declaration. source: react-native-sdk#index.ts; react-native-sdk#polyfills/crypto.ts - Preview panel: `PreviewPanelOverlay`/`PreviewPanel` are on the `/preview` subpath, need the optional clipboard + safe-area peers, and fetch `nt_audience`/`nt_experience` entries through the supplied `contentfulClient`. Expo apps require a custom dev build (`expo run:ios`/`expo diff --git a/documentation/internal/sdk-knowledge/node/node.md b/documentation/internal/sdk-knowledge/node/node.md index 2a8e4f333..ceff166fa 100644 --- a/documentation/internal/sdk-knowledge/node/node.md +++ b/documentation/internal/sdk-knowledge/node/node.md @@ -14,15 +14,15 @@ per-visitor state between requests. Package source root: `packages/node/node-sdk ## Package & entry points -| Import path | Purpose | source | -| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@contentful/optimization-node` (default export) | Node `ContentfulOptimization` class (extends `CoreStateless`) | node-sdk#index.ts; node-sdk#ContentfulOptimization.ts#ContentfulOptimization | -| `@contentful/optimization-node` (named) | `OPTIMIZATION_NODE_SDK_NAME`, `OPTIMIZATION_NODE_SDK_VERSION`, `OptimizationNodeConfig`, `PublicNodeEventBuilderConfig`, `createRequestHandoffFromData` | node-sdk#index.ts; node-sdk#constants.ts#OPTIMIZATION_NODE_SDK_NAME; node-sdk#ContentfulOptimization.ts#OptimizationNodeConfig; node-sdk#handoff.ts#createRequestHandoffFromData | -| `@contentful/optimization-node/constants` | `ANONYMOUS_ID_COOKIE`, `ANONYMOUS_ID_KEY`, Node SDK name/version | node-sdk#constants.ts; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; core-sdk#constants.ts#ANONYMOUS_ID_KEY | -| `@contentful/optimization-node/core-sdk` | Re-exports all of core-sdk (incl. `UniversalEventBuilderArgs`, `CoreStateless`, `CoreStatelessRequest`) plus `prefetchManagedEntries` entry-source helpers | node-sdk#core-sdk.ts; core-sdk#events/EventBuilder.ts#UniversalEventBuilderArgs | -| `@contentful/optimization-node/api-schemas` | Schemas + type guards incl. `isMergeTagEntry` | node-sdk#api-schemas.ts; core-sdk#contentful/typeGuards.ts#isMergeTagEntry | -| `@contentful/optimization-node/api-client` | API client re-export | node-sdk#api-client.ts | -| `@contentful/optimization-node/logger` | logger utilities (default + named) | node-sdk#logger.ts | +| Import path | Purpose | source | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@contentful/optimization-node` (default export) | Node `ContentfulOptimization` class (extends `CoreStateless`) | node-sdk#index.ts; node-sdk#ContentfulOptimization.ts#ContentfulOptimization | +| `@contentful/optimization-node` (named) | Node constants/config plus request handoff helpers | node-sdk#index.ts; node-sdk#constants.ts#OPTIMIZATION_NODE_SDK_NAME; node-sdk#ContentfulOptimization.ts#OptimizationNodeConfig; core-sdk#handoff.ts#createRequestHandoffFromData; core-sdk#handoff.ts#createRequestHandoffFromPreview | +| `@contentful/optimization-node/constants` | `ANONYMOUS_ID_COOKIE`, `ANONYMOUS_ID_KEY`, Node SDK name/version | node-sdk#constants.ts; core-sdk#constants.ts#ANONYMOUS_ID_COOKIE; core-sdk#constants.ts#ANONYMOUS_ID_KEY | +| `@contentful/optimization-node/core-sdk` | Re-exports all of core-sdk (incl. `UniversalEventBuilderArgs`, `CoreStateless`, `CoreStatelessRequest`) plus `prefetchManagedEntries` entry-source helpers | node-sdk#core-sdk.ts; core-sdk#events/EventBuilder.ts#UniversalEventBuilderArgs | +| `@contentful/optimization-node/api-schemas` | Schemas + type guards incl. `isMergeTagEntry` | node-sdk#api-schemas.ts; core-sdk#contentful/typeGuards.ts#isMergeTagEntry | +| `@contentful/optimization-node/api-client` | API client re-export | node-sdk#api-client.ts | +| `@contentful/optimization-node/logger` | logger utilities (default + named) | node-sdk#logger.ts | ## Setup / initialization and binding @@ -105,7 +105,11 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core events; the caller owns when the request-bound Experience call happens and which browser framework 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 + source: core-sdk#handoff.ts#createRequestHandoffFromData; core-sdk#handoff.ts#assertOptimizationCacheSafety; core-sdk#handoff.ts#OptimizationHandoff +- `createRequestHandoffFromPreview()` accepts only an admitted `previewInitialExperience()` result, + binds its SDK-owned replay commands to a private route handoff, and rejects public or static cache + metadata. A blocked final page cannot produce a replay handoff. + source: core-sdk#handoff.ts#createRequestHandoffFromPreview; core-sdk#CoreStatelessRequest.ts#previewInitialExperience ## Identifier ownership @@ -149,6 +153,14 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core - 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 +- Initial browser continuation uses `previewInitialExperience()` plus + `createRequestHandoffFromPreview()`: the request client evaluates caller identify/track commands, + appends its page, and sends the admitted sequence as one forced-preflight mutation. The private + handoff carries the server-built events, request context, and route key; the live browser + retains the server-built wire events and submits the sequence through one combined hydration and current-page operation. Shared + actual-event validation, batching, consent, and fallback behavior is recorded in + [`../shared/concepts.md`](../shared/concepts.md#experience-preflight-and-private-replay). + source: core-sdk#CoreStatelessRequest.ts#previewInitialExperience; core-sdk#handoff.ts#createRequestHandoffFromPreview; kb:shared/concepts.md - `eventContext` (`UniversalEventBuilderArgs`: `locale?`, `userAgent?`, `page?`, `screen?`, `campaign?`, `location?`) is merged into every event built through the request client; a per-call payload wins over it. `page.*` requires `path`, `query`, `referrer`, `search`, `url` (`title` optional). The app builds @@ -183,6 +195,10 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core `plainText`, `preflight`) and `insightsOptions` (`CoreStatelessInsightsOptions`: `beacon`) apply only to that request's calls. source: core-sdk#CoreStateless.ts#CoreStatelessRequestOptions; core-sdk#CoreStateless.ts#CoreStatelessInsightsOptions +- Global `api.preflight` is a deprecated compatibility input and is inert in Node. Ordinary + stateless calls use request-local `experienceOptions.preflight`; `previewInitialExperience()` + forces preflight for its single ordered preview regardless of that request-local value. + source: core-sdk#CoreApiConfig.ts#CoreSharedApiConfig; core-sdk#CoreStateless.ts#createStatelessExperienceApiConfig; core-sdk#CoreStatelessRequest.ts#previewInitialExperience - SDK config is framework-agnostic; store the instance in a module-level singleton. No browser live updates or preview UI in Node. source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization @@ -203,6 +219,11 @@ source: node-sdk#ContentfulOptimization.ts#ContentfulOptimization; core-sdk#Core matching selection / broken variant link / all-locale payload: the resolver returns the baseline entry. Shared model: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). source: core-sdk#resolvers/OptimizedEntryResolver.ts#resolveWithContext +- `previewInitialExperience()` deliberately propagates Experience API, event-interceptor, and event + schema failures so a server can observe and classify them. A direct Node-plus-Web route catches + that rejection at its application boundary and returns baseline HTML without a handoff; it must + not use a request-derived handoff in public or static output. + source: core-sdk#CoreStatelessRequest.ts#previewInitialExperience; core-sdk#handoff.ts#assertOptimizationCacheSafety - Consent-blocked events fail closed without throwing (see Events & tracking). Insights-only calls and non-sticky `trackView` without a bound `profile.id` throw a method-specific error; sticky `trackView` throws only if it cannot derive a profile from its Experience response and none was bound. diff --git a/documentation/internal/sdk-knowledge/shared/concepts.md b/documentation/internal/sdk-knowledge/shared/concepts.md index 771cfeea0..b9b592228 100644 --- a/documentation/internal/sdk-knowledge/shared/concepts.md +++ b/documentation/internal/sdk-knowledge/shared/concepts.md @@ -183,11 +183,12 @@ 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 -(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 +A page event signals a page/route view. Auto-page trackers emit on each eligible route and dedupe +consecutive accepted route keys. Legacy `initialPageEvent` inputs are compatibility-only and do not +suppress current-page tracking. `AcceptedCurrentStateTracker` is the authority for a private replay +or a normal page event on the initial route. Interaction events (view/click/hover) are consent-gated +browser activity and use the resolved entry id. +source: react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter; web-sdk#ContentfulOptimization.ts#trackCurrentPage; core-sdk#tracking/AcceptedCurrentStateTracker.ts#AcceptedCurrentStateTracker ## Campaign attribution @@ -274,18 +275,64 @@ The API Client scopes Experience API requests under space and environment; an omitted environment resolves to `master`. source: api-client#ApiClientBase.ts#DEFAULT_ENVIRONMENT; api-client#experience/ExperienceApiClient.ts#ExperienceApiClient; api-client#insights/InsightsApiClient.ts#InsightsApiClient +## Experience preflight and private replay + +The Experience API client adds `type=preflight` only to single-profile mutations. Profile reads and +batch profile upserts suppress the query parameter even when it is present in client defaults or +per-call options. Core keeps the global `api.preflight` field for compatibility but neither its +stateful nor stateless runtime forwards it into the Experience client; stateless request-local +`experienceOptions.preflight` still controls ordinary single-profile request calls. +source: api-client#experience/ExperienceApiClient.ts#ExperienceApiClient; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig; core-sdk#CoreStateless.ts#createStatelessExperienceApiConfig; core-sdk#CoreApiConfig.ts#CoreSharedApiConfig + +Single-profile creation is a `POST` to +`/v3/spaces/{spaceId}/environments/{environment}/profiles`; updating an existing profile is a +`POST` to the same path plus `/{profileId}`. Those create and update paths resolve and pass +`preflight` to the shared mutation request. `getProfile()` is a `GET` that supplies only locale, and +`upsertManyProfiles()` removes `preflight` before sending its batch request. +source: api-client#experience/ExperienceApiClient.ts#createProfile; api-client#experience/ExperienceApiClient.ts#updateProfile; api-client#experience/ExperienceApiClient.ts#makeProfileMutationRequest; api-client#experience/ExperienceApiClient.ts#getProfile; api-client#experience/ExperienceApiClient.ts#upsertManyProfiles + +`previewInitialExperience()` evaluates caller-supplied identify/track commands, appends its own page +command, and sends the admitted sequence as one forced-preflight profile mutation. Request consent +gates the commands; the request EventBuilder builds actual events, then request-side event +interceptors and Experience-event schema validation run before the mutation. A blocked page +prevents the request; an accepted preview updates the request-local profile and selected +optimizations and returns the built event arrays for a private browser continuation. +source: core-sdk#CoreStatelessRequest.ts#previewInitialExperience; core-sdk#replay.ts#PreviewInitialExperienceOptions + +Private replay carries server-built Experience and Insights event arrays, with the profile known before preflight when available. Page-bearing journals require a private route key. Journals without a page can contain Personalization or Analytics events. Event IDs, timestamps, channel, library metadata, request context, and server interceptor changes are retained for browser delivery. Existing event schemas validate the payloads; no separate replay version or envelope schema is required. +source: core-sdk#replay.ts#OptimizationReplayEnvelope; core-sdk#CoreStatelessRequest.ts#previewExperience; core-sdk#handoff.ts#createRequestHandoffFromPreview + +Request handoff factories accept an optional hydration policy. Supplying it retains that policy in both the serialized handoff and its inferred return type, so a content or analytics-only result can be passed to its matching runtime without a literal assertion or a spread wrapper. Omitting the policy keeps the framework-neutral result. +source: core-sdk#handoff.ts#createRequestHandoffFromData; core-sdk#handoff.ts#createRequestHandoffFromPreview; core-sdk#handoff.ts#OptimizationHandoffWithHydration + +`previewExperience()` builds and intercepts all supplied inputs once on the server, validates the wire events, and evaluates the Experience array in one forced-preflight request. It retains both wire arrays for browser delivery without sending Insights on the server. A sticky view is built and intercepted once for both transports. `previewInitialExperience()` appends the SDK page to an optional prefix. Preview state is provisional; the result also retains the profile known before preflight. +source: core-sdk#CoreStatelessRequest.ts#previewExperience; core-sdk#CoreStatelessRequest.ts#previewInitialExperience + +`hydrateAndTrackCurrentPage()` owns one initial state and event operation. Readiness precedes delivery so preview content can render while network work is pending. New handoffs do not cancel earlier events. Ordinary-page fallback runs only when no page was accepted, including after partial mixed-journal failure. +source: web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#ContentfulOptimization.ts#emitInitialPage + +Browser replay filters the server-built Experience array by live consent and submits it in one batch through the ordinary queue. Event locale remains metadata; the normal SDK/request locale governs the API response. The Insights array follows with live consent and the returned or available initial profile. Browser queue interceptors and event schemas still apply, but the SDK does not rebuild event IDs, timestamps, channel, or context. Cross-transport interleaving and intermediate profiles are not reconstructed. Existing buffering and retries govern offline delivery; offline acceptance does not establish durable continuity before a live response succeeds. +source: core-sdk#CoreStateful.ts#replayOptimizationHandoff; core-sdk#queues/ExperienceQueue.ts#sendBatch; core-sdk#queues/InsightsQueue.ts#send; web-sdk#ContentfulOptimization.ts#promoteCommittedCurrentPage + +Browser replay reaches `ExperienceApiClient.upsertProfile()` through the stateful Experience queue. +The client sends `POST .../profiles` without a live profile id or `POST .../profiles/:id` with one, +and places the admitted event array in the request body's `events` field. The stateful client has no +preflight default and replay supplies no request-level override, so this request has no +`type=preflight` query parameter. +source: core-sdk#CoreStateful.ts#replayOptimizationHandoff; core-sdk#queues/ExperienceQueue.ts#sendBatch; core-sdk#queues/ExperienceQueue.ts#upsertProfile; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig; api-client#experience/ExperienceApiClient.ts#upsertProfile; api-client#experience/ExperienceApiClient.ts#createProfile; api-client#experience/ExperienceApiClient.ts#updateProfile; api-client#experience/ExperienceApiClient.ts#makeProfileMutationRequest; api-client#experience/ExperienceApiClient.ts#constructExperienceRequestBody + ## Optimization handoff `OptimizationHandoff` is the framework-neutral handoff shape for server, static, and edge rendered Optimization state. It can carry selected state (`selectedOptimizations`, `changes`, optional -`profile`), managed-entry baseline snapshots, and cache metadata. Public/static handoffs must not -carry request-derived profile state; public permutations need an application-owned `cache.key`. The -generated public-permutation `cache.key` is SDK identity and transport metadata, while framework -tags are caller-owned invalidation labels. The generic helper reports cache-safety warnings instead -of throwing. Node request handoff creation -throws a `TypeError` when request data with profile state is paired with `public-permutation` or +`profile`), managed-entry baseline snapshots, cache metadata, and private replay instructions. +Public/static handoffs must not carry request-derived profile state or replay; public permutations +need an application-owned `cache.key`. The generated public-permutation `cache.key` is SDK identity +and transport metadata, while framework tags are caller-owned invalidation labels. The generic +helper reports cache-safety warnings instead of throwing. Node request handoff creation throws a +`TypeError` when request data with profile state or replay is paired with `public-permutation` or `static` cache metadata. -source: core-sdk#handoff.ts#OptimizationHandoff; core-sdk#handoff.ts#createPublicPermutationCacheMetadata; core-sdk#handoff.ts#getOptimizationCacheSafetyWarnings; node-sdk#handoff.ts#createRequestHandoffFromData +source: core-sdk#handoff.ts#OptimizationHandoff; core-sdk#handoff.ts#createPublicPermutationCacheMetadata; core-sdk#handoff.ts#getOptimizationCacheSafetyWarnings; core-sdk#handoff.ts#assertOptimizationCacheSafety; core-sdk#handoff.ts#createRequestHandoffFromPreview `createHandoffFromSelections()` builds a selection handoff from application-owned selected optimizations and optional managed-entry snapshots. It does not include profile state and requires @@ -313,18 +360,28 @@ 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 -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` -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 -`undefined` fields intentionally, and marks the Experience request state successful even for -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 +Browser handoffs extend the core handoff with `hydration`; legacy `initialPageEvent` values are inert. +Content handoffs are accepted by `hydrateOptimizationHandoff`; analytics-only handoffs are accepted +by the analytics runtime. Both paths enforce cache safety before state is published. Browser SDK +cache-safety failures stay fail-closed: the runtime does not apply the supplied state or replay, and +framework fallback must not reuse that handoff. Cache-safe hydration or replay failures can discard +the handoff and continue with one ordinary page attempt. Browser SDK +state hydration is Web handoff-owned. Every cache-safe full browser handoff applies state under +durable-continuity suppression regardless of cache scope or replay presence, so the handoff changes +live memory without overwriting existing durable continuity. The raw +`hydrateOptimizationHandoffState` helper applies state only and leaves suppression and currentness +under caller control through its options; it does not infer either from cache scope or replay +presence. State hydration 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 `undefined` fields +intentionally, and marks the Experience request state successful even for undefined or empty handoff +state. Content handoff state hydration starts from a content reset for `selectedOptimizations` and +`changes`, so a 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#HandoffStateHydrationOptions; web-sdk#handoff.ts#applyHydratedSignals; web-sdk#handoff.ts#applySuccessfulEmptyHandoffHydration; web-sdk#storage/durableContinuityPersistence.ts#suppressDurableContinuityPersistence; core-sdk#handoff.ts#assertOptimizationCacheSafety + +Content and analytics handoffs use the same combined Web operation. State publication keeps latest-wins arbitration, but receiving newer state does not cancel an older event journal. Caller guards protect runtime lifetime rather than handoff replacement. Preview-backed rendering proceeds before browser delivery completes. +source: web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#handoff.ts#hydrateContentOptimizationHandoffState; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff 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..f1bacce2c 100644 --- a/documentation/internal/sdk-knowledge/web/nextjs-app-router.md +++ b/documentation/internal/sdk-knowledge/web/nextjs-app-router.md @@ -63,21 +63,17 @@ dependencies; each carries a symbol-anchored source pointer. 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` +- Request handoff: the bound `createRequestHandoff(options)` resolves server consent in the App + Router request resource, binds request URL/cookies/headers and profile continuity, then previews + optional identify/track commands followed by the initial page as one forced-preflight request. + An accepted preview becomes a private route-bound replay handoff; a blocked final page produces a + handoff without preview state or replay so the browser can make its normal current-page attempt. + If the lower-level helper accepts the preview but cannot derive a route key, it retains the preview + state in the handoff and omits replay. + `trustedRequestHandoff` is a compatibility input and is not read. Boolean consent seeds both + browser consent axes; object consent seeds `consent` when `events` is present and always sets + `persistenceConsent`, defaulting missing `persistence` to `false`. + source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#app-router-request-handoff.ts#toHandoffDefaults`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#server.tsx#createRequestHandoffFromPreviewOrData`; `nextjs-sdk#request-handoff-support.ts#createNextjsRequestRouteKey`; `core-sdk#CoreStatelessRequest.ts#previewInitialExperience`; `core-sdk#handoff.ts#createRequestHandoffFromPreview` - 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 @@ -92,10 +88,15 @@ dependencies; each carries a symbol-anchored source pointer. - Request hydration defaults to `preserve-server`. A configured resolver runs once during cached initialization with the SDK-derived request URL and route key. source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#bound-component-types.ts#NextjsAppRouterRequestHydration` +- Config-owned request `initialExperienceEvents` may be static or resolved once by the cached + initializer from SDK-derived `{ requestUrl, routeKey }`; those commands precede the final page in + both server preview and browser replay. The bound manual `createRequestHandoff(options)` and the + lower-level `/server` helper accept only an already-resolved command array. + source: `nextjs-sdk#app-router-request-runtime.tsx#resolveInitialExperienceEvents`; `nextjs-sdk#app-router-request-runtime.tsx#AppRouterCreateRequestHandoffOptions`; `nextjs-sdk#bound-component-types.ts#NextjsAppRouterRequestConfig`; `nextjs-sdk#server.tsx#NextjsRequestHandoffOptions`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff` - 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. + Legacy `initialPageEvent` input is accepted for compatibility but is not serialized. 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 +140,23 @@ 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, resolves optional initial Experience commands from either an array or a + request-snapshot resolver, and previews those commands plus the final page as one request. An + accepted preview carries a private replay bound to the request pathname and search. The + helper converts operational preview failures into a profileless private-request baseline handoff, + dropping preview state and replay while preserving the chosen hydration mode. The + compatibility `persist(response)` callback is inert; preview identity is not written from the Edge + response. + source: `nextjs-sdk#edge.ts#configureNextjsEdgeOptimization`; `nextjs-sdk#edge.ts#NextjsEdgeRequestHandoffOptions`; `nextjs-sdk#edge.ts#resolveInitialExperienceEvents`; `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#createEdgeRequestHandoffFromPreview`; `nextjs-sdk#edge.ts#createEdgeRequestRouteKey`; `nextjs-sdk#request-preview-fallback.ts#resolveRequestPreview`; `nextjs-sdk#request-preview-fallback.ts#createPrivateRequestPreviewFallbackHandoff`; `core-sdk#CoreStatelessRequest.ts#previewInitialExperience` - 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. + accepts an already-resolved initial-command array, previews it plus the final page, and returns a + private replay handoff when that preview is admitted and a route key can be derived. An accepted + preview without a route key still contributes handoff state but no replay. `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#NextjsRequestHandoffOptions`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#server.tsx#createRequestHandoffFromPreviewOrData`; `nextjs-sdk#request-handoff-support.ts#createNextjsRequestRouteKey`; `nextjs-sdk#server.tsx#persistNextjsAnonymousId`; `nextjs-sdk#server.tsx#ServerOptimizedEntry`; `nextjs-sdk#tracking-attributes.ts#getServerTrackingAttributes` ## Components & hooks @@ -187,7 +195,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. @@ -195,21 +203,17 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` ## Identifier ownership -| 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` | -| 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` | +| Identifier | Owner | Notes | source | +| --------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Request helpers read existing continuity; the live browser SDK writes replay results when persistence permits. The Edge handoff's compatibility `persist()` callback does not write preview identity. | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#server.tsx#readNextjsAnonymousId`; `nextjs-sdk#edge.ts#configureNextjsEdgeOptimization`; `web-sdk#ContentfulOptimization.ts#ContentfulOptimization` | +| 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 preview optional identify/track commands followed by the page, then bind the server-built event arrays to the initial route for browser replay. The bound root owns one combined hydration and initial event operation; preview rendering and live state readiness precede delivery completion. Server-built IDs, timestamps, channel, request context, and interceptor changes survive browser submission; live consent and ordinary queue interceptors still apply. Legacy `initialPageEvent` inputs do not own or suppress the route. + source: `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `core-sdk#handoff.ts#createRequestHandoffFromPreview`; `web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage`; `web-sdk#ContentfulOptimization.ts#trackCurrentPage` + - 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 @@ -217,8 +221,8 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` [`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. + Its legacy `initialPageEvent` input is inert; current-route dedupe comes from accepted live tracking + and private replay. source: `react-web-sdk#router/next-app.tsx#NextAppAutoPageTracker`; `react-web-sdk#auto-page/useAutoPageEmitter.ts#InitialAutoPageEvent` - 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 @@ -272,21 +276,14 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` 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 + their rewrite target while the handler still applies sanitized request context. 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` + request context, then write only the current request URL into the SDK-owned forwarded header. The + request-context handler performs no SDK/API, consent, profile, or cookie-persistence work; its + legacy options are inert. + source: `nextjs-sdk#request-handler.ts#createNextjsOptimizationContextHandler`; `nextjs-sdk#request-handler.ts#hasExistingTerminalMiddlewareTarget`; `nextjs-sdk#request-handler.ts#createSanitizedForwardedRequestHeaders`; `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 @@ -294,7 +291,7 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` static cache metadata before request evaluation. The manual App Router request helper defaults omitted cache metadata to `private-request`; the nested request family uses that same default. - source: `nextjs-sdk#handoff.ts#createPublicPermutationHandoff`; `core-sdk#handoff.ts#createPublicPermutationCacheMetadata`; `nextjs-sdk#app-router-request-handoff.ts#assertRequestHandoffCacheMetadata`; `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#edge.ts#assertEdgeRequestHandoffCacheMetadata`; `node-sdk#handoff.ts#createRequestHandoffFromData` + source: `nextjs-sdk#handoff.ts#createPublicPermutationHandoff`; `core-sdk#handoff.ts#createPublicPermutationCacheMetadata`; `nextjs-sdk#app-router-request-handoff.ts#assertRequestHandoffCacheMetadata`; `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#edge.ts#assertEdgeRequestHandoffCacheMetadata`; `core-sdk#handoff.ts#createRequestHandoffFromData` - **Rendered server output is request-specific:** the bound `OptimizedEntry` reads the current request handoff state and resolves a supplied or managed baseline entry with that request's `selectedOptimizations`; merge tags can also read its profile. Request handoff state, resolved @@ -302,21 +299,21 @@ source: `nextjs-sdk#app-router-server.tsx#bindNextjsAppRouterServerOptimization` cache key covers the complete personalization context. Raw Contentful baseline-entry caching is a separate application policy. source: `nextjs-sdk#app-router-server.tsx#OptimizedEntry`; `nextjs-sdk#app-router-server.tsx#getAppRouterBaselineEntry`; `core-sdk#CoreBase.ts#resolveOptimizedEntry` -- **The bound root provides a handoff-to-live transition:** React Web builds the initial browser - render from `handoff.state` and `handoff.entries`, then hydrates the owned live SDK before - switching the context runtime. Children remain mounted through the transition. - source: `nextjs-sdk#app-router-server.tsx#toClientRootConfig`; `react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime`; `react-web-sdk#provider/OptimizationProvider.tsx#initializeServerOptimizationState`; `react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider` - A handoff without matching baseline entries cannot guarantee no visual change for managed-entry - client rendering; stable takeover also requires the browser to render the same baseline entry - through the same component path or to receive a matching managed-entry handoff in `handoff.entries`. +- The bound root owns one combined hydration and initial event operation; preview rendering and live state readiness precede delivery completion. The root's ordinary route tracking effect performs the browser commit after that transition. Children remain mounted throughout. A handoff without matching baseline entries cannot guarantee no visual change for managed-entry client rendering; stable takeover also requires the browser to render the same baseline entry through the same component path or to receive a matching managed-entry handoff in `handoff.entries`. + source: `nextjs-sdk#app-router-server.tsx#toClientRootConfig`; `react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime`; `react-web-sdk#provider/OptimizationProvider.tsx#initializeProviderSdk`; `react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider`; `react-web-sdk#root/OptimizationRoot.tsx#PageEmitter`; `web-sdk#ContentfulOptimization.ts#trackCurrentPage` source: `react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime`; `react-web-sdk#provider/OptimizationProvider.tsx#createPrefetchedManagedEntries`; `react-web-sdk#optimized-entry/useOptimizedEntry.ts#useManagedBaselineEntry` ## Failure & fallback behavior -- The request-family initializer requires the SDK-forwarded `x-ctfl-opt-request-url` header and - throws setup guidance for the Optimization request handler/proxy before it resolves any request - component when that header is absent. - source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#request-context.ts#NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER` +- The automatic request family treats missing forwarded request context and operational preview + failures (Experience API, request command resolver, interceptor, or event-schema failures) as + profileless private-request baseline handoffs. It discards preview state and replay, preserves the + chosen hydration mode, and lets the browser make its normal page attempt. For missing or malformed + forwarded URLs, it omits server route inputs so the injected client root or request page tracker + derives the real browser route instead of emitting a synthetic one. The public bound + `optimization.createRequestHandoff()` uses the same operational fallback; the lower-level + `createNextjsRequestHandoff()` remains strict. + source: `nextjs-sdk#app-router-request-runtime.tsx#bindNextjsAppRouterRequestRuntime`; `nextjs-sdk#request-preview-fallback.ts#resolveRequestPreview`; `nextjs-sdk#request-preview-fallback.ts#createPrivateRequestPreviewFallbackHandoff`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff` - Baseline fallback when event policy produced no selections / no variant / unresolved links / all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). diff --git a/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md b/documentation/internal/sdk-knowledge/web/nextjs-pages-router.md index c4cd6cc39..bb61e5196 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`. + - The shared `cookie` config is browser continuity configuration. Pages Router request preview does + not turn it into a server `Set-Cookie` write. + source: `nextjs-sdk#bound-component-types.ts#NextjsOptimizationCookieConfig`; `nextjs-sdk#pages-router-server.ts#toServerOptimizationConfig`; `web-sdk#ContentfulOptimization.ts#ContentfulOptimization`. - **`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. @@ -55,9 +56,10 @@ source: `nextjs-sdk#../package.json`; `nextjs-sdk#pages-router.ts#bindNextjsPage - Consent resolver reads a merged Pages Router cookie reader built from `context.req.cookies` and the raw `cookie` header. source: `nextjs-sdk#pages-router-server.ts#createPagesRouterCookieReader`; `nextjs-sdk#pages-router-server.ts#resolveServerConsent`. - - `createRequestHandoff(context, options)` returns a browser handoff. It also writes the anonymous - ID `Set-Cookie` when profile persistence permits it. - source: `nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization`; `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `nextjs-sdk#pages-router-server.ts#appendSetCookie`. + - `createRequestHandoff(context, options)` previews optional identify/track commands plus the + initial page as one forced-preflight request and returns a private browser replay handoff when + the final page is admitted. It does not write preview identity into the server response. + source: `nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization`; `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `core-sdk#CoreStatelessRequest.ts#previewInitialExperience`. - Request handoffs carry browser defaults derived from the resolved server consent: boolean consent seeds both consent axes, while object consent seeds `consent` only when `events` is present and always seeds `persistenceConsent`, defaulting missing `persistence` to `false`. Managed-entry @@ -68,6 +70,11 @@ source: `nextjs-sdk#../package.json`; `nextjs-sdk#pages-router.ts#bindNextjsPage handoffs retain the normalized descriptor and store the fetched entry's `sys.id`; the bound root forwards them to React Web, where a matching slug source hydrates without a client fetch. source: `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterRequestHandoffOptions`; `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `core-sdk#CoreBase.ts#prefetchManagedEntries`; `core-sdk#CoreBase.ts#ManagedEntryDescriptor`; `core-sdk#CoreBase.ts#ManagedEntryHandoff`. + - `initialExperienceEvents` is an already-resolved command array inherited from the low-level Next + request-handoff options. The Pages server helper has no event-resolver callback; in contrast, the + App binder's config-owned request resource resolves from `{ requestUrl, routeKey }` before it + calls the same array-only helper. + source: `nextjs-sdk#server.tsx#NextjsRequestHandoffOptions`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`; `nextjs-sdk#pages-router-server.ts#NextjsPagesRouterRequestHandoffOptions`; `nextjs-sdk#bound-component-types.ts#NextjsAppRouterRequestConfig`; `nextjs-sdk#app-router-request-runtime.tsx#resolveInitialExperienceEvents`. - `resolveEntriesForSelections` is re-exported through the Pages Router binding so public/static selection renders can resolve multiple baseline entries with one selected-optimization set; shared behavior is recorded in [`../shared/concepts.md`](../shared/concepts.md#optimization-handoff). @@ -92,7 +99,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` | current-page tracker; legacy `initialPageEvent` is inert | `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` | @@ -127,12 +134,12 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en ## Identifier ownership -| Identifier | Owner | Notes | source | -| -------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Written by server props helper via `Set-Cookie`; must NOT be `HttpOnly` (browser reads it) | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#createNextjsAnonymousIdSetCookieHeader` | -| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `consent.server` and personalizes on the result | `impl:nextjs-sdk_pages-router#lib/config.ts`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts` | -| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | -| preview-panel enable flag | reader | Reader-owned, gated on a browser env var; the guide uses the standard `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` prefix (the ref impl's bare `PUBLIC_...` is non-standard) | `impl:nextjs-sdk_pages-router#lib/config.ts` | +| Identifier | Owner | Notes | source | +| -------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` (profile/anon-id cookie) | SDK | Request preview reads existing continuity; the live browser SDK writes replay results when persistence permits. The Pages server helper does not write preview identity. | `core-sdk#constants.ts#ANONYMOUS_ID_COOKIE`; `nextjs-sdk#cookies.ts#DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE`; `nextjs-sdk#pages-router-server.ts#createPagesRouterCookieReader`; `web-sdk#ContentfulOptimization.ts#ContentfulOptimization` | +| app consent cookie (e.g. `personalizationConsentCookie`) | reader | Reader names/writes/reads; SDK only calls `consent.server` and personalizes on the result | `impl:nextjs-sdk_pages-router#lib/config.ts`; `impl:nextjs-sdk_pages-router#lib/optimization-server.ts` | +| `NEXT_PUBLIC_*` env vars | reader | Next.js exposes only `NEXT_PUBLIC_`-prefixed vars to the browser | `extern:Next.js exposes only NEXT_PUBLIC_-prefixed vars to the browser` | +| preview-panel enable flag | reader | Reader-owned, gated on a browser env var; the guide uses the standard `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` prefix (the ref impl's bare `PUBLIC_...` is non-standard) | `impl:nextjs-sdk_pages-router#lib/config.ts` | ## Events & tracking @@ -140,11 +147,12 @@ 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 - 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 + available, and parses it into Core page URL/query/search context before the server preview. The + serialized replay retains the server-built events and their request context for browser commit. 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. - source: nextjs-sdk#pages-router-server.ts#createPagesRouterRequest; nextjs-sdk#pages-router-server.ts#createPagesRouterRequestUrl; nextjs-sdk#server.tsx#createNextjsRequestContext; core-sdk#page-context.ts#createPageContextFromUrl; kb:web/react-web.md; kb:shared/concepts.md + source: nextjs-sdk#pages-router-server.ts#createPagesRouterRequest; nextjs-sdk#pages-router-server.ts#createPagesRouterRequestUrl; nextjs-sdk#server.tsx#createNextjsRequestContext; nextjs-sdk#server.tsx#createNextjsRequestHandoff; core-sdk#CoreStatelessRequest.ts#previewInitialExperience; core-sdk#CoreStateful.ts#replayOptimizationHandoff; core-sdk#page-context.ts#createPageContextFromUrl; kb:web/react-web.md; kb:shared/concepts.md - The Pages Router client binder forwards `beforeInitialPage` only to its bound content root; its bound provider and analytics root projections omit it. On this path the bound content root, rather than `NextPagesAutoPageTracker`, owns page emission. The React-owned callback, watchdog, readiness, @@ -157,11 +165,9 @@ 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)` sends `pagePayload` as the final command in one server preview. An accepted preview carries a private route-bound replay. The bound root owns one combined hydration and initial event operation; preview rendering and live state readiness precede delivery completion. Legacy `initialPageEvent` is inert. + source: nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization; nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff; nextjs-sdk#server.tsx#createNextjsRequestHandoff; core-sdk#CoreStatelessRequest.ts#previewInitialExperience; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#ContentfulOptimization.ts#trackCurrentPage + - 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`. @@ -191,15 +197,15 @@ source: `nextjs-sdk#pages-router.ts#OptimizedEntry`; `react-web-sdk#optimized-en ## Version / runtime quirks -- **No proxy/middleware.** Server identity + resolution + `Set-Cookie` all happen inside - `getServerSideProps` via `createRequestHandoff(context, options)`. - source: `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `nextjs-sdk#pages-router-server.ts#appendSetCookie`. +- **No proxy/middleware.** Request preview and resolution happen inside `getServerSideProps` via + `createRequestHandoff(context, options)`; preview identity is not persisted by that server helper. + source: `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `nextjs-sdk#server.tsx#createNextjsRequestHandoff`. - `getServerSideProps` is already per-request dynamic — no static/ISR conflict; the page is the request boundary. (Contrast App Router, where server personalization forces a route dynamic.) source: `extern:Next.js getServerSideProps runs per request (never statically pre-rendered)`. - Request handoff helpers accept `private-request` cache metadata and reject public or static cache - metadata through the shared Node request handoff path. - source: `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `node-sdk#handoff.ts#createRequestHandoffFromData` + metadata through the shared Node replay-handoff path. + source: `nextjs-sdk#pages-router-server.ts#createNextjsPagesRouterRequestHandoff`; `core-sdk#handoff.ts#createRequestHandoffFromPreview` - Static and ISR Pages Router routes do not have request context. Public permutation handoffs are valid for those routes only when application code supplies selected optimizations and public permutation dimensions without reading request profile state. @@ -216,11 +222,12 @@ 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`. +- The bound `createRequestHandoff()` converts operational preview failures (Experience API, + server-consent resolver, interceptor, or event-schema failures) into a profileless + private-request baseline handoff. It drops preview state and replay, and the browser root makes + its normal page attempt. A consent-blocked final page likewise returns an unpersonalized handoff + without an API call or replay. + source: `nextjs-sdk#pages-router-server.ts#bindNextjsPagesRouterServerOptimization`; `nextjs-sdk#request-preview-fallback.ts#resolveRequestPreview`; `nextjs-sdk#request-preview-fallback.ts#createPrivateRequestPreviewFallbackHandoff`; `core-sdk#CoreStatelessRequest.ts#previewInitialExperience`. - 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..e26d480db 100644 --- a/documentation/internal/sdk-knowledge/web/react-web.md +++ b/documentation/internal/sdk-knowledge/web/react-web.md @@ -49,7 +49,8 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web source: core-sdk#CoreBase.ts#CoreConfig - `defaults` (`consent`, `persistenceConsent`), `api?`, `allowedEventTypes?`, `onEventBlocked?`, `queuePolicy?` — `core-sdk` `CoreStatefulConfig`. `api` = `experienceBaseUrl`, - `insightsBaseUrl` — `core-sdk` `CoreSharedApiConfig`. + `insightsBaseUrl` — `core-sdk` `CoreSharedApiConfig`. Its inherited `preflight` compatibility + field is deprecated and inert in the stateful browser runtime. source: core-sdk#CoreStateful.ts#CoreStatefulConfig; core-sdk#StatefulDefaults.ts#StatefulDefaults; core-sdk#CoreApiConfig.ts#CoreSharedApiConfig - `app` (`name`, `version`), `cookie?` (`domain`, `expires` days) — `web-sdk` `OptimizationWebConfig`; `web-sdk` `CookieAttributes`. @@ -79,21 +80,14 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web - Mount once. A second **owned** instance in the same browser runtime throws `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. - 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 - provider renders children first against a snapshot runtime, invokes `onStatesReady` before it - publishes the live runtime, and then rerenders children under the live runtime; provider-managed - state subscribers registered there observe child auto-page effects that emit through that live - runtime. - source: react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider; react-web-sdk#provider/OptimizationProvider.tsx#bindOnStatesReady; web-sdk#presentation/optimizationRootRuntime.ts#createOptimizationRootSdkBinding; core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; react-web-sdk#provider/OptimizationProvider.onStatesReady.test.tsx +- Analytics-only roots render their children while the browser operation runs. They initialize one narrow runtime, delegate handoffs to the combined initial operation, and use runtime identity as the lifetime guard. Newer handoffs preserve earlier event delivery while state application keeps latest-wins arbitration. + source: react-web-sdk#root/OptimizationAnalyticsRoot.tsx#OptimizationAnalyticsRoot; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage + +- `onStatesReady` runs after initial hydration and before replay events. Its returned cleanup is + attached to the live SDK binding. The provider exposes the live runtime without waiting for + event delivery; recoverable hydration errors preserve that runtime. With no handoff, a setup + callback failure disposes an owned binding and publishes initialization failure. + source: react-web-sdk#provider/OptimizationProvider.tsx#initializeProviderSdk; react-web-sdk#provider/OptimizationProvider.tsx#bindOnStatesReady; react-web-sdk#provider/OptimizationProvider.tsx#OptimizationProvider ## Components & hooks @@ -105,7 +99,7 @@ Package source root: `packages/web/frameworks/react-web-sdk/src`; underlying Web | `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 | +| next-pages / next-app tracker | component | `/router/next-pages` or next-app | legacy `initialPageEvent` input is accepted but inert | `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 | @@ -149,13 +143,8 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn connects during React layout effects. Loading, committed content, and `preserve-server` adoption settle before browser paint; preserved content stays visible through snapshot-to-live adoption. source: react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntrySnapshot; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController -- Before-initial-page readiness applies only inside a root with `beforeInitialPage` and holds an open - presentation until the direct page attempt reaches terminality. `preserve-server`, a permitted - pre-existing seed, and an existing commitment remain visible. The entry's existing deadline can - commit baseline first, and default non-live behavior keeps that fallback after the - before-initial-page work later finishes. - Outside this root path, the readiness context defaults to ready. - source: react-web-sdk#context/BeforeInitialPageContext.tsx#BeforeInitialPageContext; react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntrySnapshot; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController +- Initial event delivery does not gate presentation. Preview-backed entries render from available data while callback work and transport are pending; ordinary SDK and entry readiness still govern unseeded content. + source: react-web-sdk#optimized-entry/useOptimizedEntry.ts#useOptimizedEntrySnapshot; react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot; web-sdk#presentation/OptimizedEntryController.ts#OptimizedEntryController - **Loading model:** default `client-only-hidden-until-ready` hydration renders baseline as a hidden layout target, commits resolved content when ready, and commits baseline after a settled failure or a **5s** timeout (`BASELINE_REVEAL_TIMEOUT_MS = 5000`) if resolution remains open; @@ -181,9 +170,9 @@ 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). +- Page events: auto-page trackers emit on navigation; each dedupes consecutive accepted route keys + including Strict Mode double effects. Mount ONE tracker per router tree. Next Pages/App trackers + retain `initialPageEvent` only as an inert compatibility input. 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 - 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 @@ -199,33 +188,18 @@ 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. +- Initial page tracking runs on mount, not only on navigation: the shared emitter calls + `sdk.trackCurrentPage()` in an effect even without a payload builder, in which case Web emits the + legacy empty page payload. Legacy `initialPageEvent: 'skip'` does not 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 -- 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 - `{ 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 -- 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 - route change after readiness emits normally. - source: react-web-sdk#root/OptimizationRoot.tsx#BeforeInitialPageSequence; react-web-sdk#auto-page/useAutoPageEmitter.ts#useAutoPageEmitter +- A root with a handoff and route key starts one combined initial operation. Preview-backed children render while hydration, prerequisite work, or delivery is pending. `onStatesReady` subscribes after state hydration and before initial replay emission; live runtime availability does not wait for event transport. + source: react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot; react-web-sdk#provider/OptimizationProvider.tsx#initializeProviderSdk; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage +- `beforeInitialPage` supplies receiver-safe identify/screen/track methods to browser prerequisite work. Matching page replay bypasses the callback. Otherwise the operation awaits returned callback work or its watchdog before reading current router inputs and attempting the ordinary page. Callback completion gates the initial page attempt, not presentation. The event-completion presentation context is absent; entry readiness follows SDK and entry data. A root owns this sequence for its subtree; a separate tracker adds 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#OptimizationRoot; web-sdk#ContentfulOptimization.ts#emitInitialPage +- Later route tracking uses the normal current-page deduplication. A page already being delivered is not canceled by navigation or a newer handoff. The existing route tracker reserves the initial operation and suppresses stale route completion; later routing uses the ordinary auto-page hook. + source: react-web-sdk#root/OptimizationRoot.tsx#OptimizationRoot; react-web-sdk#root/OptimizationRoot.tsx#PageEmitter; web-sdk#ContentfulOptimization.ts#trackCurrentPage - `getPagePayload` receives `{ context, routeKey, isInitialEmission }`; React Router `context` has `pathname`. It returns `AutoPagePayload | undefined`, the argument shape accepted by `sdk.page()`. Put application-specific route values under `properties` rather than returning arbitrary @@ -284,13 +258,11 @@ 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#initializeProviderSdk; web-sdk#handoff.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 - `handoff.state`; unsafe public/static handoffs with profile state throw before provider children - render. - source: react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime; core-sdk#handoff.ts#assertOptimizationCacheSafety +- Handoff-backed initial rendering validates cache safety and consumes preview state through a read-only snapshot. Provider initialization reports hydrated state and registers `onStatesReady` before the root's combined event operation proceeds. State readiness and preview rendering are independent of delivery completion. + source: react-web-sdk#provider/OptimizationProvider.tsx#createInitialRuntime; react-web-sdk#provider/OptimizationProvider.tsx#initializeProviderSdk; core-sdk#handoff.ts#assertOptimizationCacheSafety - Live updates precedence: preview panel open → per-entry `liveUpdates` → root `liveUpdates` → default. The default retains the first committed presentation for the same baseline entry ID. Effective live updates accept later defined selections; `[]` resolves baseline content and @@ -322,19 +294,8 @@ source: core-sdk#runtime/SnapshotRuntime.ts#SnapshotRuntime; core-sdk#runtime/Sn all-locale payloads: see [`../shared/concepts.md`](../shared/concepts.md#baseline-fallback). source: react-web-sdk#optimized-entry/OptimizedEntry.tsx#OptimizedEntry; concept:entry-personalization-and-variant-resolution -- A callback throw, returned-work rejection, or watchdog expiry is reported through `onError` when - supplied and logged otherwise. While the root remains mounted on the same runtime, the direct page - is still attempted. A throwing `onError` and page rejection are caught and logged; page rejection - and `{ accepted: false }` both release readiness. The watchdog does not cancel callback work or - requests, and fire-and-forget work is not awaited. If the root unmounts or its live owned runtime - is no longer current, this sequence suppresses only unsent local page/readiness continuation; it - 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 +- Callback throws, returned-work rejection, and watchdog expiry report through `onError` or logging and permit the ordinary page. The watchdog does not cancel already-started callback requests. Runtime lifetime cancellation prevents unsent initial page work. Recoverable handoff hydration errors preserve a usable owned or injected runtime and permit replay/page delivery while surfacing the error. + source: react-web-sdk#before-initial-page/beforeInitialPage.ts#runBeforeInitialPage; react-web-sdk#provider/OptimizationProvider.tsx#initializeProviderSdk; web-sdk#ContentfulOptimization.ts#emitInitialPage - 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..77bb97685 100644 --- a/documentation/internal/sdk-knowledge/web/web.md +++ b/documentation/internal/sdk-knowledge/web/web.md @@ -40,6 +40,9 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: source: core-sdk#CoreBase.ts#locale; core-sdk#CoreBase.ts#logLevel; api-client#lib/logger/logging.ts#LogLevels - `api.experienceBaseUrl` / `api.insightsBaseUrl`. source: core-sdk#CoreApiConfig.ts#experienceBaseUrl; core-sdk#CoreApiConfig.ts#insightsBaseUrl + - Inherited global `api.preflight` is a deprecated compatibility input and is inert in the + stateful Web runtime. + source: core-sdk#CoreApiConfig.ts#CoreSharedApiConfig; core-sdk#CoreStateful.ts#createStatefulExperienceApiConfig - `defaults.consent`, `defaults.persistenceConsent` — **`persistenceConsent` defaults to `consent`** (`persistenceConsent ?? consent`). source: core-sdk#StatefulDefaults.ts#resolveStatefulDefaults @@ -55,25 +58,12 @@ wraps this). Package source root: `packages/web/web-sdk/src`; shared core: `CoreConfig.contentful`; see [`../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 - `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 - fields are present, the helper awaits the Web SDK state interceptor, treats own-property presence - 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 -- `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 +- Browser handoff state is provisional and memory-only. `hydrateAndTrackCurrentPage()` owns hydration, replay admission, and one ordinary-page fallback. Its setup callback runs after hydration and before events, including recoverable hydration errors, without waiting for transport. The state-only handoff helpers apply cache-safe state without retaining replay. Empty state clears content selections and changes while retaining the profile; present fields pass through state interceptors and latest-wins publication arbitration. + source: web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#handoff.ts#hydrateOptimizationHandoff; web-sdk#handoff.ts#hydrateContentOptimizationHandoffState; web-sdk#handoff.ts#applyHydratedSignals +- Full handoff hydration suppresses durable continuity writes regardless of cache scope. Existing cookies and LocalStore continuity remain intact until a successful live Experience response and persistence consent permit promotion. Offline acceptance is queue acceptance and has no live response yet. + source: web-sdk#ContentfulOptimization.ts#promoteCommittedCurrentPage; web-sdk#handoff.ts#applyHydratedSignals; web-sdk#storage/durableContinuityPersistence.ts#suppressDurableContinuityPersistence +- Repeated calls with the same handoff object share one completion. Distinct handoffs keep their events when newer state arrives. Reset, destroy, or a caller lifetime guard can stop delivery before it starts. Replay route admission is captured when the operation starts. A later route or handoff cannot revoke that admission, and an older response cannot replace current-route deduplication. Matching page replay suppresses an ordinary page; mismatched, unusable, blocked, or failed replay falls back only if no page was accepted. Later failure in a mixed journal preserves earlier page acceptance. Ordinary tracking waits for the active initial completion and uses the existing accepted/in-flight route tracker; distinct initial journals retain their events. + source: web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#ContentfulOptimization.ts#emitInitialPage; web-sdk#ContentfulOptimization.ts#trackCurrentPage; core-sdk#CoreStateful.ts#replayOptimizationHandoff ## Components & hooks @@ -165,9 +155,10 @@ None (imperative class + Web Components; no React surface). Web Components eleme 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. +- `trackCurrentPage({ routeKey, buildPayload, initialPageEvent? })` dedupes consecutive accepted + route keys. Omitted `buildPayload` emits the legacy empty page payload. The compatibility + `initialPageEvent` input is inert, including `'skip'`; a bare `page()` always emits when consent + permits. source: web-sdk#ContentfulOptimization.ts#trackCurrentPage; 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: @@ -189,15 +180,8 @@ 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 - `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 + runtime with `tracking`, `trackCurrentPage`, `flush`, and `destroy`, but no content-resolution surface. `hydrateOptimizationAnalyticsHandoff()` delegates hydration and the initial page decision to the full runtime's combined operation. Earlier handoff event delivery continues when a newer handoff arrives; state publication keeps latest-wins arbitration. All cache-safe handoff state uses durable-continuity suppression. + source: web-sdk#analytics.ts#initializeOptimizationAnalyticsRuntime; web-sdk#analytics.ts#hydrateOptimizationAnalyticsHandoff; web-sdk#ContentfulOptimization.ts#hydrateAndTrackCurrentPage; web-sdk#handoff.ts#applyHydratedSignals - 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). @@ -252,6 +236,8 @@ None (imperative class + Web Components; no React surface). Web Components eleme - `consent(false)` blocks non-allowed events and clears SDK durable storage; does NOT drop the active in-memory profile (use `reset()`) or erase app/server/CMP records. source: core-sdk#CoreStateful.ts#consent +- Private replay is rejected on public or static handoffs before state application. Live consent, ordinary interceptors, and event schemas govern submission of server-built events through the normal Experience and Insights queues. Recoverable state-apply errors are reported and delivery continues; reset/destroy lifetime cancellation stops unsent work. Ordinary-page fallback is conditional on no accepted page. + source: core-sdk#handoff.ts#assertOptimizationCacheSafety; core-sdk#CoreStateful.ts#replayOptimizationHandoff; core-sdk#queues/ExperienceQueue.ts#sendBatch; core-sdk#queues/InsightsQueue.ts#send; web-sdk#ContentfulOptimization.ts#emitInitialPage - Preview panel: separate published package `@contentful/optimization-web-preview-panel` (dir `packages/web/preview-panel`), `attachOptimizationPreviewPanel` is its DEFAULT export. `attachOptimizationPreviewPanel({ contentful? | entries? | optimization?, nonce? })`; diff --git a/implementations/nextjs-sdk_app-router/README.md b/implementations/nextjs-sdk_app-router/README.md index af0e38164..2b90c724d 100644 --- a/implementations/nextjs-sdk_app-router/README.md +++ b/implementations/nextjs-sdk_app-router/README.md @@ -28,8 +28,8 @@ 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. +- A client-only binding in `lib/optimization-client.ts` that supplies the request root's browser + runtime. - Request-bound Server Components with browser hydration and live updates. - Static public permutation and analytics-only handoff. - App-owned and SDK-managed Contentful entry fetching. @@ -87,19 +87,19 @@ async function PrivateRequestSlot() { Keep provider-dependent tools inside `RequestOptimizationRoot`. -## Run before-initial-page work +## Preview an ordered initial batch -`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 the maintained identify-before-page scenario from +`?beforeInitialPage=readiness` as a server preview prefix. It injects +`lib/optimization-client.ts`'s `ClientRequestOptimizationRoot` into the server request family as a +Client Component reference. 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. +client root derives the current App Router route and lazy page payload in the browser, so no +payload-builder function crosses the Server Component boundary. Hydration publishes private preview +state in memory and starts the combined initial event operation. The root's later ordinary +current-page tracking call attempts it once; an unusable replay follows the ordinary page path. Do +not mount a separate request page tracker in the same subtree. ## Choose entry-fetch ownership diff --git a/implementations/nextjs-sdk_app-router/lib/optimization-client.ts b/implementations/nextjs-sdk_app-router/lib/optimization-client.ts index 613c80b27..0f7242dbf 100644 --- a/implementations/nextjs-sdk_app-router/lib/optimization-client.ts +++ b/implementations/nextjs-sdk_app-router/lib/optimization-client.ts @@ -4,9 +4,6 @@ import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization- 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 @@ -29,15 +26,6 @@ const optimization = bindNextjsAppRouterClientOptimization({ 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 }, }) diff --git a/implementations/nextjs-sdk_app-router/lib/optimization.ts b/implementations/nextjs-sdk_app-router/lib/optimization.ts index 0a3e0e613..7a8b91c4e 100644 --- a/implementations/nextjs-sdk_app-router/lib/optimization.ts +++ b/implementations/nextjs-sdk_app-router/lib/optimization.ts @@ -1,23 +1,19 @@ import { bindNextjsAppRouterServerOptimization, - createPublicPermutationCacheMetadata, + type NextjsAppRouterServerOptimizationConfig, } from '@contentful/optimization-nextjs/app-router/server' -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 { client } from './contentful' -import { getCustomerSegment, type CustomerSegment } from './customer-segments' +import type { CustomerSegment } from './customer-segments' import { ClientRequestOptimizationRoot } from './optimization-client' import { getAppConsent } from './util' const HIDDEN_UNTIL_READY_ROUTE = '/hidden-until-ready' -const PUBLIC_HANDOFF_PREFIXES = ['/selection-handoff/', '/analytics-only/'] as const +const BEFORE_INITIAL_PAGE_QUERY_VALUE = 'readiness' type AppRouterOptimization = ReturnType export type ContentHandoff = NonNullable< @@ -34,7 +30,7 @@ const serverOptimizationConfig = { name: 'Contentful Optimization Next.js SDK App Router', version: '0.1.0', }, -} as const +} satisfies NextjsAppRouterServerOptimizationConfig const serverConsent: NextjsOptimizationServerConsentResolver = ({ cookies }) => getAppConsent(cookies) ? { events: true, persistence: true } : false @@ -53,6 +49,11 @@ const optimization = bindNextjsAppRouterServerOptimization( routeKey.split('?')[0] === HIDDEN_UNTIL_READY_ROUTE ? 'client-only-hidden-until-ready' : 'preserve-server', + initialExperienceEvents: ({ requestUrl }) => + new URL(requestUrl).searchParams.get('beforeInitialPage') === + BEFORE_INITIAL_PAGE_QUERY_VALUE + ? [{ type: 'identify', userId: 'charles', traits: { identified: true } }] + : [], }, }, { @@ -75,25 +76,6 @@ export const { OptimizationRoot: RequestOptimizationRoot, OptimizedEntry: Reques optimization.request export { getServerTrackingAttributes } -const cacheMiddleware: NextjsPublicPermutationCacheMiddleware = - createNextjsPublicPermutationCacheMiddleware({ - resolveCache: (request) => { - const segmentSlug = getPublicHandoffSegmentSlug(request.nextUrl.pathname) - const segment = segmentSlug === undefined ? undefined : getCustomerSegment(segmentSlug) - - return segment === undefined - ? undefined - : createPublicPermutationCacheMetadata({ - cacheVersion: segment.cacheVersion, - entryIds: segment.baselineEntryIds, - locale: segment.locale, - permutationKey: segment.slug, - selectedOptimizations: segment.selectedOptimizations, - tags: createCustomerSegmentCacheTags(segment), - }) - }, - }) - const forwardOptimizationContext = createNextjsOptimizationContextHandler() export function createCustomerSegmentHandoff(segment: CustomerSegment): ContentHandoff { @@ -101,7 +83,6 @@ export function createCustomerSegmentHandoff(segment: CustomerSegment): ContentH cacheVersion: segment.cacheVersion, entryIds: segment.baselineEntryIds, hydration: 'preserve-server', - initialPageEvent: 'emit', locale: segment.locale, permutationKey: segment.slug, selectedOptimizations: segment.selectedOptimizations, @@ -114,7 +95,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, @@ -126,26 +106,10 @@ function createCustomerSegmentCacheTags(segment: CustomerSegment): readonly stri return [`ctfl-opt-segment:${segment.slug}:v${segment.cacheVersion}`] } -function getPublicHandoffSegmentSlug(pathname: string): string | undefined { - const prefix = PUBLIC_HANDOFF_PREFIXES.find((candidate) => pathname.startsWith(candidate)) - if (prefix === undefined) return undefined - - const segment = pathname.slice(prefix.length) - return segment.length > 0 ? segment : undefined -} - export async function proxy(request: NextRequest): Promise { - if (isPublicHandoffPath(request.nextUrl.pathname)) { - return cacheMiddleware(request) - } - return forwardOptimizationContext(request) } -function isPublicHandoffPath(pathname: string): boolean { - return PUBLIC_HANDOFF_PREFIXES.some((prefix) => pathname.startsWith(prefix)) -} - export function createRoutePagePayload( routeKey: string, url: string, diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/README.md b/implementations/nextjs-sdk_app-router_edge-runtime/README.md index 5f9fe3456..768402ea7 100644 --- a/implementations/nextjs-sdk_app-router_edge-runtime/README.md +++ b/implementations/nextjs-sdk_app-router_edge-runtime/README.md @@ -28,7 +28,8 @@ does not cover ISR, route-level `revalidate`, or Cache Components. - Request-personalized Edge runtime handoff from `app/edge-request/route.ts` - Public permutation Edge runtime handoff from `app/edge-selection/[segment]/route.ts` - Edge runtime assertion with `globalThis.EdgeRuntime === 'edge-runtime'` -- Browser handoff state created without Node-only APIs +- Browser handoff state created without Node-only APIs; the server preview does not persist state, + and the browser commits a matching replay or tracks the current page normally ## Prerequisites diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/EdgeHandoff.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/EdgeHandoff.tsx new file mode 100644 index 000000000..190d2ca31 --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/EdgeHandoff.tsx @@ -0,0 +1,47 @@ +'use client' + +import { appConfig } from '@/lib/config' +import type { createEdgeRequestHandoff } from '@/lib/edge-optimization' +import { bindNextjsAppRouterClientOptimization } from '@contentful/optimization-nextjs/app-router/client' +import type { ReactNode } from 'react' + +const optimization = bindNextjsAppRouterClientOptimization({ + spaceId: appConfig.spaceId, + environment: appConfig.environment, + locale: appConfig.locale, + logLevel: 'debug', + api: appConfig.api, + app: { + name: 'Contentful Optimization Next.js SDK Edge runtime', + version: '0.1.0', + }, + consent: { + clientDefaults: { consent: false, persistenceConsent: false }, + }, +}) + +type EdgeBrowserHandoff = Awaited>['handoff'] +export type EdgeContentHandoff = Extract< + EdgeBrowserHandoff, + { readonly hydration: 'preserve-server' | 'client-only-hidden-until-ready' } +> + +export function EdgeHandoff({ + children, + handoff, + routeKey, +}: Readonly<{ + children: ReactNode + handoff: EdgeContentHandoff + routeKey: string +}>) { + return ( + ({ properties: { path: routeKey, url: routeKey } })} + handoff={handoff} + routeKey={routeKey} + > + {children} + + ) +} diff --git a/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/page.tsx b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/page.tsx new file mode 100644 index 000000000..4c0313c9d --- /dev/null +++ b/implementations/nextjs-sdk_app-router_edge-runtime/app/edge-handoff/page.tsx @@ -0,0 +1,33 @@ +import { createEdgeRequestHandoff } from '@/lib/edge-optimization' +import { assertEdgeRuntime } from '@/lib/edge-runtime' +import { headers } from 'next/headers' +import { EdgeHandoff } from './EdgeHandoff' + +export const runtime = 'edge' + +export default async function EdgeHandoffPage() { + const runtimeWitness = assertEdgeRuntime() + const requestHeaders = await headers() + const origin = + requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? 'localhost:3003' + const protocol = requestHeaders.get('x-forwarded-proto') ?? 'http' + const url = `${protocol}://${origin}/edge-handoff` + const { handoff } = await createEdgeRequestHandoff({ + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + pagePayload: { properties: { path: '/edge-handoff', url } }, + request: { headers: requestHeaders, url }, + }) + if (handoff.hydration === 'analytics-only') { + throw new Error('Edge request handoff must be content-capable.') + } + + return ( + +
+

Next.js Edge browser handoff

+

{runtimeWitness.witness}

+
+
+ ) +} 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..62139d2f2 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 } = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', pagePayload: { properties: { path: url.pathname, url: request.url } }, @@ -14,11 +14,9 @@ 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, runtime: runtimeWitness, }, { @@ -30,7 +28,5 @@ export async function GET(request: Request): Promise { }, ) - persist(response) - return response } 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..f869c270d 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,10 @@ +import Link from 'next/link' + export default function HomePage() { return (

Next.js Edge runtime reference implementation

+ Open the Edge browser handoff
) } 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_pages-router/README.md b/implementations/nextjs-sdk_pages-router/README.md index 8d2e46df4..b49f9461d 100644 --- a/implementations/nextjs-sdk_pages-router/README.md +++ b/implementations/nextjs-sdk_pages-router/README.md @@ -27,7 +27,7 @@ The implementation binds `OptimizationRoot` and `OptimizedEntry` once in `@/lib/ 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 readiness callback - `@contentful/optimization-nextjs/pages-router/server` in `@/lib/optimization-server` for `getServerSideProps` request handoff - `@contentful/optimization-nextjs/client` for browser hooks and providers @@ -45,8 +45,7 @@ after hydration. It covers: - Query-controlled before-initial-page work in the root-owned browser page flow - 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 +- Private request preview with browser replay of the ordered initial event batch - Live re-resolution after consent, identify, reset, and client-side route changes - Preview panel attachment behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` @@ -59,20 +58,18 @@ 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. +Use `getServerSideProps` for server-personalized first paint. It fetches entries, calls the Pages Router helper, and returns both through `props`. `pages/_app.tsx` passes `pageProps.contentfulOptimization.handoff`, `routeKey`, and `buildPagePayload` to the bound root. The helper previews one ordered Personalization batch. The root hydrates preview state in memory and owns the initial replay/page decision while rendering proceeds. + +The server request helper configures `?beforeInitialPage=readiness` as the maintained +identify-before-page preview prefix. The root's next ordinary current-page call delivers that +continuation when it can; otherwise it follows the normal current-page path. Later route changes use +the emitter's normal path. Do not mount `NextPagesAutoPageTracker` beside this root; that would +introduce a second page owner. + +Routes without a private request handoff still use the client binding's `beforeInitialPage` +callback. The callback identifies only for `?beforeInitialPage=readiness`; ordinary routes return +immediately. A matching private replay bypasses that callback because the previewed batch already +owns the initial route. Use `getStaticProps` and `getStaticPaths` for finite public personalization permutations. The `/selection-handoff/[segment]` route builds a public-permutation handoff with the SDK helper, @@ -142,8 +139,8 @@ Run the focused readiness scenarios to verify the shared SSG route's raw HTML an pnpm test:e2e:nextjs-sdk_pages-router -- --grep readiness ``` -Run the focused before initial page scenarios to verify callback-before-page ordering, latest-route -capture, rejection and watchdog continuation, preserved content, and later-route emission: +Run the focused initial-batch scenarios to verify preview ordering, replay hydration, preserved +content, and later-route emission: ```sh pnpm test:e2e:nextjs-sdk_pages-router -- --grep "before initial page" diff --git a/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts b/implementations/nextjs-sdk_pages-router/lib/optimization-server.ts index 68c7e2e91..36ea9fd7f 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', + initialExperienceEvents: + new URL(routeKey, 'http://localhost').searchParams.get('beforeInitialPage') === + BEFORE_INITIAL_PAGE_QUERY_VALUE + ? [{ type: 'identify', userId: 'charles', traits: { identified: true } }] + : [], pagePayload: createRoutePagePayload(routeKey), }) assertContentHandoff(handoff) 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..dca7879af 100644 --- a/implementations/node-sdk+web-sdk/README.md +++ b/implementations/node-sdk+web-sdk/README.md @@ -24,15 +24,15 @@ This is a reference implementation using both the ## What this demonstrates Use this implementation when you need a hybrid SSR/browser example. It demonstrates a stateless Node -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. +SDK preview flow, a stateful Web SDK browser continuation, consent-aware application cookies, and +local mock API usage for end-to-end validation. + +The stateless Node SDK is created once at module load. Each consented request calls `previewInitialExperience()` once for `[page]`, `[identify, page]`, or `[track, page]`, then creates a private handoff. The server neither persists preview identity nor writes its anonymous-ID cookie. Web calls `hydrateAndTrackCurrentPage()` to apply state in memory and deliver one browser batch or ordinary fallback. Rendering does not await delivery. A successful live response can persist continuity, and later navigation uses ordinary tracking. Missing or denied app consent skips preview and renders baseline entries. + +The SSR page transfers one canonical `optimizationHandoff` object to the browser. The browser uses +that object for both initial state hydration and its possible private replay; it does not reconstruct +or combine a second replay payload. A replay is one-shot, so a route mismatch or delivery failure +falls through to ordinary current-page tracking. 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 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..0e1df936d 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 @@ -28,18 +28,17 @@ test.describe('identified user: cookie', () => { 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('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('should preserve custom profile id in cookie and localStorage', async ({ context }) => { + await expect + .poll(async () => await getAnonymousIdFromCookie(context)) + .toEqual(CUSTOM_PROFILE_ID) + await expect + .poll(async () => { + const cookieId = await getAnonymousIdFromCookie(context) + const storedId = await getAnonymousIdFromStorage(context) + return cookieId !== undefined && storedId === cookieId + }) + .toBe(true) }) test('displays common variants', async ({ page }) => { 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..f9f85bd7d 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 @@ -18,17 +18,15 @@ test.describe('identified user', () => { await page.waitForLoadState('domcontentloaded') }) - test('should store profile id in cookie', async ({ context }) => { - const cookieId = await getAnonymousIdFromCookie(context) - expect(cookieId).toBeDefined() - }) - - 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('should store profile id in cookie and localStorage', async ({ context }) => { + await expect.poll(async () => await getAnonymousIdFromCookie(context)).toBeDefined() + await expect + .poll(async () => { + const cookieId = await getAnonymousIdFromCookie(context) + const storedId = await getAnonymousIdFromStorage(context) + return cookieId !== undefined && storedId === cookieId + }) + .toBe(true) }) test('displays common variants', async ({ page }) => { diff --git a/implementations/node-sdk+web-sdk/e2e/displays-unidentified-user-variants.spec.ts b/implementations/node-sdk+web-sdk/e2e/displays-unidentified-user-variants.spec.ts index 06c41b4c4..c21c9f078 100644 --- a/implementations/node-sdk+web-sdk/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/node-sdk+web-sdk/e2e/displays-unidentified-user-variants.spec.ts @@ -1,6 +1,22 @@ import { expect, test } from '@playwright/test' import { getAnonymousIdFromCookie, getAnonymousIdFromStorage } from './utils' +const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readEventTypes(payload: unknown): string[] { + if (!isRecord(payload)) return [] + const events = payload.events + if (!Array.isArray(events)) return [] + + return events.flatMap((event) => + isRecord(event) && typeof event.type === 'string' ? [event.type] : [], + ) +} + test.describe('unidentified user', () => { test.beforeEach(async ({ page }) => { await page.goto('/') @@ -45,4 +61,30 @@ test.describe('unidentified user', () => { page.getByText('This is a baseline content entry for all identified or unidentified users.'), ).toBeVisible() }) + + test('commits the track preview replay once without preflight', async ({ context, page }) => { + const commits: Array<{ readonly eventTypes: string[]; readonly url: string }> = [] + await context.addCookies([ + { + name: APP_PERSONALIZATION_CONSENT_COOKIE, + value: 'granted', + domain: 'localhost', + path: '/', + sameSite: 'Lax', + }, + ]) + await page.route('**/experience/**', async (route) => { + commits.push({ + eventTypes: readEventTypes(route.request().postDataJSON()), + url: route.request().url(), + }) + await route.continue() + }) + + await page.goto('/track') + await expect + .poll(() => commits) + .toEqual([{ eventTypes: ['track', 'page'], url: expect.any(String) }]) + expect(commits[0]?.url).not.toContain('type=preflight') + }) }) diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index 13f006419..91cd5e564 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -1,10 +1,13 @@ -import ContentfulOptimization from '@contentful/optimization-node' -import type { OptimizationData } from '@contentful/optimization-node/api-schemas' +import ContentfulOptimization, { + createRequestHandoffFromPreview, + type OptimizationNodeConfig, +} from '@contentful/optimization-node' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import type { - EventEmissionResult, + InitialExperienceCommandInput, UniversalEventBuilderArgs, } from '@contentful/optimization-node/core-sdk' +import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' import cookieParser from 'cookie-parser' import express, { type Express, type Request, type Response } from 'express' import rateLimit from 'express-rate-limit' @@ -30,6 +33,18 @@ const __dirname = path.dirname(__filename) app.set('view engine', 'ejs') app.set('views', path.join(__dirname, '.')) +const optimizationConfig = { + allowedEventTypes: [], + spaceId: process.env.PUBLIC_CONTENTFUL_SPACE_ID ?? '', + environment: process.env.PUBLIC_CONTENTFUL_ENVIRONMENT, + logLevel: 'debug', + locale: APP_LOCALE, + api: { + insightsBaseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + experienceBaseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, + }, +} satisfies OptimizationNodeConfig + const config = { contentful: { accessToken: process.env.PUBLIC_CONTENTFUL_TOKEN, @@ -39,19 +54,10 @@ const config = { basePath: process.env.PUBLIC_CONTENTFUL_BASE_PATH, insecure: Boolean(process.env.PUBLIC_CONTENTFUL_CDA_HOST), }, - optimization: { - spaceId: process.env.PUBLIC_CONTENTFUL_SPACE_ID ?? '', - environment: process.env.PUBLIC_CONTENTFUL_ENVIRONMENT, - logLevel: 'debug', - locale: APP_LOCALE, - api: { - insightsBaseUrl: process.env.PUBLIC_INSIGHTS_API_BASE_URL, - experienceBaseUrl: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, - }, - }, + optimization: optimizationConfig, } as const -const sdk = new ContentfulOptimization(config.optimization) +const sdk = new ContentfulOptimization(optimizationConfig) const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent' type QsPrimitive = string | ParsedQs @@ -59,13 +65,12 @@ type QsArray = QsPrimitive[] // Note: mixed arrays are allowed by ParsedQs type QsValue = QsPrimitive | QsArray | undefined interface ProfileResult { readonly appLocale: string - readonly optimizationData: OptimizationData | undefined + readonly handoff: ContentOptimizationHandoff | undefined } interface RenderResponseOptions { readonly appConsent: boolean | undefined readonly appLocale: string - readonly id?: string - readonly optimizationData?: OptimizationData + readonly handoff?: ContentOptimizationHandoff readonly userId?: string } @@ -129,29 +134,16 @@ 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, userId }: RenderResponseOptions, ): void { - if (appConsent === true && id) { - res.cookie(ANONYMOUS_ID_COOKIE, id, { - path: '/', - sameSite: 'lax', // good default for same-site apps - }) - } else { - res.clearCookie(ANONYMOUS_ID_COOKIE, { path: '/' }) - } - res.render('index', { config, appConsent: appConsent ?? null, appLocale, identified: userId, - optimizationData: optimizationData ?? null, + optimizationHandoff: handoff ?? null, }) } @@ -159,75 +151,97 @@ async function getProfile( req: Request, appConsent: boolean | undefined, userId?: string, - anonymousId?: string, + track?: boolean, ): Promise { if (appConsent !== true) { return { appLocale: APP_LOCALE, - optimizationData: undefined, + handoff: undefined, } } const args = getUniversalEventBuilderArgs(req, APP_LOCALE) - const cookieProfile = anonymousId ? { id: anonymousId } : undefined - const requestOptimization = sdk.forRequest({ - consent: { events: true, persistence: true }, - eventContext: args, - locale: APP_LOCALE, - profile: cookieProfile, - }) + const anonymousId = getAnonymousIdFromCookies(req.cookies) + try { + const requestOptimization = sdk.forRequest({ + consent: { events: true, persistence: true }, + eventContext: args, + locale: APP_LOCALE, + ...(anonymousId === undefined ? {} : { profile: { id: anonymousId } }), + }) + + const events: InitialExperienceCommandInput[] = [] + if (userId) { + events.push({ type: 'identify', userId, traits: { identified: true } }) + } else if (track) { + events.push({ type: 'track', event: 'server-previewed-page' }) + } + const routeKey = `${req.path}${new URL(req.originalUrl, 'http://localhost').search}` + const preview = await requestOptimization.previewInitialExperience({ + ...(events.length > 0 ? { events } : {}), + page: { properties: { url: req.originalUrl } }, + }) + if (!preview.accepted) { + return { + appLocale: APP_LOCALE, + handoff: undefined, + } + } - if (!userId) { return { appLocale: APP_LOCALE, - optimizationData: getAcceptedOptimizationData(await requestOptimization.page()), + handoff: createRequestHandoffFromPreview({ + preview, + routeKey, + hydration: 'preserve-server', + }), + } + } catch { + // Preview is an enhancement. Do not carry request identity into a baseline fallback. + process.emitWarning('Optimization preview failed; rendering baseline output.') + return { + appLocale: APP_LOCALE, + handoff: undefined, } - } - - await requestOptimization.identify({ - userId, - traits: { identified: true }, - }) - - return { - appLocale: APP_LOCALE, - optimizationData: getAcceptedOptimizationData(await requestOptimization.page()), } } app.get('/', limiter, async (req, res) => { const appConsent = getAppConsentFromCookies(req.cookies) - const { appLocale, optimizationData } = await getProfile(req, appConsent) + const { appLocale, handoff } = await getProfile(req, appConsent) respond(res, { appConsent, appLocale, - id: optimizationData?.profile.id, - optimizationData, + handoff, }) }) app.get('/smoke-test', limiter, (_, res) => { res.render('index', { - appConsent: null, config, + appConsent: null, appLocale: APP_LOCALE, - optimizationData: null, + optimizationHandoff: null, }) }) 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 } = await getProfile(req, appConsent, userId) respond(res, { appConsent, appLocale, - id: optimizationData?.profile.id, - optimizationData, + handoff, userId, }) }) +app.get('/track', limiter, async (req, res) => { + const appConsent = getAppConsentFromCookies(req.cookies) + const { appLocale, handoff } = await getProfile(req, appConsent, undefined, true) + + respond(res, { appConsent, appLocale, handoff }) +}) app.use('/dist', express.static('./public/dist')) const port = 3000 diff --git a/implementations/node-sdk+web-sdk/src/index.ejs b/implementations/node-sdk+web-sdk/src/index.ejs index 150168590..16715c8f3 100644 --- a/implementations/node-sdk+web-sdk/src/index.ejs +++ b/implementations/node-sdk+web-sdk/src/index.ejs @@ -200,33 +200,31 @@ 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_HANDOFF = <%- JSON.stringify(optimizationHandoff) %> diff --git a/implementations/web-sdk_angular/README.md b/implementations/web-sdk_angular/README.md index f623b2415..afc0bd6d7 100644 --- a/implementations/web-sdk_angular/README.md +++ b/implementations/web-sdk_angular/README.md @@ -38,6 +38,13 @@ an Angular-specific SDK adapter. - Analytics event display with interaction-session aggregation - Multi-route navigation with conversion tracking +For SSR, the implementation transfers one `ServerOptimizationTransfer` object through Angular +`TransferState`. Its `handoff` is the canonical Optimization data passed from the Node preview to the +browser runtime; `defaults` provide the matching consent and locale snapshot. The browser hydrates +that handoff once before routing begins, in memory only. A private replay is attempted by the first +matching route tracking call and otherwise falls through to ordinary route tracking; a later +successful live Experience response is the durable-continuity path. + ## CDA locale handling This app configures one locale in `app.config.ts`, passes it as the Web SDK top-level `locale`, and @@ -156,7 +163,7 @@ panel behavior. | File or area | Purpose | | --------------------------------------- | ------------------------------------------------------------ | | `src/app/app.config.ts` | Angular providers and SDK configuration | -| `src/app/services/optimization.ts` | Web SDK singleton, consent, state, and event-stream glue | +| `src/app/services/optimization.ts` | SSR preview handoff, Web SDK promotion, consent, and routing | | `src/app/services/contentful-client.ts` | Single-locale Contentful CDA reads | | `src/app/components/entry-card/` | Optimized entry display and automatic/manual tracking markup | | `src/app/components/control-panel/` | Consent, identify, reset, live updates, and preview controls | diff --git a/implementations/web-sdk_angular/src/app/services/optimization.ts b/implementations/web-sdk_angular/src/app/services/optimization.ts index 683a044f7..1ddb0fc18 100644 --- a/implementations/web-sdk_angular/src/app/services/optimization.ts +++ b/implementations/web-sdk_angular/src/app/services/optimization.ts @@ -7,7 +7,6 @@ import { PLATFORM_ID, provideAppInitializer, REQUEST, - RESPONSE_INIT, signal, TransferState, type EnvironmentProviders, @@ -17,6 +16,7 @@ import { } from '@angular/core' import { NavigationEnd, Router } from '@angular/router' import type NodeContentfulOptimizationType from '@contentful/optimization-node' +import { createRequestHandoffFromPreview } from '@contentful/optimization-node' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' import type { CoreStatelessRequest, @@ -24,7 +24,8 @@ import type { } from '@contentful/optimization-node/core-sdk' import ContentfulOptimization from '@contentful/optimization-web' import type { Profile, SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas' -import { hydrateOptimizationHandoff } from '@contentful/optimization-web/handoff' +import { assertOptimizationCacheSafety } from '@contentful/optimization-web/core-sdk' +import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' import { createScopedLogger } from '@contentful/optimization-web/logger' import { createWebSnapshotRuntime, @@ -47,12 +48,27 @@ import { NgContentfulClient, SERVER_BASELINES_KEY } from './contentful-client' /** * SSR handoff for the personalization runtime. Stamped by the server preflight, * read on the browser to seed the initial snapshot runtime before the live SDK - * takes over. The shape matches {@link OptimizationSnapshot} so the same - * request-scoped payload backs `createSnapshotRuntime` on both sides of the - * hydration boundary. + * takes over. The handoff owns resolved optimization data; defaults preserve + * the request's consent, persistence, and locale for snapshot rendering. */ -const SERVER_OPTIMIZATION_KEY: StateKey = - makeStateKey('ssr-optimization') +interface ServerOptimizationTransfer { + readonly defaults: Pick + readonly handoff: ContentOptimizationHandoff | undefined +} + +const SERVER_OPTIMIZATION_KEY: StateKey = + makeStateKey('ssr-optimization') + +function createOptimizationSnapshot( + transfer: ServerOptimizationTransfer | undefined, +): OptimizationSnapshot | undefined { + if (transfer === undefined) return undefined + + return { + ...transfer.defaults, + ...(transfer.handoff?.state === undefined ? {} : { data: transfer.handoff.state }), + } +} /** * Shared SDK-config mapping used by both the browser Web SDK constructor and @@ -100,28 +116,26 @@ async function attachPreviewPanel( // Kept as module-scope helpers (rather than instance methods) so SonarQube // typescript:S7059 does not fire on in-constructor async work. -function hydrateSnapshotAndPromote( +async function hydrateSnapshotAndPromote( sdk: ContentfulOptimization, - snapshot: OptimizationSnapshot | undefined, + handoff: ContentOptimizationHandoff | undefined, runtimeSignal: WritableSignal, -): void { - if (!snapshot?.data) { - runtimeSignal.set(sdk) - return - } - hydrateOptimizationHandoff(sdk, { - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - initialPageEvent: snapshot.consent === true ? 'skip' : 'emit', - state: snapshot.data, - }) - .then(() => { - runtimeSignal.set(sdk) - }) - .catch((error: unknown) => { - hydrationLogger.warn('Failed to hydrate live SDK from SSR snapshot.', error) - runtimeSignal.set(sdk) + routeKey: string, +): Promise { + if (handoff !== undefined) assertOptimizationCacheSafety(handoff) + try { + await sdk.hydrateAndTrackCurrentPage(handoff, { + buildPayload: () => ({ properties: { url: window.location.origin + routeKey } }), + routeKey, + onHydrated: (error) => { + runtimeSignal.set(sdk) + if (error !== undefined) + hydrationLogger.warn('Failed to hydrate live SDK from SSR snapshot.', error) + }, }) + } catch (error) { + hydrationLogger.warn('Failed to track the initial browser page.', error) + } } function attachPreviewPanelSafely( @@ -133,9 +147,16 @@ function attachPreviewPanelSafely( }) } -function getOrCreateInstance(config: NgContentfulOptimizationConfig): ContentfulOptimization { +function getOrCreateInstance( + config: NgContentfulOptimizationConfig, + snapshot: OptimizationSnapshot | undefined, +): ContentfulOptimization { instance ??= new ContentfulOptimization({ ...toSdkConstructorArgs(config), + defaults: { + consent: snapshot?.consent, + persistenceConsent: snapshot?.persistenceConsent, + }, autoTrackEntryInteraction: config.autoTrackEntryInteraction ?? { views: true, clicks: true, @@ -168,10 +189,12 @@ export class NgContentfulOptimization { const destroyRef = inject(DestroyRef) const transferState = inject(TransferState) const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)) - const snapshot = transferState.get( + const transfer = transferState.get( SERVER_OPTIMIZATION_KEY, undefined, ) + const snapshot = createOptimizationSnapshot(transfer) + const handoff = transfer?.handoff const runtimeSignal = signal(createWebSnapshotRuntime(snapshot)) this.runtime = runtimeSignal.asReadonly() @@ -186,7 +209,7 @@ export class NgContentfulOptimization { return } - const sdk = getOrCreateInstance(config) + const sdk = getOrCreateInstance(config, snapshot) // Prime the live SDK with the server-computed snapshot before promoting // it to the runtime signal, so the first live render matches the SSR @@ -194,28 +217,33 @@ export class NgContentfulOptimization { // With no server data (consent denied or preflight skipped), the snapshot // runtime and the fresh live SDK already share the same initial state, so // we can swap immediately. - hydrateSnapshotAndPromote(sdk, snapshot, runtimeSignal) + const promotion = hydrateSnapshotAndPromote( + sdk, + handoff, + runtimeSignal, + window.location.pathname + window.location.search, + ) if (config.previewPanel !== undefined) { attachPreviewPanelSafely(sdk, config) } - // Page events fire on every route change. The first NavigationEnd after - // hydration is skipped when the server preflight already emitted page() - // for the same route (consent was granted server-side) — without this - // skip, analytics double-counts the SSR landing page. Subsequent - // navigations always emit. - let skipNextPage = snapshot?.consent ?? false const routerSubscription = router.events .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) .subscribe((e) => { - if (skipNextPage) { - skipNextPage = false - return - } - void sdk.page({ - properties: { url: window.location.origin + e.urlAfterRedirects }, - }) + const { urlAfterRedirects: routeKey } = e + void promotion + .then(async () => { + await sdk.trackCurrentPage({ + buildPayload: () => ({ + properties: { url: window.location.origin + routeKey }, + }), + routeKey, + }) + }) + .catch((error: unknown) => { + hydrationLogger.warn('Failed to track a navigation after SSR hydration.', error) + }) }) destroyRef.onDestroy(() => { @@ -234,23 +262,6 @@ export class NgContentfulOptimization { // `provideServerOptimizationInitializer()` so `app.config.server.ts` only // needs a single import to wire them in. -/** - * Read the SDK anonymous-id cookie from the inbound request. Returns the raw - * value when present so it can be passed to `forRequest({ profile })` for - * cross-request profile continuity. - */ -function readAnonymousId(request: Request): string | undefined { - const header = request.headers.get('cookie') ?? '' - for (const part of header.split(';')) { - const trimmed = part.trim() - if (!trimmed) continue - const eq = trimmed.indexOf('=') - if (eq < 0) continue - if (trimmed.slice(0, eq) === ANONYMOUS_ID_COOKIE) return trimmed.slice(eq + 1) - } - return undefined -} - async function createServerOptimization( config: NgContentfulOptimizationConfig, ): Promise { @@ -258,6 +269,15 @@ async function createServerOptimization( return new NodeContentfulOptimization(toSdkConstructorArgs(config)) } +function readAnonymousId(request: Request): string | undefined { + const cookieHeader = request.headers.get('cookie') ?? '' + for (const cookie of cookieHeader.split(';')) { + const [name, value] = cookie.trim().split('=', 2) + if (name === ANONYMOUS_ID_COOKIE && value) return value + } + return undefined +} + /** * Build an event context for the SSR `forRequest()` call so the server-side * page event carries the current route. Without this, route-targeted @@ -280,9 +300,8 @@ function createServerEventContext(request: Request, locale: string): UniversalEv } interface ServerPreflightOutcome { - readonly snapshot: OptimizationSnapshot - readonly profileId: string | undefined - readonly canPersistProfile: boolean + readonly defaults: ServerOptimizationTransfer['defaults'] + readonly handoff: ContentOptimizationHandoff | undefined } async function computeSnapshot( @@ -293,9 +312,8 @@ async function computeSnapshot( ): Promise { if (!consentGranted) { return { - snapshot: { consent: false, locale }, - profileId: undefined, - canPersistProfile: false, + defaults: { consent: false, locale }, + handoff: undefined, } } @@ -306,57 +324,58 @@ async function computeSnapshot( eventContext: createServerEventContext(request, locale), ...(anonymousId === undefined ? {} : { profile: { id: anonymousId } }), }) - const pageResult = await requestOptimization.page() - if (!pageResult.accepted || !pageResult.data) { + const url = new URL(request.url) + const routeKey = `${url.pathname}${url.search}` + const preview = await requestOptimization.previewInitialExperience({ + page: { + properties: { + path: url.pathname, + search: url.search, + url: request.url, + }, + }, + }) + if (!preview.accepted) { return { - snapshot: { consent: false, locale }, - profileId: undefined, - canPersistProfile: false, + defaults: { consent: false, locale }, + handoff: undefined, } } return { - snapshot: { + defaults: { consent: true, persistenceConsent: requestOptimization.canPersistProfile, locale, - data: pageResult.data, }, - profileId: pageResult.data.profile.id, - canPersistProfile: requestOptimization.canPersistProfile, + handoff: createRequestHandoffFromPreview({ preview, routeKey, hydration: 'preserve-server' }), } } -function persistAnonymousIdCookie(responseInit: ResponseInit, profileId: string): void { - const headers = - responseInit.headers instanceof Headers - ? responseInit.headers - : new Headers(responseInit.headers) - headers.append('set-cookie', `${ANONYMOUS_ID_COOKIE}=${profileId}; Path=/; SameSite=Lax`) - responseInit.headers = headers -} - async function runServerPreflight(): Promise { const request = inject(REQUEST, { optional: true }) if (!request) return - const responseInit = inject(RESPONSE_INIT, { optional: true }) const transferState = inject(TransferState) const config = inject(NG_CONTENTFUL_OPTIMIZATION_CONFIG) const contentful = inject(NgContentfulClient) const consentGranted = readConsentFromRequest(request) - const sdk = await createServerOptimization(config) - const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] - const baselines = await contentful.fetchEntries(baselineIds) - - const outcome = await computeSnapshot(sdk, request, consentGranted, config.locale) - - if (outcome.canPersistProfile && outcome.profileId && responseInit) { - persistAnonymousIdCookie(responseInit, outcome.profileId) + let outcome: ServerPreflightOutcome = { + defaults: { consent: consentGranted, locale: config.locale }, + handoff: undefined, + } + let baselines: Entry[] = [] + try { + const sdk = await createServerOptimization(config) + const baselineIds = [...new Set([...PAGES.home.ids, ...PAGES.pageTwo.ids])] + baselines = await contentful.fetchEntries(baselineIds) + outcome = await computeSnapshot(sdk, request, consentGranted, config.locale) + } catch (error) { + hydrationLogger.warn('Failed to prepare the server optimization preview.', error) } - transferState.set(SERVER_OPTIMIZATION_KEY, outcome.snapshot) + transferState.set(SERVER_OPTIMIZATION_KEY, outcome) transferState.set>( SERVER_BASELINES_KEY, Object.fromEntries(baselines.map((baseline) => [baseline.sys.id, baseline])), diff --git a/lib/e2e-web/e2e/handoff.spec.ts b/lib/e2e-web/e2e/handoff.spec.ts index ef6dd4313..7ed9ae910 100644 --- a/lib/e2e-web/e2e/handoff.spec.ts +++ b/lib/e2e-web/e2e/handoff.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type APIRequestContext, type Locator, type Page } from '@playwright/test' import { CUSTOMER_SEGMENTS, PAGES } from '../src/fixtures' -import { runIf, runIfImplementation } from './utils' +import { CONSENT_COOKIE, runIf, runIfImplementation } from './utils' const newVisitorSegment = CUSTOMER_SEGMENTS['new-visitor'] const baselineSegment = CUSTOMER_SEGMENTS.baseline @@ -19,6 +19,14 @@ function isRecord(value: unknown): value is Readonly> { return typeof value === 'object' && value !== null } +function readExperienceEventTypes(payload: unknown): string[] { + if (!isRecord(payload) || !Array.isArray(payload.events)) return [] + + return payload.events.flatMap((event) => + isRecord(event) && typeof event.type === 'string' ? [event.type] : [], + ) +} + function isPublicCacheMetadata(value: unknown): value is PublicCacheMetadata { if (!isRecord(value)) return false const { key, scope, tags } = value @@ -45,24 +53,6 @@ interface EdgeRuntimePayload { } } -function expectPublicCacheMiddlewareRewrite({ - cacheKey, - path, - responseHeaders, -}: { - readonly cacheKey: string - readonly path: string - readonly responseHeaders: Readonly> -}): void { - const rewriteHeader = responseHeaders['x-middleware-rewrite'] - expect(rewriteHeader).toBeTruthy() - - const rewriteUrl = new URL(rewriteHeader ?? '/', 'http://middleware.invalid') - const requestedUrl = new URL(path, rewriteUrl.origin) - expect(rewriteUrl.pathname).toBe(requestedUrl.pathname) - expect(rewriteUrl.searchParams.get('ctfl-opt-cache-key')).toBe(cacheKey) -} - async function expectRawHiddenUntilReadyHtml(page: Page, html: string): Promise { const snapshot = await page.evaluate((rawHtml) => { const doc = new DOMParser().parseFromString(rawHtml, 'text/html') @@ -123,7 +113,6 @@ async function expectRawSelectedHandoffHtml({ if (expectedCacheControl !== undefined) { expect(responseHeaders['cache-control']).toContain(expectedCacheControl) } - expectPublicCacheMiddlewareRewrite({ cacheKey, path, responseHeaders }) expect(html).toContain(`data-testid="${routeTestId}"`) expect(html).toContain(`data-testid="${cacheKeyTestId}"`) expect(html).toContain(segment.resolvedEntryText) @@ -257,14 +246,60 @@ test.describe('Next.js handoff routes', () => { await expectPageTwoSelectedVariant(page) }) - test('uses forwarded request context without duplicating the initial browser page event', async ({ + test('uses forwarded request context and commits one browser replay batch', async ({ + baseURL, + context, page, }) => { - await page.goto(PAGES.home.path) + await context.addCookies([{ name: CONSENT_COOKIE, value: 'granted', url: baseURL }]) + const browserExperienceRequests: Array<{ readonly method: string; readonly url: string }> = [] + const browserEventTypes: string[] = [] + await page.route('**/experience/**', async (route) => { + browserExperienceRequests.push({ + method: route.request().method(), + url: route.request().url(), + }) + browserEventTypes.push(...readExperienceEventTypes(route.request().postDataJSON())) + await route.continue() + }) + + const replayResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && response.url().includes('/experience/'), + ) + await page.goto(`${PAGES.home.path}?beforeInitialPage=readiness`) await page.waitForLoadState('networkidle') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect(page.locator('[data-testid^="event-page-"]')).toHaveCount(0) + const response = await replayResponse + expect(response.ok()).toBe(true) + expect(new URL(response.url()).searchParams.get('type')).not.toBe('preflight') + + expect(browserExperienceRequests).toEqual([ + expect.objectContaining({ method: 'POST', url: expect.stringContaining('/experience/') }), + ]) + expect(browserEventTypes).toEqual(['identify', 'page']) + }) + + test('keeps preview content visible while the initial browser replay waits', async ({ page }) => { + const delivery = Promise.withResolvers() + const started = Promise.withResolvers() + await page.route('**/experience/**', async (route) => { + started.resolve(undefined) + await delivery.promise + await route.continue() + }) + + try { + await page.goto(PAGES.pageTwo.path, { waitUntil: 'domcontentloaded' }) + await started.promise + await expect(page.getByTestId('page-two-view')).toBeVisible() + await expectPageTwoSelectedVariant(page) + } finally { + delivery.resolve(undefined) + } + await page.waitForLoadState('networkidle') + await expectPageTwoSelectedVariant(page) }) for (const segment of publicPermutationSegments) { @@ -384,13 +419,31 @@ test.describe('Next.js Edge runtime handoff routes', () => { expect(response.headers()['cache-control']).toBe('private, no-store') expect(response.headers()['x-optimization-cache-scope']).toBe('private-request') expect(payload).toMatchObject({ - accepted: true, cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', }) }) + test('hydrates an Edge request handoff in the browser', async ({ page }) => { + const browserExperienceRequests: Array<{ readonly method: string; readonly url: string }> = [] + await page.route('**/experience/**', async (route) => { + browserExperienceRequests.push({ + method: route.request().method(), + url: route.request().url(), + }) + await route.continue() + }) + + const response = await page.goto('/edge-handoff') + await expect(page.getByTestId('edge-browser-handoff')).toBeVisible() + await expect(page.getByTestId('edge-browser-handoff-runtime')).toHaveText('edge-runtime') + + expect(response?.headers()['set-cookie']).toBeUndefined() + expect(browserExperienceRequests).toEqual([ + expect.objectContaining({ method: 'POST', url: expect.stringContaining('/experience/') }), + ]) + }) + for (const segment of publicPermutationSegments) { test(`creates a ${segment.slug} public permutation handoff with customer-owned cache metadata`, async ({ request, diff --git a/lib/e2e-web/e2e/readiness.spec.ts b/lib/e2e-web/e2e/readiness.spec.ts index 85e091c1a..8fdec44aa 100644 --- a/lib/e2e-web/e2e/readiness.spec.ts +++ b/lib/e2e-web/e2e/readiness.spec.ts @@ -642,32 +642,39 @@ test.describe('readiness', () => { }) pagesCsrTest( - 'before initial page uses the latest route and emits once after readiness', + 'paired replay preserves preview during delivery and tracks later navigation', async ({ page }) => { const diagnostics = watchDiagnostics(page) const recorder = recordExperienceEvents(page) - const identifyRequest = await holdNextRequest(page, 'identify') + const replayRequest = await holdNextRequest(page, 'identify') try { await observeFromDocumentStart(page, `[data-testid="entry-text-${PAGES.pageTwo.auto}"]`) await page.goto(BEFORE_INITIAL_PAGE_PRESERVED_PATH) - await expect.poll(identifyRequest.held).toBe(true) - expect(recorder.events().map(({ type }) => type)).toEqual(['identify']) + await expect.poll(replayRequest.held).toBe(true) + expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page']) + expect( + recorder + .events() + .filter(({ type }) => type === 'page') + .map(({ pageRouteKey }) => pageRouteKey), + ).toEqual([BEFORE_INITIAL_PAGE_PRESERVED_PATH]) await expect(page.getByTestId('page-two-view')).toBeVisible() await expect.poll(async () => (await readEvidence(page)).visibleCandidates.length).toBe(1) const pendingEvidence = await readEvidence(page) expectPreservedFirst(pendingEvidence) expectContinuouslyVisible(pendingEvidence) - expectNewVisitor(candidateAt(pendingEvidence)) + expectCandidate(candidateAt(pendingEvidence)) expectNoVisibleBlankAfterCommitment(pendingEvidence) - expect( - recorder.events().filter(({ type }) => type === 'page'), - 'the root page must wait for the returned identify request', - ).toEqual([]) await page.getByTestId('link-ssg-client-personalization').click() await expect(page.getByTestId('readiness-ssg-route')).toBeVisible() - identifyRequest.release() + const heldReplayRequest = replayRequest.request() + const releasedReplayResponse = page.waitForResponse( + (response) => response.request() === heldReplayRequest, + ) + replayRequest.release() + expect((await releasedReplayResponse).ok()).toBe(true) await expect .poll(() => recorder @@ -675,8 +682,8 @@ test.describe('readiness', () => { .filter(({ type }) => type === 'page') .map(({ pageRouteKey }) => pageRouteKey), ) - .toEqual(['/ssg-client-personalization']) - expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page']) + .toEqual([BEFORE_INITIAL_PAGE_PRESERVED_PATH, '/ssg-client-personalization']) + expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page', 'page']) await expect(page.getByTestId('readiness-ssg-entry')).toBeVisible() await page.getByTestId('link-home').click() @@ -688,42 +695,53 @@ test.describe('readiness', () => { .filter(({ type }) => type === 'page') .map(({ pageRouteKey }) => pageRouteKey), ) - .toEqual(['/ssg-client-personalization', PAGES.home.path]) - expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page', 'page']) + .toEqual([ + BEFORE_INITIAL_PAGE_PRESERVED_PATH, + '/ssg-client-personalization', + PAGES.home.path, + ]) + expect(recorder.events().map(({ type }) => type)).toEqual([ + 'identify', + 'page', + 'page', + 'page', + ]) expectNoErrors(diagnostics) } finally { - await identifyRequest.remove() + await replayRequest.remove() recorder.remove() } }, ) appRouterCsrTest( - 'before initial page App request root avoids handoff duplicates and emits once later', + 'App paired replay avoids duplicates and tracks later navigation', async ({ page }) => { const diagnostics = watchDiagnostics(page) const recorder = recordExperienceEvents(page) - const identifyRequest = await holdNextRequest(page, 'identify') + const replayRequest = await holdNextRequest(page, 'identify') try { await page.goto(BEFORE_INITIAL_PAGE_PRESERVED_PATH) - await expect.poll(identifyRequest.held).toBe(true) + await expect.poll(replayRequest.held).toBe(true) await expect(page.getByTestId('page-two-view')).toBeVisible() - expect(recorder.events().map(({ type }) => type)).toEqual(['identify']) + expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page']) + expect( + recorder + .events() + .filter(({ type }) => type === 'page') + .map(({ pageRouteKey }) => pageRouteKey), + ).toEqual([BEFORE_INITIAL_PAGE_PRESERVED_PATH]) await page.getByTestId('link-home').click() - expect( - recorder.events().filter(({ type }) => type === 'page'), - 'the browser-owned home page must wait for the returned identify request', - ).toEqual([]) - const heldIdentifyRequest = identifyRequest.request() - const releasedIdentifyResponse = page.waitForResponse( - (response) => response.request() === heldIdentifyRequest, + const heldReplayRequest = replayRequest.request() + const releasedReplayResponse = page.waitForResponse( + (response) => response.request() === heldReplayRequest, ) - identifyRequest.release() - const identifyResponse = await releasedIdentifyResponse - expect(identifyResponse.request()).toBe(heldIdentifyRequest) - expect(identifyResponse.status()).toBe(200) + replayRequest.release() + const replayResponse = await releasedReplayResponse + expect(replayResponse.request()).toBe(heldReplayRequest) + expect(replayResponse.status()).toBe(200) await expect(page).toHaveURL(PAGES.home.path) await expect(page.getByRole('heading', { name: 'Next.js SDK App Router' })).toBeVisible() await expect @@ -733,8 +751,8 @@ test.describe('readiness', () => { .filter(({ type }) => type === 'page') .map(({ pageRouteKey }) => pageRouteKey), ) - .toEqual([PAGES.home.path]) - expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page']) + .toEqual([BEFORE_INITIAL_PAGE_PRESERVED_PATH, PAGES.home.path]) + expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page', 'page']) await page.getByTestId('link-page-two').click() await expect(page).toHaveURL(PAGES.pageTwo.path) @@ -747,11 +765,16 @@ test.describe('readiness', () => { .filter(({ type }) => type === 'page') .map(({ pageRouteKey }) => pageRouteKey), ) - .toEqual([PAGES.home.path, PAGES.pageTwo.path]) - expect(recorder.events().map(({ type }) => type)).toEqual(['identify', 'page', 'page']) + .toEqual([BEFORE_INITIAL_PAGE_PRESERVED_PATH, PAGES.home.path, PAGES.pageTwo.path]) + expect(recorder.events().map(({ type }) => type)).toEqual([ + 'identify', + 'page', + 'page', + 'page', + ]) expectNoErrors(diagnostics) } finally { - await identifyRequest.remove() + await replayRequest.remove() recorder.remove() } }, @@ -832,6 +855,17 @@ test.describe('readiness', () => { .toEqual([PAGES.home.path]) expect(identifyRequest.held()).toBe(true) + // A newer handoff can deliver its page before the older callback's watchdog expires. + await expect + .poll( + () => + diagnostics.consoleErrors.some((error) => + BEFORE_INITIAL_PAGE_WATCHDOG_ERROR.test(error), + ), + { timeout: BEFORE_INITIAL_PAGE_WATCHDOG_EVIDENCE_TIMEOUT_MS }, + ) + .toBe(true) + const heldIdentifyRequest = identifyRequest.request() const releasedIdentifyResponse = page.waitForResponse( (response) => response.request() === heldIdentifyRequest, diff --git a/lib/e2e-web/e2e/ssr.spec.ts b/lib/e2e-web/e2e/ssr.spec.ts index 1a5a85a60..29e1df29c 100644 --- a/lib/e2e-web/e2e/ssr.spec.ts +++ b/lib/e2e-web/e2e/ssr.spec.ts @@ -1,26 +1,42 @@ import { expect, test, type Page } from '@playwright/test' -import { CONSENT_COOKIE, runIf, seedAnonymousProfile, seedIdentifiedProfile, skipIf } from './utils' +import { + CONSENT_COOKIE, + PROFILE_COOKIE, + runIf, + runIfImplementation, + seedAnonymousProfile, + seedIdentifiedProfile, + skipIf, +} from './utils' test.describe('Hydration', () => { runIf('HYDRATION') + runIfImplementation('nextjs-sdk_pages-router', 'web-sdk_angular') - test('does not issue a client Experience request after consented SSR hydration', async ({ + test('preserves the server preflight and commits its replay once in the browser', async ({ baseURL, context, page, }) => { await context.addCookies([{ name: CONSENT_COOKIE, value: 'granted', url: baseURL }]) - const clientExperienceRequests: string[] = [] + const clientExperienceRequests: Array<{ readonly method: string; readonly url: string }> = [] await page.route('**/experience/**', async (route) => { - clientExperienceRequests.push(route.request().url()) + clientExperienceRequests.push({ + method: route.request().method(), + url: route.request().url(), + }) await route.continue() }) - await page.goto('/') + const response = await page.goto('/') await page.waitForLoadState('networkidle') await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - expect(clientExperienceRequests).toEqual([]) + expect(response?.headers()['set-cookie']).toBeUndefined() + expect(clientExperienceRequests).toEqual([ + expect.objectContaining({ method: 'POST', url: expect.stringContaining('/experience/') }), + ]) + expect((await context.cookies()).some((cookie) => cookie.name === PROFILE_COOKIE)).toBe(true) }) }) @@ -31,21 +47,24 @@ test.describe('SSR first-paint state', () => { test.describe('unidentified user', () => { test('consent-status is No without consent cookie', async ({ page }) => { - await page.goto('/') + const response = await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByTestId('consent-status')).toHaveText('No') await expect(page.getByTestId('identified-status')).toHaveText('No') + expect(response?.headers()['set-cookie']).toBeUndefined() }) test('consent-status is Yes with consent cookie', async ({ baseURL, context, page }) => { - await seedAnonymousProfile(context, baseURL) + await context.addCookies([{ name: CONSENT_COOKIE, value: 'granted', url: baseURL }]) - await page.goto('/') + const response = await page.goto('/') await page.waitForLoadState('domcontentloaded') await expect(page.getByTestId('consent-status')).toHaveText('Yes') await expect(page.getByTestId('identified-status')).toHaveText('No') + expect(response?.headers()['set-cookie']).toBeUndefined() + expect((await context.cookies()).some((cookie) => cookie.name === PROFILE_COOKIE)).toBe(false) }) }) diff --git a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt index 21862a53b..e5f393b9a 100644 --- a/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt +++ b/packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationConfig.kt @@ -26,6 +26,10 @@ public data class OptimizationApiConfig( val experienceBaseUrl: String? = null, val insightsBaseUrl: String? = null, val enabledFeatures: List? = null, + @Deprecated( + message = "Retained only to serialize existing native configurations during mixed-version upgrades. It no longer affects runtime behavior.", + level = DeprecationLevel.WARNING, + ) val preflight: Boolean? = null, ) { internal fun isEmpty(): Boolean = diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift index ba0b78471..ddd86f8fd 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Core/OptimizationConfig.swift @@ -55,6 +55,7 @@ public struct OptimizationApiConfig { public let experienceBaseUrl: String? public let insightsBaseUrl: String? public let enabledFeatures: [String]? + @available(*, deprecated, message: "Retained only to serialize existing native configurations during mixed-version upgrades. It no longer affects runtime behavior.") public let preflight: Bool? public init( diff --git a/packages/node/node-sdk/README.md b/packages/node/node-sdk/README.md index d119ef03a..492ccdca9 100644 --- a/packages/node/node-sdk/README.md +++ b/packages/node/node-sdk/README.md @@ -136,12 +136,12 @@ Common `api` options: Request-scoped Experience options belong in `experienceOptions` when creating the request-bound client: -| Option | Description | -| ----------- | ------------------------------------------------------------- | -| `ip` | IP address override used by the Experience API | -| `locale` | Locale query parameter for localized Experience API responses | -| `plainText` | Sends performance-critical Experience API endpoints as text | -| `preflight` | Aggregates a new profile state without storing it | +| Option | Description | +| ----------- | --------------------------------------------------------------------------- | +| `ip` | IP address override used by the Experience API | +| `locale` | Locale query parameter for localized Experience API responses | +| `plainText` | Sends performance-critical Experience API endpoints as text | +| `preflight` | Forces a non-persistent Experience API evaluation for this ordinary request | Request-scoped Insights options belong in `insightsOptions`: @@ -226,6 +226,18 @@ In stateless runtimes, Insights-backed methods require a request-bound profile f Non-sticky `trackView`, `trackClick`, `trackHover`, and `trackFlagView` require a profile ID passed to `forRequest()`. +### Server preview and browser replay + +For a private Node-rendered browser route, use the request handoff helper instead of committing the +initial browser batch on the server. It previews the supplied `identify` and `track` commands, then +appends one SDK-created `page` command and returns a private handoff for the Web SDK to submit and +deliver in the browser. Prefix commands are flat objects: `{ type: 'identify', userId, traits? }` +and `{ type: 'track', event, properties? }`. The server owns their order; replay does not add a +general command grammar, versioning, canonicalization, or order and count enforcement. The browser +attempts the replay once when its current route matches. If it cannot use the replay, it tracks the +current page normally. Keep this replay out of public and static caches; it does not create a server +preview cookie. + ### Content resolution When a `contentful.js` client is available, prefer SDK-managed fetching by entry ID or content type @@ -327,3 +339,17 @@ package directory. hybrid SSR and browser implementation - [Optimization Web SDK](../../web/web-sdk/README.md) - browser SDK used when the same application also needs client-side consent, persistence, tracking, or live updates + +For paired browser event delivery, use `request.previewInitialExperience({ events, page })` for a prefix plus the initial page, or `request.previewExperience({ events })` for semantic server inputs with optional pages and mixed Experience/Insights commands. Both use one Experience preflight request; an Insights-only journal with an existing profile needs no preflight transport. Insights events are delivered only by the paired browser. Build one private handoff with `createRequestHandoffFromPreview()`. A page-bearing journal requires a route key. Pass the result to the browser's combined initial operation; preview data is provisional and can render without awaiting browser delivery. + +Select presentation directly in the handoff factory; its return type retains the selected mode without a literal assertion: + +```ts +const handoff = createRequestHandoffFromPreview({ + preview, + routeKey, + hydration: 'preserve-server', +}) +``` + +`createRequestHandoffFromData({ hydration: 'preserve-server' })` creates a typed empty private handoff when preview state is unavailable. Omitting `hydration` preserves the framework-neutral result. diff --git a/packages/node/node-sdk/package.json b/packages/node/node-sdk/package.json index e2ca706c3..49b687554 100644 --- a/packages/node/node-sdk/package.json +++ b/packages/node/node-sdk/package.json @@ -82,8 +82,8 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 1500, - "index.mjs": 1300 + "index.cjs": 1300, + "index.mjs": 800 } } }, diff --git a/packages/node/node-sdk/src/handoff.test.ts b/packages/node/node-sdk/src/handoff.test.ts index cdfe056c0..2477da9cd 100644 --- a/packages/node/node-sdk/src/handoff.test.ts +++ b/packages/node/node-sdk/src/handoff.test.ts @@ -5,7 +5,7 @@ import type { PrivateRequestOptimizationCacheMetadata, } from '@contentful/optimization-core' import type { Entry, EntrySkeletonType } from 'contentful' -import { createRequestHandoffFromData } from './handoff' +import { createRequestHandoffFromData, createRequestHandoffFromPreview } from './handoff' type TestEntry = Entry @@ -83,6 +83,14 @@ const requestData: OptimizationData = { } describe('createRequestHandoffFromData', () => { + it('retains the selected hydration mode without a literal assertion', () => { + const handoff = createRequestHandoffFromData({ + data: requestData, + hydration: 'preserve-server', + }) + const mode: 'preserve-server' = handoff.hydration + expect(mode).toBe('preserve-server') + }) it('maps completed request OptimizationData into Core handoff state', () => { const cache: PrivateRequestOptimizationCacheMetadata = { scope: 'private-request', @@ -147,3 +155,15 @@ describe('createRequestHandoffFromData', () => { expect(handoff.state).toBeUndefined() }) }) + +describe('createRequestHandoffFromPreview', () => { + it('retains analytics-only mode without a literal assertion', () => { + const handoff = createRequestHandoffFromPreview({ + preview: { accepted: true, experience: [], insights: [], profile: { id: 'existing' } }, + hydration: 'analytics-only', + }) + const mode: 'analytics-only' = handoff.hydration + expect(mode).toBe('analytics-only') + expect(handoff.replay?.profile?.id).toBe('existing') + }) +}) diff --git a/packages/node/node-sdk/src/handoff.ts b/packages/node/node-sdk/src/handoff.ts index c03ba5e1c..51fb3c8fa 100644 --- a/packages/node/node-sdk/src/handoff.ts +++ b/packages/node/node-sdk/src/handoff.ts @@ -1,54 +1,4 @@ -import { - assertOptimizationCacheSafety, - type ManagedEntryHandoff, - type OptimizationCacheMetadata, - type OptimizationData, - type OptimizationHandoff, - type PrivateRequestOptimizationCacheMetadata, +export { + createRequestHandoffFromData, + createRequestHandoffFromPreview, } from '@contentful/optimization-core' - -function assertRequestHandoffCacheMetadata( - cache: OptimizationCacheMetadata, -): asserts cache is PrivateRequestOptimizationCacheMetadata { - if (cache.scope === 'private-request') return - - throw new TypeError( - 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', - ) -} - -/** - * Create a framework-neutral handoff from completed Node request optimization data. - * - * @remarks - * This helper serializes data that a caller already received from a request-bound Experience call. - * It does not emit page or analytics events. - * - * @public - */ -export function createRequestHandoffFromData(input: { - readonly data?: OptimizationData - readonly entries?: readonly ManagedEntryHandoff[] - readonly cache?: PrivateRequestOptimizationCacheMetadata -}): OptimizationHandoff { - const cache: PrivateRequestOptimizationCacheMetadata = input.cache ?? { scope: 'private-request' } - assertRequestHandoffCacheMetadata(cache) - - const handoff: OptimizationHandoff = { - cache, - ...(input.entries === undefined ? {} : { entries: input.entries }), - ...(input.data === undefined - ? {} - : { - state: { - selectedOptimizations: input.data.selectedOptimizations, - changes: input.data.changes, - profile: input.data.profile, - }, - }), - } - - assertOptimizationCacheSafety(handoff) - - return handoff -} diff --git a/packages/react-native-sdk/README.md b/packages/react-native-sdk/README.md index 268bc2f9c..87294a7db 100644 --- a/packages/react-native-sdk/README.md +++ b/packages/react-native-sdk/README.md @@ -133,12 +133,12 @@ Only `spaceId` is required. Common `api` options: -| Option | Required? | Default | Description | -| ------------------- | --------- | ------------------------------------------ | ------------------------------------------------ | -| `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | -| `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | -| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Experience API features to apply to each request | -| `preflight` | No | `false` | Aggregate a new profile state without storing it | +| Option | Required? | Default | Description | +| ------------------- | --------- | ------------------------------------------ | ----------------------------------------------------------------------- | +| `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Experience API features to apply to each request | +| `preflight` | No | `false` | Deprecated compatibility input; inert for the stateful React Native SDK | Common `fetchOptions` are `fetchMethod`, `requestTimeout`, `retries`, `intervalTimeout`, `onFailedAttempt`, and `onRequestTimeout`. Default retries intentionally apply only to HTTP `503` diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index fe2babab7..1c846898d 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -136,11 +136,11 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 13000, - "index.mjs": 12700, + "index.mjs": 12600, "preview-support.cjs": 800, "preview-support.mjs": 200, "preview.cjs": 19100, - "preview.mjs": 18000 + "preview.mjs": 17900 } } }, diff --git a/packages/universal/api-client/package.json b/packages/universal/api-client/package.json index 86d57466c..332d56c77 100644 --- a/packages/universal/api-client/package.json +++ b/packages/universal/api-client/package.json @@ -62,9 +62,9 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "guards.cjs": 550, - "guards.mjs": 200, - "index.cjs": 5000, + "guards.cjs": 600, + "guards.mjs": 300, + "index.cjs": 5100, "index.mjs": 4700 } } diff --git a/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts b/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts index f168495d4..768621628 100644 --- a/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts +++ b/packages/universal/api-client/src/experience/ExperienceApiClient.test.ts @@ -125,7 +125,13 @@ describe('ExperienceApiClient', () => { }) it('getProfile hits the correct URL with default environment and optional locale', async () => { - const requested: { space?: string; env?: string; id?: string; locale?: string | null } = {} + const requested: { + space?: string + env?: string + id?: string + locale?: string | null + preflight?: string | null + } = {} server.use( http.get( @@ -137,13 +143,14 @@ describe('ExperienceApiClient', () => { requested.env = env requested.id = id requested.locale = getLocaleParam(request.url) + requested.preflight = getParam(request.url) return HttpResponse.json({ data: { id } }, { status: 200 }) }, ), ) - const client = makeClient() + const client = makeClient({ preflight: true }) // without locale const profile = await client.getProfile('f0837d7dc6344c36a3a0a06c4cde754b') @@ -152,6 +159,7 @@ describe('ExperienceApiClient', () => { expect(requested.env).toBe(ENVIRONMENT) expect(requested.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') expect(requested.locale).toBeNull() + expect(requested.preflight).toBeNull() // with locale const profile2 = await client.getProfile('a19c3f54d2b84e37a93f6d1c0e5b7284', { @@ -160,6 +168,7 @@ describe('ExperienceApiClient', () => { expect(profile2).toBeDefined() expect(requested.id).toBe('a19c3f54d2b84e37a93f6d1c0e5b7284') expect(requested.locale).toBe('de-DE') + expect(requested.preflight).toBeNull() expect(mockLogger.info).toHaveBeenCalledWith( 'ApiClient:Experience', @@ -402,6 +411,7 @@ describe('ExperienceApiClient', () => { let content: string | null = null let forcedIp: string | null = null let features: string[] | undefined + let preflight: string | null = null server.use( http.post( @@ -409,6 +419,7 @@ describe('ExperienceApiClient', () => { async ({ request }) => { content = getContent(request.headers) forcedIp = getHeader(request.headers, 'X-Force-IP') + preflight = getParam(request.url) const body = await request.json() features = getFeaturesFromBody(body) return HttpResponse.json( @@ -423,6 +434,7 @@ describe('ExperienceApiClient', () => { enabledFeatures: ['location'], ip: '198.51.100.5', plainText: false, + preflight: true, }) const events = [makeTrackEvent('update-profile-defaults')] @@ -439,6 +451,7 @@ describe('ExperienceApiClient', () => { expect(content).toBe('application/json') expect(forcedIp).toBe('198.51.100.5') expect(features).toEqual(['location']) + expect(preflight).toBe('preflight') }) it('throws when updateProfile is called with an empty events array', async () => { @@ -454,12 +467,14 @@ describe('ExperienceApiClient', () => { it('upsertManyProfiles posts to /events and defaults to application/json (plainText=false)', async () => { let content: string | null = null let anonymousId: string | undefined + let preflight: string | null = null server.use( http.post( `${EXPERIENCE_BASE_URL}v3/spaces/:space/environments/:env/events`, async ({ request }) => { content = getContent(request.headers) + preflight = getParam(request.url) const body = await request.json() if (typeof body === 'object' && body !== null && 'events' in body) { @@ -494,15 +509,16 @@ describe('ExperienceApiClient', () => { ), ) - const client = makeClient() + const client = makeClient({ preflight: true }) const profiles = await client.upsertManyProfiles( { events: [makeBatchTrackEvent('f0837d7dc6344c36a3a0a06c4cde754b')] }, - {}, + { preflight: true }, ) expect(Array.isArray(profiles)).toBe(true) expect(content).toBe('application/json') expect(anonymousId).toBe('f0837d7dc6344c36a3a0a06c4cde754b') + expect(preflight).toBeNull() }) it('throws when upsertManyProfiles is called with an empty event batch', async () => { diff --git a/packages/universal/api-client/src/experience/ExperienceApiClient.ts b/packages/universal/api-client/src/experience/ExperienceApiClient.ts index 0f96a3421..82728b608 100644 --- a/packages/universal/api-client/src/experience/ExperienceApiClient.ts +++ b/packages/universal/api-client/src/experience/ExperienceApiClient.ts @@ -240,7 +240,9 @@ export default class ExperienceApiClient extends ApiClientBase { const response = await this.fetch( this.constructUrl( `v3/spaces/${this.spaceId}/environments/${this.environment}/profiles/${id}`, - options, + { + locale: options.locale, + }, ), { method: 'GET', @@ -276,12 +278,18 @@ export default class ExperienceApiClient extends ApiClientBase { body, options, }: ProfileMutationRequestOptions): Promise { - return await this.fetch(this.constructUrl(url, options), { - method: 'POST', - headers: this.constructHeaders(options), - body: JSON.stringify(body), - keepalive: true, - }) + return await this.fetch( + this.constructUrl(url, { + locale: options.locale, + preflight: options.preflight, + }), + { + method: 'POST', + headers: this.constructHeaders(options), + body: JSON.stringify(body), + keepalive: true, + }, + ) } /** @@ -317,7 +325,7 @@ export default class ExperienceApiClient extends ApiClientBase { const response = await this.makeProfileMutationRequest({ url: `v3/spaces/${this.spaceId}/environments/${this.environment}/profiles`, body, - options, + options: { ...options, preflight: options.preflight ?? this.preflight }, }) const { @@ -372,7 +380,7 @@ export default class ExperienceApiClient extends ApiClientBase { const response = await this.makeProfileMutationRequest({ url: `v3/spaces/${this.spaceId}/environments/${this.environment}/profiles/${profileId}`, body, - options, + options: { ...options, preflight: options.preflight ?? this.preflight }, }) const { @@ -458,10 +466,11 @@ export default class ExperienceApiClient extends ApiClientBase { logger.debug(`"${requestName}" request body:`, body) try { + const { preflight: _, ...batchOptions } = options const response = await this.makeProfileMutationRequest({ url: `v3/spaces/${this.spaceId}/environments/${this.environment}/events`, body, - options: { plainText: false, ...options }, + options: { plainText: false, ...batchOptions }, }) const { @@ -487,16 +496,18 @@ export default class ExperienceApiClient extends ApiClientBase { * * @internal */ - private constructUrl(path: string, options: ExperienceApiClientRequestOptions): string { + private constructUrl( + path: string, + options: Pick, + ): string { const url = new URL(path, this.baseUrl) const locale = options.locale ?? this.locale - const preflight = options.preflight ?? this.preflight if (locale) { url.searchParams.set('locale', locale) } - if (preflight) { + if (options.preflight) { url.searchParams.set('type', 'preflight') } diff --git a/packages/universal/api-schemas/package.json b/packages/universal/api-schemas/package.json index 6d2cc7778..b638d2ed6 100644 --- a/packages/universal/api-schemas/package.json +++ b/packages/universal/api-schemas/package.json @@ -32,8 +32,8 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 5100, - "index.mjs": 3400 + "index.cjs": 900, + "index.mjs": 200 } } }, diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index 62e16015e..8fe146d83 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -81,7 +81,6 @@ const requestOptimization = statelessOptimization.forRequest({ consent: true, locale: 'en-US', eventContext: { locale: 'en-US' }, - experienceOptions: { preflight: false }, profile: { id: 'f0837d7dc6344c36a3a0a06c4cde754b' }, }) @@ -154,22 +153,27 @@ decisions, and debug state are tracked separately. Common `api` options: -| Option | Applies to | Default | Description | -| ------------------- | ---------- | ------------------------------------------ | ------------------------------------------------- | -| `experienceBaseUrl` | All | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | -| `insightsBaseUrl` | All | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | -| `enabledFeatures` | All | `['ip-enrichment', 'location']` | Experience API features for each request | -| `ip` | Stateful | `undefined` | IP address override for Experience API analysis | -| `plainText` | Stateful | `true` for single-profile mutations | Sends single-profile Experience mutations as text | -| `preflight` | Stateful | `false` | Aggregates a profile state without storing it | +| Option | Applies to | Default | Description | +| ------------------- | ---------- | ------------------------------------------ | -------------------------------------------------------------- | +| `experienceBaseUrl` | All | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `insightsBaseUrl` | All | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `enabledFeatures` | All | `['ip-enrichment', 'location']` | Experience API features for each request | +| `ip` | Stateful | `undefined` | IP address override for Experience API analysis | +| `plainText` | Stateful | `true` for single-profile mutations | Sends single-profile Experience mutations as text | +| `preflight` | Stateful | `false` | Deprecated global compatibility input; inert for stateful SDKs | When `plainText` is omitted, single-profile Experience mutation/event requests use `text/plain`. Pass `plainText: false` to send JSON. Experience batch profile updates still default to JSON. -In stateless environments, pass `ip`, `locale`, `plainText`, and `preflight` as `experienceOptions` -when creating the request-bound client instead of constructor config. Pass request-specific Insights +In stateless environments, pass request-scoped Experience API options as `experienceOptions` when +creating the request-bound client instead of constructor config. Pass request-specific Insights API options, such as a last-chance `beacon` sender, as `insightsOptions`. +For a private server-to-browser handoff, the request-bound client can preview an initial batch. Its +caller-supplied prefix is an ordered array of flat `identify` and `track` command objects; Core adds +the page command. The preview is non-persistent and the resulting replay is valid only in a private +handoff. Core does not normalize commands or enforce an application event grammar. + Core-backed stateful SDKs can accept an initial top-level `locale` and runtime `setLocale(locale)` calls. They expose that SDK Experience API and default event locale through the live `locale` getter and `states.locale` observable. Stateless server SDKs should pass the request-scoped SDK locale as diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index f959243e6..a2d3e204e 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -113,16 +113,16 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 23200, - "index.mjs": 23100, + "index.cjs": 24700, + "index.mjs": 23800, "bridge-support.cjs": 1000, - "bridge-support.mjs": 800, - "runtime.cjs": 3700, - "runtime.mjs": 4900, + "bridge-support.mjs": 700, + "runtime.cjs": 3600, + "runtime.mjs": 4100, "entry-source.cjs": 2100, - "entry-source.mjs": 1900, - "preview-support.cjs": 5200, - "preview-support.mjs": 5100 + "entry-source.mjs": 1800, + "preview-support.cjs": 5300, + "preview-support.mjs": 4700 } } }, diff --git a/packages/universal/core-sdk/src/CoreApiConfig.ts b/packages/universal/core-sdk/src/CoreApiConfig.ts index d51119676..527df1dd6 100644 --- a/packages/universal/core-sdk/src/CoreApiConfig.ts +++ b/packages/universal/core-sdk/src/CoreApiConfig.ts @@ -15,6 +15,8 @@ export interface CoreSharedApiConfig { insightsBaseUrl?: InsightsApiClientConfig['baseUrl'] /** Experience API features enabled for outgoing requests. */ enabledFeatures?: ExperienceApiClientConfig['enabledFeatures'] + /** @deprecated Global preflight is inert. Use stateless request experienceOptions instead. */ + preflight?: ExperienceApiClientConfig['preflight'] } /** @@ -27,8 +29,6 @@ export interface CoreStatefulApiConfig extends CoreSharedApiConfig { ip?: ExperienceApiClientConfig['ip'] /** Experience API plain-text request toggle. */ plainText?: ExperienceApiClientConfig['plainText'] - /** Experience API preflight request toggle. */ - preflight?: ExperienceApiClientConfig['preflight'] } /** diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts index 45a8c6021..abb4c82c1 100644 --- a/packages/universal/core-sdk/src/CoreStateful.test.ts +++ b/packages/universal/core-sdk/src/CoreStateful.test.ts @@ -5,18 +5,25 @@ import type { ContentfulEntryClient, ContentfulEntryQuery } from './CoreBase' import CoreStateful, { type CoreStatefulConfig } from './CoreStateful' import type { BlockedEvent, + EventEmissionResult, EventOptimizationContext, OptimizationEventStreamEvent, TrackBuilderArgs, ViewBuilderArgs, } from './events' +import EventBuilder from './events/EventBuilder' import type { QueueFlushFailureContext } from './lib/queue' +import type { OptimizationReplayEnvelope } from './replay' import { createSnapshotRuntime } from './runtime/SnapshotRuntime' import { batch, signals } from './signals' import { mergeTagEntry } from './test/fixtures/mergeTagEntry' import { optimizedEntry } from './test/fixtures/optimizedEntry' import { profile as profileFixture } from './test/fixtures/profile' import { selectedOptimizations as selectedOptimizationsFixture } from './test/fixtures/selectedOptimizations' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const config: CoreStatefulConfig = { spaceId: 'key_123', @@ -63,6 +70,10 @@ function createDeferred(): { promise: Promise; resolve: () => void } { } class CoreStatefulTestHarness extends CoreStateful { + async replay(input: OptimizationReplayEnvelope): Promise { + return await this.replayOptimizationHandoff(input) + } + getOptimizationContextById( optimizationContextId: string | undefined, ): EventOptimizationContext | undefined { @@ -157,6 +168,210 @@ describe('CoreStateful blocked event handling', () => { subscription.unsubscribe() }) + it('sends admitted replay commands but leaves the route unaccepted without an admitted page', async () => { + const onEventBlocked = rs.fn() + const core = createCoreStatefulHarness({ + allowedEventTypes: ['track'], + defaults: { consent: false }, + onEventBlocked, + }) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + changes: [], + profile: profileFixture, + selectedOptimizations: [], + }) + + await expect( + core.replay({ + experience: [ + replayEventBuilder.buildTrack({ event: 'opened' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: 'route', + }), + ).resolves.toMatchObject({ accepted: false }) + + expect(onEventBlocked).toHaveBeenCalledTimes(1) + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ events: [expect.objectContaining({ type: 'track' })] }), + ) + }) + + it('does not send a replay when consent filters every command', async () => { + const core = createCoreStatefulHarness({ defaults: { consent: false } }) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile') + + await expect( + core.replay({ + experience: [replayEventBuilder.buildTrack({ event: 'opened' })], + insights: [], + routeKey: 'route', + }), + ).resolves.toEqual({ accepted: false }) + + expect(upsertProfile).not.toHaveBeenCalled() + }) + + it('accepts a canonical identify, track, page replay envelope', async () => { + const core = createCoreStatefulHarness({ defaults: { consent: true } }) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ + changes: [], + profile: profileFixture, + selectedOptimizations: [], + }) + + await expect( + core.replay({ + experience: [ + replayEventBuilder.buildIdentify({ userId: 'user-1' }), + replayEventBuilder.buildTrack({ event: 'opened' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: 'route', + }), + ).resolves.toMatchObject({ accepted: true }) + + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + events: [ + expect.objectContaining({ type: 'identify' }), + expect.objectContaining({ type: 'track' }), + expect.objectContaining({ type: 'page' }), + ], + }), + ) + }) + it('commits server-built events without rebuilding their identity or metadata', async () => { + const page = replayEventBuilder.buildPageView({ + properties: { url: 'https://server.example/a' }, + userAgent: 'server-agent', + }) + const click = replayEventBuilder.buildClick({ componentId: 'entry', userAgent: 'server-agent' }) + const core = createCoreStatefulHarness({ defaults: { consent: true } }) + const buildPage = rs.spyOn(EventBuilder.prototype, 'buildPageView') + const upsert = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue({ changes: [], profile: profileFixture, selectedOptimizations: [] }) + const insights = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) + await core.replay({ + profile: { id: 'original' }, + routeKey: '/a', + experience: [page], + insights: [click], + }) + await core.flush() + expect(buildPage).not.toHaveBeenCalled() + expect(upsert.mock.calls[0]?.[0].events).toEqual([page]) + expect(insights.mock.calls[0]?.[0][0]?.events).toEqual([click]) + expect(insights.mock.calls[0]?.[0][0]?.profile.id).toBe(profileFixture.id) + expect(page.channel).toBe('server') + }) + + it('queues one mixed-locale Experience batch and uses its known identity on flush', async () => { + const core = createCoreStatefulHarness({ defaults: { consent: true } }) + const flushed = Promise.withResolvers() + const upsert = rs.spyOn(core.api.experience, 'upsertProfile').mockImplementation(async () => { + flushed.resolve(undefined) + return await Promise.resolve({ + changes: [], + profile: profileFixture, + selectedOptimizations: [], + }) + }) + core.setOnlineState(false) + await expect( + core.replay({ + profile: { id: 'original' }, + routeKey: '/a', + experience: [ + replayEventBuilder.buildPageView({ locale: 'de-DE' }), + replayEventBuilder.buildTrack({ event: 'later', locale: 'fr-FR' }), + ], + insights: [], + }), + ).resolves.toEqual({ accepted: true }) + expect(upsert).not.toHaveBeenCalled() + core.setOnlineState(true) + await core.flush() + await flushed.promise + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].profileId).toBe('original') + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.context.locale)).toEqual([ + 'de-DE', + 'fr-FR', + ]) + }) + + it('uses current Analytics consent after the single Experience response', async () => { + const blocked = rs.fn() + const core = createCoreStatefulHarness({ + defaults: { consent: true }, + allowedEventTypes: [], + onEventBlocked: blocked, + }) + const response = Promise.withResolvers<{ + changes: [] + profile: typeof profileFixture + selectedOptimizations: [] + }>() + const started = Promise.withResolvers() + rs.spyOn(core.api.experience, 'upsertProfile').mockImplementation(async () => { + started.resolve(undefined) + return await response.promise + }) + const events: string[] = [] + const subscription = core.states.eventStream.subscribe((event) => { + if (event) events.push(event.type) + }) + const replay = core.replay({ + routeKey: '/a', + experience: [replayEventBuilder.buildPageView({})], + insights: [replayEventBuilder.buildClick({ componentId: 'entry' })], + }) + await started.promise + core.consent(false) + response.resolve({ changes: [], profile: profileFixture, selectedOptimizations: [] }) + await expect(replay).resolves.toMatchObject({ accepted: true }) + expect(events).toEqual(['page']) + expect(blocked).toHaveBeenCalledTimes(1) + subscription.unsubscribe() + }) + + it('batches Personalization in input order without splitting for Analytics or event locale', async () => { + const core = createCoreStatefulHarness({ defaults: { consent: true }, locale: 'en-GB' }) + const upsert = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue({ changes: [], profile: profileFixture, selectedOptimizations: [] }) + const events: string[] = [] + const subscription = core.states.eventStream.subscribe((event) => { + if (event) events.push(event.type) + }) + await expect( + core.replay({ + profile: { id: 'original' }, + routeKey: '/a', + experience: [ + replayEventBuilder.buildPageView({ locale: 'de-DE' }), + replayEventBuilder.buildTrack({ event: 'later', locale: 'de-DE' }), + replayEventBuilder.buildTrack({ event: 'french', locale: 'fr-FR' }), + ], + insights: [replayEventBuilder.buildClick({ componentId: 'entry' })], + }), + ).resolves.toMatchObject({ accepted: true }) + expect(events).toEqual(['page', 'track', 'track', 'component_click']) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].profileId).toBe('original') + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.context.locale)).toEqual([ + 'de-DE', + 'de-DE', + 'fr-FR', + ]) + expect(upsert.mock.calls[0]?.[1]).toBeUndefined() + subscription.unsubscribe() + }) + it('does not emit blocked events for repeated entry view calls', async () => { const onEventBlocked = rs.fn() const core = createCoreStateful({ @@ -350,7 +565,10 @@ describe('CoreStateful blocked event handling', () => { try { const onDrop = rs.fn() - const onFlushFailure = rs.fn<(context: QueueFlushFailureContext) => void>() + const failure = Promise.withResolvers() + const onFlushFailure = rs.fn((context: QueueFlushFailureContext) => { + failure.resolve(context) + }) const core = createCoreStatefulHarness({ defaults: { consent: true }, queuePolicy: { @@ -382,6 +600,7 @@ describe('CoreStateful blocked event handling', () => { core.setOnlineState(true) await core.flush() + await failure.promise expect(onFlushFailure).toHaveBeenCalledTimes(1) expect(onFlushFailure).toHaveBeenCalledWith( diff --git a/packages/universal/core-sdk/src/CoreStateful.ts b/packages/universal/core-sdk/src/CoreStateful.ts index 38310a669..c384b94bd 100644 --- a/packages/universal/core-sdk/src/CoreStateful.ts +++ b/packages/universal/core-sdk/src/CoreStateful.ts @@ -3,12 +3,15 @@ import type { InsightsApiClientRequestOptions, } from '@contentful/optimization-api-client' import type { + ExperienceEvent, + InsightsEvent, Json, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger, logger } from '@contentful/optimization-api-client/logger' import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' + import { installCoreBridgeCapabilities } from './bridge-support/capabilities' import type { ConsentController, ConsentGuard, ConsentInput } from './consent' import { UNLOCKING_EVENT_TYPES } from './consent/ConsentPolicy' @@ -19,6 +22,7 @@ import { type AllowedEventType, type BlockedEvent, DEFAULT_ALLOWED_EVENT_TYPES, + type EventEmissionResult, type EventOptimizationContext, type OptimizationEventStreamEvent, } from './events' @@ -31,6 +35,7 @@ import { import { normalizeExplicitLocale } from './locale' import { ExperienceQueue, type ExperienceQueueDropContext } from './queues/ExperienceQueue' import { InsightsQueue } from './queues/InsightsQueue' +import type { OptimizationReplayEnvelope } from './replay' import type { ResolvedData } from './resolvers' import { batch, @@ -82,7 +87,6 @@ const createStatefulExperienceApiConfig = ( ip: api?.ip, locale, plainText: api?.plainText, - preflight: api?.preflight, } return hasDefinedValues(experienceConfig) ? experienceConfig : undefined @@ -349,6 +353,42 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController this.experienceQueue.clearQueuedEvents() } + /** One Experience batch, then Analytics through its normal queue. */ + protected async replayOptimizationHandoff( + replay: OptimizationReplayEnvelope, + ): Promise { + if (replay.profile !== undefined) this.experienceQueue.bindProfileId(replay.profile.id) + const events = replay.experience.filter((event) => this.admitReplayEvent(event)) + const data = events.length === 0 ? undefined : await this.experienceQueue.sendBatch(events) + const accepted = events.some((event) => event.type === 'page') + try { + for (const event of replay.insights) { + if (!this.admitReplayEvent(event)) continue + await this.insightsQueue.send(event, undefined, data?.profile ?? replay.profile) + } + } catch (error: unknown) { + coreLogger.warn('Insights replay failed; retaining the Experience batch result.', error) + } + if (!accepted) return { accepted: false } + return data === undefined ? { accepted: true } : { accepted: true, data } + } + + private admitReplayEvent(event: ExperienceEvent | InsightsEvent): boolean { + const method = + event.type === 'component' + ? event.componentType === 'Variable' + ? 'trackFlagView' + : 'trackView' + : event.type === 'component_click' + ? 'trackClick' + : event.type === 'component_hover' + ? 'trackHover' + : event.type + if (this.hasConsent(method)) return true + this.reportBlockedEvent(method, [event]) + return false + } + override resolveOptimizedEntry< S extends EntrySkeletonType = EntrySkeletonType, L extends LocaleCode = LocaleCode, @@ -449,6 +489,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController } reset(): void { + this.experienceQueue.bindProfileId() this.optimizationContexts.clear() batch(() => { blockedEventSignal.value = undefined diff --git a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts index ae83abe58..ee23e795e 100644 --- a/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts +++ b/packages/universal/core-sdk/src/CoreStatefulEventEmitter.ts @@ -498,7 +498,7 @@ abstract class CoreStatefulEventEmitter return trackedObservable } - private reportBlockedEvent(method: string, args: readonly unknown[]): void { + protected reportBlockedEvent(method: string, args: readonly unknown[]): void { const event: BlockedEvent = { reason: 'consent', method, args } try { diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index 36ab03c92..da03cba5b 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -201,6 +201,215 @@ describe('CoreStateless', () => { ) }) + it('previews ordered initial commands with request-scoped preflight and one replay envelope', async () => { + const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const interceptedTypes: string[] = [] + core.interceptors.event.add((event) => { + interceptedTypes.push(event.type) + return event + }) + const requestOptimization = core.forRequest({ + consent: true, + experienceOptions: { locale: 'de-DE', preflight: false }, + }) + + const preview = await requestOptimization.previewInitialExperience({ + events: [ + { type: 'identify', userId: 'user-123' }, + { event: 'opened', type: 'track' }, + ], + page: { properties: { path: '/products' } }, + }) + + expect(preview).toMatchObject({ accepted: true }) + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + events: [ + expect.objectContaining({ type: 'identify' }), + expect.objectContaining({ type: 'track' }), + expect.objectContaining({ type: 'page' }), + ], + }), + { locale: 'de-DE', preflight: true }, + ) + if (preview.accepted) { + expect(preview.experience.map((event) => event.type)).toEqual(['identify', 'track', 'page']) + } + expect(interceptedTypes).toEqual(['identify', 'track', 'page']) + expect(upsertProfile).toHaveBeenCalledTimes(1) + }) + + it('preflights mixed semantic inputs once and leaves Insights delivery to the browser', async () => { + const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) + const upsert = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const insights = rs.spyOn(core.api.insights, 'sendBatchEvents') + const request = core.forRequest({ + consent: true, + profile: { id: 'original' }, + locale: 'de-DE', + eventContext: { userAgent: 'request-agent' }, + }) + const preview = await request.previewExperience({ + events: [ + { type: 'identify', userId: 'visitor' }, + { type: 'trackClick', componentId: 'entry' }, + { type: 'track', event: 'opened', locale: 'fr-FR' }, + { type: 'page' }, + ], + }) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual([ + 'identify', + 'track', + 'page', + ]) + expect(insights).not.toHaveBeenCalled() + expect(preview).toMatchObject({ + accepted: true, + profile: { id: 'original' }, + experience: [ + expect.objectContaining({ + type: 'identify', + context: expect.objectContaining({ locale: 'de-DE', userAgent: 'request-agent' }), + }), + expect.objectContaining({ + type: 'track', + context: expect.objectContaining({ locale: 'fr-FR' }), + }), + expect.objectContaining({ type: 'page' }), + ], + insights: [expect.objectContaining({ type: 'component_click' })], + }) + }) + + it('retains an Analytics-only journal with an existing profile without preflight transport', async () => { + const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) + const upsert = rs.spyOn(core.api.experience, 'upsertProfile') + const request = core.forRequest({ consent: true, profile: { id: 'original' } }) + await expect( + request.previewExperience({ events: [{ type: 'trackClick', componentId: 'entry' }] }), + ).resolves.toMatchObject({ accepted: true, profile: { id: 'original' } }) + expect(upsert).not.toHaveBeenCalled() + }) + + it('retains the intercepted preflight events and builds a sticky view once for both transports', async () => { + const core = new CoreStateless({ + spaceId: 'key_123', + environment: 'main', + eventBuilder: { channel: 'server', library: { name: 'test-server', version: '1.0.0' } }, + }) + const upsert = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const interceptedIds: string[] = [] + core.interceptors.event.add((event) => { + interceptedIds.push(event.messageId) + const forwarded = structuredClone(event) + Object.assign(forwarded.context, { userAgent: 'server-interceptor' }) + return forwarded + }) + const preview = await core.forRequest({ consent: true }).previewExperience({ + events: [ + { + type: 'trackView', + componentId: 'entry', + sticky: true, + viewId: 'server-view', + viewDurationMs: 500, + }, + { type: 'page' }, + ], + }) + if (!preview.accepted) throw new Error('Expected an accepted preview.') + expect(preview.experience).toBe(upsert.mock.calls[0]?.[0].events) + expect(preview.insights[0]?.messageId).toBe(preview.experience[0]?.messageId) + expect(interceptedIds).toHaveLength(2) + expect(preview.experience.map((event) => event.context.userAgent)).toEqual([ + 'server-interceptor', + 'server-interceptor', + ]) + expect(preview.insights[0]?.channel).toBe('server') + expect(preview).not.toHaveProperty('commands') + }) + + it('accepts Analytics ahead of Personalization because the batch supplies its browser profile', async () => { + const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) + const upsert = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const request = core.forRequest({ consent: true }) + await expect( + request.previewExperience({ + events: [{ type: 'trackClick', componentId: 'entry' }, { type: 'page' }], + }), + ).resolves.toMatchObject({ accepted: true }) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual(['page']) + }) + + it('propagates interceptor schema errors before preview mutation', async () => { + const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) + core.interceptors.event.add((event) => { + Reflect.deleteProperty(event, 'type') + return event + }) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile') + + await expect(core.forRequest({ consent: true }).previewInitialExperience()).rejects.toThrow( + 'Invalid input', + ) + expect(upsertProfile).not.toHaveBeenCalled() + }) + + it('filters blocked preview prefixes while building the page command with consent', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = new CoreStateless({ + allowedEventTypes: ['page'], + environment: 'main', + onEventBlocked: (event) => blockedEvents.push(event), + spaceId: 'key_123', + }) + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + + const preview = await core.forRequest({ consent: false }).previewInitialExperience({ + events: [{ event: 'prefix', type: 'track' }], + }) + + expect(preview).toMatchObject({ accepted: true }) + expect(blockedEvents.map((event) => event.method)).toEqual(['track']) + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ events: [expect.objectContaining({ type: 'page' })] }), + expect.anything(), + ) + }) + + it('blocks a preview directly when its SDK-built page command lacks consent', async () => { + const blockedEvents: BlockedEvent[] = [] + const core = new CoreStateless({ + allowedEventTypes: [], + environment: 'main', + onEventBlocked: (event) => blockedEvents.push(event), + spaceId: 'key_123', + }) + const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile') + + await expect( + core.forRequest({ consent: false }).previewInitialExperience({ + events: [{ event: 'prefix', type: 'track' }], + }), + ).resolves.toEqual({ accepted: false }) + + expect(blockedEvents.map((event) => event.method)).toEqual(['page']) + expect(upsertProfile).not.toHaveBeenCalled() + }) + it('forwards request page query context to request-bound page events', async () => { const core = new CoreStateless({ spaceId: 'key_123', environment: 'main' }) const upsertProfile = rs diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts index 4ba4464a1..19f820908 100644 --- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts +++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts @@ -18,6 +18,7 @@ import type { import type CoreStateless from './CoreStateless' import type { CoreStatelessInsightsOptions, CoreStatelessRequestOptions } from './CoreStateless' import { PartialProfile, type OptimizationData } from './api-schemas' +import { hasEventConsent } from './consent/ConsentPolicy' import type { AllowedEventType, ClickBuilderArgs, @@ -33,6 +34,12 @@ import type { } from './events' import { normalizeExplicitLocale } from './locale' import { createManagedEntryHandoffs, normalizeManagedEntryDescriptor } from './managed-entry' +import type { + OptimizationReplayCommand, + OptimizationReplayEnvelope, + PreviewExperienceOptions, + PreviewInitialExperienceOptions, +} from './replay' const coreLogger = createScopedLogger('CoreStateless') @@ -47,6 +54,8 @@ const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = const STICKY_TRACK_VIEW_PROFILE_ERROR = 'CoreStatelessRequest.trackView() could not derive a profile from the sticky Experience response. Bind `profile.id` with forRequest() if you need a fallback.' +type ReplayCommand = OptimizationReplayCommand + /** * Request-scoped consent accepted by stateless request clients. * @@ -120,6 +129,22 @@ export type StatelessNonStickyTrackViewPayload = Omit sticky?: false | undefined } +/** Result from a single paired-event preflight. @public */ +export type ExperiencePreview = + | { readonly accepted: false } + | { + readonly accepted: true + readonly data?: OptimizationData + readonly profile?: PartialProfile + readonly experience: OptimizationReplayEnvelope['experience'] + readonly insights: OptimizationReplayEnvelope['insights'] + } + +/** Result of a page-bearing initial Experience preflight. @public */ +export type InitialExperiencePreview = + | { readonly accepted: false } + | (Extract & { readonly data: OptimizationData }) + const requireInsightsProfile = ( profile: PartialProfile | undefined, errorMessage: string, @@ -189,6 +214,96 @@ export class CoreStatelessRequest { return this.currentProfile } + /** + * Preview a browser replay as one forced-preflight Experience mutation. + * + * Prefix commands that lack consent are diagnosed and omitted. The SDK-built page command must + * have consent; otherwise no preview request is made. + * + * @public + */ + async previewInitialExperience( + input: PreviewInitialExperienceOptions = {}, + ): Promise { + const pageCommand = { ...input.page, type: 'page' } satisfies ReplayCommand + if (!this.hasConsent('page')) { + this.reportBlockedEvent('page', [pageCommand]) + return { accepted: false } + } + const preview = await this.previewExperience({ + events: [...(input.events ?? []), pageCommand], + }) + if (!preview.accepted || preview.data === undefined) return { accepted: false } + return { ...preview, data: preview.data } + } + + /** + * Build all events once, preflight Personalization, and retain the resulting wire events. + * Analytics is built and validated here but delivered only by the paired browser. + * Unlike initial-page preview, this operation also accepts journals without a page. + * @public + */ + async previewExperience(input: PreviewExperienceOptions): Promise { + const { currentProfile: profile } = this + const events = await this.buildPreviewInputs(input) + if (events.experience.length === 0 && events.insights.length === 0) return { accepted: false } + if (events.experience.length === 0) + return { accepted: true, ...events, ...(profile === undefined ? {} : { profile }) } + const data = await this.core.api.experience.upsertProfile( + { profileId: profile?.id, events: events.experience }, + { ...this.experienceOptions, preflight: true }, + ) + const { profile: nextProfile, selectedOptimizations } = data + this.currentProfile = nextProfile + this.currentSelectedOptimizations = selectedOptimizations + return { accepted: true, ...events, data, ...(profile === undefined ? {} : { profile }) } + } + + private async buildPreviewInputs( + input: PreviewExperienceOptions, + ): Promise<{ experience: ExperienceEventPayload[]; insights: InsightsEventPayload[] }> { + const experience: ExperienceEventPayload[] = [] + const insights: InsightsEventPayload[] = [] + for (const command of input.events) { + if (!hasEventConsent(command.type, this.requestEventConsent, this.core.allowedEventTypes)) { + this.reportBlockedEvent(command.type, [command]) + continue + } + const event = this.buildPreviewEvent(command) + const intercepted = await this.core.interceptors.event.run( + withRequestEventConsent(event, this.requestEventConsent === true), + ) + const isInsights = ['trackView', 'trackClick', 'trackHover', 'trackFlagView'].includes( + command.type, + ) + if (!isInsights || (command.type === 'trackView' && command.sticky === true)) + experience.push(parseWithFriendlyError(ExperienceEventSchema, intercepted)) + if (isInsights) insights.push(parseWithFriendlyError(InsightsEventSchema, intercepted)) + } + return { experience, insights } + } + + private buildPreviewEvent(command: ReplayCommand): ExperienceEventPayload | InsightsEventPayload { + switch (command.type) { + case 'identify': + return this.core.eventBuilder.buildIdentify(this.withEventContext(command)) + case 'track': + return this.core.eventBuilder.buildTrack(this.withEventContext(command)) + case 'page': + return this.core.eventBuilder.buildPageView(this.withEventContext(command)) + case 'screen': + return this.core.eventBuilder.buildScreenView(this.withEventContext(command)) + case 'trackView': + return this.core.eventBuilder.buildView(this.withEventContext(command)) + case 'trackClick': + return this.core.eventBuilder.buildClick(this.withEventContext(command)) + case 'trackHover': + return this.core.eventBuilder.buildHover(this.withEventContext(command)) + case 'trackFlagView': + return this.core.eventBuilder.buildFlagView(this.withEventContext(command)) + } + } + async identify( payload: StatelessExperiencePayload, ): Promise { diff --git a/packages/universal/core-sdk/src/handoff.test.ts b/packages/universal/core-sdk/src/handoff.test.ts index 8b198b342..945695137 100644 --- a/packages/universal/core-sdk/src/handoff.test.ts +++ b/packages/universal/core-sdk/src/handoff.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from '@rstest/core' import type { Entry, EntrySkeletonType } from 'contentful' import type { SelectedOptimizationArray } from './api-schemas' import type { ManagedEntryHandoff } from './CoreBase' +import EventBuilder from './events/EventBuilder' import { assertOptimizationCacheSafety, createHandoffFromSelections, createOptimizationCacheKey, createPublicPermutationCacheMetadata, + createRequestHandoffFromPreview, createSelectionFingerprint, getOptimizationCacheSafetyWarnings, resolveEntriesForSelections, @@ -14,6 +16,10 @@ import { } from './handoff' import { optimizedEntry } from './test/fixtures/optimizedEntry' import { profile } from './test/fixtures/profile' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) type TestEntry = Entry @@ -304,6 +310,38 @@ describe('handoff helpers', () => { }) }) + describe('createRequestHandoffFromPreview', () => { + it('creates a private request handoff with preview state and a route-bound replay', () => { + const handoff = createRequestHandoffFromPreview({ + preview: { + accepted: true, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + data: { + changes: [], + profile, + selectedOptimizations: [], + }, + }, + routeKey: '/products', + }) + + expect(handoff).toMatchObject({ + cache: { scope: 'private-request' }, + replay: { + experience: [expect.objectContaining({ type: 'page' })], + insights: [], + routeKey: '/products', + }, + state: { + changes: [], + profile, + selectedOptimizations: [], + }, + }) + }) + }) + describe('getOptimizationCacheSafetyWarnings', () => { it('warns for profile state in public or static cache scopes', () => { expect( diff --git a/packages/universal/core-sdk/src/handoff.ts b/packages/universal/core-sdk/src/handoff.ts index 6416a743d..1c6849ed7 100644 --- a/packages/universal/core-sdk/src/handoff.ts +++ b/packages/universal/core-sdk/src/handoff.ts @@ -1,11 +1,14 @@ import type { ChainModifiers, EntrySkeletonType, LocaleCode } from 'contentful' import type { ChangeArray, + OptimizationData, Profile, SelectedOptimization, SelectedOptimizationArray, } from './api-schemas' import type { FetchOptimizedEntryResult, ManagedEntryHandoff } from './CoreBase' +import type { ExperiencePreview } from './CoreStatelessRequest' +import type { OptimizationReplayEnvelope } from './replay' import OptimizedEntryResolver, { type EntryFor } from './resolvers/OptimizedEntryResolver' const SELECTION_FINGERPRINT_PREFIX = 'ctfl-opt-selection:v1' @@ -141,6 +144,38 @@ export interface OptimizationHandoff { readonly entries?: readonly ManagedEntryHandoff[] /** Cache metadata for the rendered output. */ readonly cache: OptimizationCacheMetadata + /** + * Private replay instructions created by the SDK for a browser continuation. + * Public and static handoffs must not carry replay instructions. + */ + readonly replay?: OptimizationReplayEnvelope +} + +/** Presentation policy carried by content handoffs. @public */ +export type ContentOptimizationHydrationMode = 'preserve-server' | 'client-only-hidden-until-ready' + +/** Presentation policy carried by content or analytics-only handoffs. @public */ +export type OptimizationHydrationMode = ContentOptimizationHydrationMode | 'analytics-only' + +/** A handoff with its caller-selected presentation mode retained in the type. @public */ +export type OptimizationHandoffWithHydration = + OptimizationHandoff & { readonly hydration: TMode } + +/** Options for a private handoff from evaluated request data. @public */ +export interface CreateRequestHandoffFromDataOptions { + readonly data?: OptimizationData + readonly entries?: readonly ManagedEntryHandoff[] + readonly cache?: PrivateRequestOptimizationCacheMetadata + readonly hydration?: OptimizationHydrationMode +} + +/** Options for a private handoff from an accepted request preview. @public */ +export interface CreateRequestHandoffFromPreviewOptions extends Omit< + CreateRequestHandoffFromDataOptions, + 'data' +> { + readonly preview: ExperiencePreview + readonly routeKey?: string } /** @@ -151,6 +186,7 @@ export interface OptimizationHandoff { export type OptimizationCacheSafetyWarningCode = | 'profile-state-in-public-cache' | 'missing-public-permutation-cache-key' + | 'replay-in-non-private-cache' /** * Cache-safety warning for an optimization handoff. @@ -379,6 +415,15 @@ export function getOptimizationCacheSafetyWarnings( }) } + if (handoff.replay !== undefined && cache.scope !== 'private-request') { + warnings.push({ + code: 'replay-in-non-private-cache', + message: + 'Replay instructions are request-private and must not be included in public or static caches.', + path: ['replay'], + }) + } + return warnings } @@ -396,3 +441,84 @@ export function assertOptimizationCacheSafety(handoff: OptimizationHandoff): voi throw new TypeError(warnings.map((warning) => warning.message).join(' ')) } + +function assertPrivateRequestCacheMetadata( + cache: OptimizationCacheMetadata, +): asserts cache is PrivateRequestOptimizationCacheMetadata { + if (cache.scope === 'private-request') return + + throw new TypeError( + 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', + ) +} + +/** Create a private request handoff from request-scoped optimization data. @public */ +export function createRequestHandoffFromData( + input: CreateRequestHandoffFromDataOptions & { readonly hydration: TMode }, +): OptimizationHandoffWithHydration +export function createRequestHandoffFromData( + input: CreateRequestHandoffFromDataOptions, +): OptimizationHandoff +export function createRequestHandoffFromData( + input: CreateRequestHandoffFromDataOptions, +): OptimizationHandoff { + const cache: PrivateRequestOptimizationCacheMetadata = input.cache ?? { scope: 'private-request' } + assertPrivateRequestCacheMetadata(cache) + + const handoff: OptimizationHandoff = { + cache, + ...(input.hydration === undefined ? {} : { hydration: input.hydration }), + ...(input.entries === undefined ? {} : { entries: input.entries }), + ...(input.data === undefined + ? {} + : { + state: { + selectedOptimizations: input.data.selectedOptimizations, + changes: input.data.changes, + profile: input.data.profile, + }, + }), + } + return handoff +} + +/** Bind an accepted preview replay to a private route handoff. @public */ +export function createRequestHandoffFromPreview( + input: CreateRequestHandoffFromPreviewOptions & { readonly hydration: TMode }, +): OptimizationHandoffWithHydration +export function createRequestHandoffFromPreview( + input: CreateRequestHandoffFromPreviewOptions, +): OptimizationHandoff +export function createRequestHandoffFromPreview( + input: CreateRequestHandoffFromPreviewOptions, +): OptimizationHandoff { + if (!input.preview.accepted) + throw new TypeError( + 'Cannot create a request handoff from a blocked initial Experience preview.', + ) + + if ( + input.preview.experience.some((event) => event.type === 'page') && + input.routeKey === undefined + ) { + throw new TypeError('Page-bearing replay requires a route key.') + } + + const replay = { + experience: input.preview.experience, + insights: input.preview.insights, + ...(input.routeKey === undefined ? {} : { routeKey: input.routeKey }), + ...(input.preview.profile === undefined ? {} : { profile: input.preview.profile }), + } satisfies OptimizationReplayEnvelope + + const handoff: OptimizationHandoff = { + ...createRequestHandoffFromData({ + cache: input.cache, + data: input.preview.data, + entries: input.entries, + hydration: input.hydration, + }), + replay, + } + return handoff +} diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index 2dcf64c94..769ab826c 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -43,6 +43,13 @@ export type * from './OptimizedEntryMetadata' export * from './page-context' export type { ExperienceQueue } from './queues/ExperienceQueue' export type { InsightsQueue, InsightsQueueFlushOptions } from './queues/InsightsQueue' +export type { + InitialExperienceCommandInput, + OptimizationReplayCommand, + OptimizationReplayEnvelope, + PreviewExperienceOptions, + PreviewInitialExperienceOptions, +} from './replay' export * from './resolvers' export * from './StatefulDefaults' export * from './tracking' diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts index d5091b407..354b8d35d 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts @@ -1,7 +1,9 @@ import type { + ExperienceEvent, ExperienceEventArray, OptimizationData, } from '@contentful/optimization-api-client/api-schemas' +import type { LifecycleInterceptors } from '../CoreBase' import { InterceptorManager } from '../lib/interceptor' import { resolveQueueFlushPolicy } from '../lib/queue' import { @@ -13,6 +15,12 @@ import { import { profile as profileFixture } from '../test/fixtures/profile' import { ExperienceQueue } from './ExperienceQueue' +type InterceptedEvent = Parameters[0] extends ( + value: Readonly, +) => unknown + ? T + : never + const SAMPLE_DATA: OptimizationData = { changes: [], selectedOptimizations: [], @@ -26,13 +34,19 @@ class ExperienceQueueTestHarness extends ExperienceQueue { } interface BuildQueueOptions { + eventInterceptors?: LifecycleInterceptors['event'] upsertProfile?: (payload: { profileId?: string events: ExperienceEventArray }) => Promise + offlineMaxEvents?: number } -const buildQueue = ({ upsertProfile }: BuildQueueOptions = {}): { +const buildQueue = ({ + eventInterceptors = new InterceptorManager(), + offlineMaxEvents = 100, + upsertProfile, +}: BuildQueueOptions = {}): { queue: ExperienceQueueTestHarness upsertProfile: ReturnType } => { @@ -43,16 +57,73 @@ const buildQueue = ({ upsertProfile }: BuildQueueOptions = {}): { const queue = new ExperienceQueueTestHarness({ experienceApi: { upsertProfile: upsertProfileMock }, - eventInterceptors: new InterceptorManager(), + eventInterceptors, flushPolicy: resolveQueueFlushPolicy(undefined), getAnonymousId: () => undefined, - offlineMaxEvents: 100, + offlineMaxEvents, stateInterceptors: new InterceptorManager(), }) return { queue, upsertProfile: upsertProfileMock } } +const makeTrackEvent = (event: string): ExperienceEvent => ({ + channel: 'web', + context: { + app: { name: 'test-app', version: '1.0.0' }, + campaign: {}, + gdpr: { isConsentGiven: true }, + library: { name: 'test-lib', version: '1.0.0' }, + locale: 'en-US', + }, + event, + messageId: crypto.randomUUID(), + originalTimestamp: '2026-01-01T00:00:00.000Z', + properties: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + sentAt: '2026-01-01T00:00:00.000Z', + timestamp: '2026-01-01T00:00:00.000Z', + type: 'track', +}) + +const makePageEvent = (): ExperienceEvent => ({ + channel: 'web', + context: { + app: { name: 'test-app', version: '1.0.0' }, + campaign: {}, + gdpr: { isConsentGiven: true }, + library: { name: 'test-lib', version: '1.0.0' }, + locale: 'en-US', + page: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + }, + messageId: crypto.randomUUID(), + originalTimestamp: '2026-01-01T00:00:00.000Z', + properties: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + sentAt: '2026-01-01T00:00:00.000Z', + timestamp: '2026-01-01T00:00:00.000Z', + type: 'page', +}) + const observeRequestState = (): { states: ExperienceRequestState[] unsubscribe: () => void @@ -158,3 +229,45 @@ describe('ExperienceQueue.experienceRequestState transitions', () => { unsubscribe() }) }) + +describe('ExperienceQueue batches', () => { + beforeEach(() => { + onlineSignal.value = true + }) + + it('intercepts and sends a batch once', async () => { + const eventInterceptors = new InterceptorManager() + const interceptedTypes: string[] = [] + eventInterceptors.add((event) => { + interceptedTypes.push(event.type) + return event + }) + const { queue, upsertProfile } = buildQueue({ eventInterceptors }) + + await queue.sendBatch([makeTrackEvent('first'), makePageEvent()]) + + expect(upsertProfile).toHaveBeenCalledTimes(1) + expect(interceptedTypes).toEqual(['track', 'page']) + expect(upsertProfile).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + expect.objectContaining({ event: 'first' }), + expect.objectContaining({ type: 'page' }), + ]), + }), + ) + }) + + it('rejects an overflowing batch without queueing a partial batch', async () => { + const { queue, upsertProfile } = buildQueue({ offlineMaxEvents: 1 }) + onlineSignal.value = false + + await expect(queue.sendBatch([makeTrackEvent('first'), makePageEvent()])).rejects.toThrow( + 'Experience batch exceeds offline queue capacity', + ) + + onlineSignal.value = true + await queue.flush({ force: true }) + expect(upsertProfile).not.toHaveBeenCalled() + }) +}) diff --git a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts index 8cb663cad..14d14a0a6 100644 --- a/packages/universal/core-sdk/src/queues/ExperienceQueue.ts +++ b/packages/universal/core-sdk/src/queues/ExperienceQueue.ts @@ -69,6 +69,7 @@ export class ExperienceQueue { private readonly offlineMaxEvents: number private readonly onOfflineDrop?: ExperienceQueueOptions['onOfflineDrop'] private readonly queuedExperienceEvents = new Set() + private requestProfileId: string | undefined = undefined private readonly stateInterceptors: ExperienceQueueOptions['stateInterceptors'] constructor(options: ExperienceQueueOptions) { @@ -99,6 +100,11 @@ export class ExperienceQueue { }) } + /** Request-private identity is volatile and uses the ordinary queue. @internal */ + bindProfileId(profileId?: string): void { + this.requestProfileId = profileId + } + clearScheduledRetry(): void { this.flushRuntime.clearScheduledRetry() } @@ -112,23 +118,7 @@ export class ExperienceQueue { event: ExperienceEventPayload, optimizationContext?: EventOptimizationContext, ): Promise { - const intercepted = await this.eventInterceptors.run(event) - const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) - - eventSignal.value = - optimizationContext === undefined - ? validEvent - : ({ - ...validEvent, - optimization: optimizationContext, - } satisfies OptimizationEventStreamEvent) - - if (onlineSignal.value) return await this.upsertProfile([validEvent]) - - coreLogger.debug(`Queueing ${validEvent.type} event`, validEvent) - this.enqueueEvent(validEvent) - - return undefined + return await this.sendBatch([event], [optimizationContext]) } async flush(options: { force?: boolean } = {}): Promise { @@ -191,6 +181,51 @@ export class ExperienceQueue { } } + async sendBatch( + events: ExperienceEventArray, + optimizationContexts: ReadonlyArray = [], + ): Promise { + if (events.length === 0) throw new TypeError('Experience batches require at least one event.') + + const validEvents: ExperienceEventArray = [] + for (const event of events) { + const intercepted = await this.eventInterceptors.run(event) + validEvents.push(parseWithFriendlyError(ExperienceEventSchema, intercepted)) + } + + if ( + !onlineSignal.value && + validEvents.length > 1 && + this.queuedExperienceEvents.size + validEvents.length > this.offlineMaxEvents + ) { + throw new Error('Experience batch exceeds offline queue capacity and was not enqueued.') + } + + validEvents.forEach((event, index) => { + const { [index]: optimizationContext } = optimizationContexts + eventSignal.value = + optimizationContext === undefined + ? event + : ({ + ...event, + optimization: optimizationContext, + } satisfies OptimizationEventStreamEvent) + }) + + if (onlineSignal.value) return await this.upsertProfile(validEvents) + + if (validEvents.length > 1) { + validEvents.forEach((event) => this.queuedExperienceEvents.add(event)) + return undefined + } + + validEvents.forEach((event) => { + coreLogger.debug(`Queueing ${event.type} event`, event) + this.enqueueEvent(event) + }) + return undefined + } + private dropOldestEvents(count: number): ExperienceEventArray { const droppedEvents: ExperienceEventArray = [] @@ -231,10 +266,16 @@ export class ExperienceQueue { try { const data = await this.experienceApi.upsertProfile({ - profileId: anonymousId ?? profileSignal.value?.id, + profileId: this.requestProfileId ?? anonymousId ?? profileSignal.value?.id, events, }) + if (this.requestProfileId !== undefined) { + const { + profile: { id }, + } = data + this.requestProfileId = id + } await applyOptimizationDataToSignals(data, this.stateInterceptors) return data diff --git a/packages/universal/core-sdk/src/queues/InsightsQueue.ts b/packages/universal/core-sdk/src/queues/InsightsQueue.ts index 56dee4983..7daefd4cd 100644 --- a/packages/universal/core-sdk/src/queues/InsightsQueue.ts +++ b/packages/universal/core-sdk/src/queues/InsightsQueue.ts @@ -5,7 +5,7 @@ import { type BatchInsightsEventArray, type InsightsEventArray, type InsightsEvent as InsightsEventPayload, - type Profile, + type PartialProfile, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' import type { LifecycleInterceptors } from '../CoreBase' @@ -18,7 +18,7 @@ const coreLogger = createScopedLogger('CoreStateful') const MAX_QUEUED_INSIGHTS_EVENTS = 25 interface QueuedProfileEvents { - profile: Profile + profile: PartialProfile events: InsightsEventArray } @@ -47,7 +47,7 @@ export class InsightsQueue { private readonly flushIntervalMs: number private readonly flushRuntime: QueueFlushRuntime private readonly insightsApi: InsightsQueueOptions['insightsApi'] - private readonly queuedInsightsByProfile = new Map() + private readonly queuedInsightsByProfile = new Map() private insightsPeriodicFlushTimer: ReturnType | undefined constructor(options: InsightsQueueOptions) { @@ -88,8 +88,9 @@ export class InsightsQueue { async send( event: InsightsEventPayload, optimizationContext?: EventOptimizationContext, + replayProfile?: PartialProfile, ): Promise { - const { value: profile } = profileSignal + const profile = replayProfile ?? profileSignal.value if (!profile) { coreLogger.warn('Attempting to emit an event without an Optimization profile') diff --git a/packages/universal/core-sdk/src/replay.ts b/packages/universal/core-sdk/src/replay.ts new file mode 100644 index 000000000..7792ced5a --- /dev/null +++ b/packages/universal/core-sdk/src/replay.ts @@ -0,0 +1,48 @@ +import type { ExperienceEvent, InsightsEvent, PartialProfile } from './api-schemas' +import type { + ClickBuilderArgs, + FlagViewBuilderArgs, + HoverBuilderArgs, + IdentifyBuilderArgs, + PageViewBuilderArgs, + ScreenViewBuilderArgs, + TrackBuilderArgs, + ViewBuilderArgs, +} from './events' + +/** Safe caller input for optional commands before the SDK-built page command. @public */ +export type InitialExperienceCommandInput = + | ({ readonly type: 'identify' } & IdentifyBuilderArgs) + | ({ readonly type: 'track' } & TrackBuilderArgs) + | ({ readonly type: 'trackView' } & ViewBuilderArgs) + | ({ readonly type: 'trackClick' } & ClickBuilderArgs) + | ({ readonly type: 'trackHover' } & HoverBuilderArgs) + | ({ readonly type: 'trackFlagView' } & FlagViewBuilderArgs) + +/** Server inputs used to build one paired event batch. @public */ +export type OptimizationReplayCommand = + | InitialExperienceCommandInput + | ({ readonly type: 'page' } & PageViewBuilderArgs) + | ({ readonly type: 'screen' } & ScreenViewBuilderArgs) + +/** One batch preflight; all events are built on the server for browser delivery. @public */ +export interface PreviewExperienceOptions { + readonly events: readonly OptimizationReplayCommand[] +} + +/** Initial request preview input. @public */ +export interface PreviewInitialExperienceOptions { + readonly events?: readonly InitialExperienceCommandInput[] + readonly page?: PageViewBuilderArgs +} +/** Private replay payload carried inside an optimization handoff. @public */ +export interface OptimizationReplayEnvelope { + /** Required when commands contain a page. */ + readonly routeKey?: string + /** Profile known before preview, when available. */ + readonly profile?: PartialProfile + /** Server-built Personalization events, in input order. */ + readonly experience: readonly ExperienceEvent[] + /** Server-built Analytics events, delivered after Personalization. */ + readonly insights: readonly InsightsEvent[] +} diff --git a/packages/universal/core-sdk/src/tracking/AcceptedCurrentStateTracker.ts b/packages/universal/core-sdk/src/tracking/AcceptedCurrentStateTracker.ts index 16c9787ea..d0c24aa2e 100644 --- a/packages/universal/core-sdk/src/tracking/AcceptedCurrentStateTracker.ts +++ b/packages/universal/core-sdk/src/tracking/AcceptedCurrentStateTracker.ts @@ -18,6 +18,8 @@ export interface AcceptedCurrentStateEmissionOptions { readonly key: TKey readonly isAllowed: boolean readonly emit: () => Promise> + /** Initial journals share route ownership but retain their distinct events. @internal */ + readonly deduplicate?: boolean } /** @@ -61,10 +63,14 @@ export class AcceptedCurrentStateTracker { key, isAllowed, emit, + deduplicate = true, }: AcceptedCurrentStateEmissionOptions): Promise< AcceptedCurrentStateEmissionResult > { - if (!isAllowed || this.matches(this.accepted, key) || this.matches(this.inFlight, key)) { + if ( + !isAllowed || + (deduplicate && (this.matches(this.accepted, key) || this.matches(this.inFlight, key))) + ) { return { accepted: false, attempted: false } } diff --git a/packages/universal/optimization-js-bridge/src/index.test.ts b/packages/universal/optimization-js-bridge/src/index.test.ts index 3f2296fc4..7748aab3c 100644 --- a/packages/universal/optimization-js-bridge/src/index.test.ts +++ b/packages/universal/optimization-js-bridge/src/index.test.ts @@ -63,6 +63,34 @@ describe('bridge contract', () => { } }) + it('accepts deprecated global preflight without changing mutation behavior', async () => { + const fetchMock = rs.fn( + async () => new Response(JSON.stringify(PROFILE_RESPONSE)), + ) + rs.stubGlobal('fetch', fetchMock) + bridge.initialize({ + spaceId: 'test-client', + environment: 'main', + api: { preflight: true }, + defaults: { consent: true }, + }) + + await new Promise((resolve, reject) => { + bridge.page( + {}, + () => { + resolve() + }, + (error) => { + reject(new Error(error)) + }, + ) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain('type=preflight') + }) + it('rejects invalid identify payloads before calling core', () => { initializeBridge() const { onError, onSuccess } = createCallbacks() diff --git a/packages/universal/optimization-js-bridge/src/index.ts b/packages/universal/optimization-js-bridge/src/index.ts index 4fb6b7ba9..252e400b4 100644 --- a/packages/universal/optimization-js-bridge/src/index.ts +++ b/packages/universal/optimization-js-bridge/src/index.ts @@ -74,6 +74,10 @@ interface BridgeConfig { experienceBaseUrl?: CoreApiConfig['experienceBaseUrl'] insightsBaseUrl?: CoreApiConfig['insightsBaseUrl'] enabledFeatures?: CoreApiConfig['enabledFeatures'] + /** + * @deprecated Compatibility-only wire field retained for existing native + * configurations. It is forwarded without adding native replay behavior. + */ preflight?: CoreApiConfig['preflight'] } locale?: string diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 38dbb0d54..3ad30a9d0 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -178,9 +178,16 @@ Keep any `Suspense` or `connection()` boundary required by your Next.js renderin Components setup; request mode removes SDK request plumbing, not Next.js rendering requirements. Set `request.hydration` on the server binder only when a route needs another fixed hydration mode or -a synchronous resolver based on `requestUrl` and `routeKey`. Set `request.trustedRequestHandoff` to -`true` only when the request handler is configured with the server SDK and consent so it can forward -trusted page and profile context. +a synchronous resolver based on `requestUrl` and `routeKey`. The proxy or middleware request-context +handler is context-only; request helpers create their private preview and browser replay at the +rendering boundary. + +For the low-level request helpers, `initialExperienceEvents` is an array of flat `identify` and +`track` command objects. The App Router request configuration and Edge helper can resolve that array +from their framework request context before they call the low-level helper. Keep command ordering in +that application or framework boundary; replay does not normalize commands or enforce an event +grammar. If a request helper cannot derive route identity, it still returns accepted preview state, +but it omits replay and browser tracking follows the ordinary current-page path. ### Client Components @@ -230,7 +237,6 @@ const handoff = optimization.createPublicPermutationHandoff({ entryIds: permutation.entryIds, selectedOptimizations: permutation.selectedOptimizations, hydration: 'preserve-server', - initialPageEvent: 'emit', }) ``` @@ -357,9 +363,7 @@ to the browser handoff. The bound client `OptimizedEntry` and `/client` `useOpti ## Work before the initial page decision Both client binders accept `beforeInitialPage` when browser identity or custom Experience event work -must finish before the bound content root's initial page decision. That 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: +must finish before the bound content root coordinates its initial page event: ```tsx 'use client' @@ -417,10 +421,7 @@ state and replaces the request family's default root. Mount it without route changes. The component reference crosses the server composition boundary, but the callback remains in its client module and does not enter server config or Flight data. -The App server request family can accept its page event and put that ownership in the handoff. The -browser request root applies the handoff; after its live owned runtime exists, it invokes -`beforeInitialPage`, makes one direct page attempt or same-route handoff skip, marks the attempted -route, and emits for later route changes. +The App server request family preflights one initial Personalization batch and puts server-built event arrays in a private handoff. The browser root applies preview state in memory and owns the initial replay/page decision. Accepted matching page replay supplies the initial event work; otherwise `beforeInitialPage` runs before an ordinary page attempt. Preview rendering is independent of delivery. Newer handoffs preserve earlier admitted journals, and later routes use ordinary tracking. Durable continuity requires a successful live Experience response and persistence consent. The Pages Router client binder uses the same option in its client-only module: @@ -439,10 +440,7 @@ export const optimization = bindNextjsPagesRouterOptimization({ The client binder captures the callback and forwards it only to the bound content root. The callback receives receiver-safe `identify`, `screen`, and `track` methods. It is not forwarded to the bound `OptimizationProvider` or `OptimizationAnalyticsRoot`, and injected providers do not accept it. -For Pages Router requests, the server helper can accept the page event and record that ownership in -the handoff passed through page props. After the browser root applies that handoff and its live owned -runtime exists, it invokes `beforeInitialPage`, makes one direct page attempt or same-route handoff -skip, marks the attempted route, and emits for later route changes. +For Pages Router requests, the helper preflights one initial Personalization batch and places replay in the private handoff. The browser root hydrates state and owns the initial replay/page decision. An accepted matching page replay supplies the initial work; otherwise `beforeInitialPage` precedes the ordinary page attempt. Newer handoffs preserve admitted journals, preview rendering proceeds during delivery, and later routes use ordinary tracking. The `NextjsClientOptimizationConfigWithoutBeforeInitialPage` and `NextjsClientOptimizationConfigWithBeforeInitialPage` branches derive from @@ -454,12 +452,11 @@ The `NextjsClientOptimizationConfigWithoutBeforeInitialPage` and config is narrowed before binding. After the callback's returned work finishes or the watchdog expires, the root reads the latest route -and payload builder for one direct page attempt. A successfully applied same-route handoff can make -that direct decision a `skip`; otherwise, it attempts `emit`. After the attempt reaches a terminal -result, the existing page emitter makes a non-emitting initial `skip` mark for the attempted route, -then uses its normal `emit` path for later route changes. The before-initial-page root is the sole -page owner in its subtree. Direct App roots, injected App request roots, and Pages roots therefore -do not mount a separate `NextAppAutoPageTracker`, `RequestNextAppAutoPageTracker`, or +and payload builder for one initial-page coordination. Only a replay whose route key matches the +route the root is about to track skips this callback; a stale or mismatched replay is discarded and +the root attempts the current page after its usual callback. The before-initial-page root is the sole +page owner in its subtree. Direct App roots, injected App request roots, and Pages roots therefore do +not mount a separate `NextAppAutoPageTracker`, `RequestNextAppAutoPageTracker`, or `NextPagesAutoPageTracker` on this path. When `maxWaitMs` is omitted, it defaults to 3,000 ms. It accepts positive finite values. `0`, @@ -471,15 +468,12 @@ The sequence is best-effort. Return every promise or thenable that belongs to th before-initial-page work. Fire-and-forget work is later activity, and the watchdog stops waiting without canceling callback code or in-flight requests. While the root remains mounted and the same live owned runtime is current, callback failure or watchdog expiry still leads to the direct page -attempt. If the root unmounts or its runtime is replaced, only unsent local page and readiness -continuation is suppressed. +attempt. If the root unmounts or its runtime is replaced, only unsent local page work is suppressed. -A route change after the direct page attempt starts neither cancels that attempt nor starts a -competing 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; a route change -after readiness emits normally. The existing entry deadline can commit fallback content first, and -default non-live behavior keeps that fallback frozen after the before-initial-page work later -succeeds. +A route change does not cancel events already handed off. Ordinary route effects wait for the +initial operation, discard unsent work for disposed route effects, and then use the existing +accepted/in-flight route tracker. The current route can emit after initial delivery settles. +Preview content and entry resolution do not wait for event completion. Keep `beforeInitialPage` config in a client-only module. The App Router server config inherits, and the Pages Router server binder uses, an explicit `beforeInitialPage?: never` boundary. Both server @@ -511,7 +505,7 @@ const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ export const runtime = 'edge' export async function GET(request: Request) { - const { handoff, persist } = await createEdgeRequestHandoff({ + const { handoff } = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', pagePayload: { properties: { route: new URL(request.url).pathname } }, @@ -521,8 +515,6 @@ export async function GET(request: Request) { headers: { 'content-type': 'text/html; charset=utf-8' }, }) - persist(response) - return response } ``` @@ -555,16 +547,10 @@ import { createNextjsOptimizationContextHandler } from '@contentful/optimization export const proxy = createNextjsOptimizationContextHandler() ``` -The request-context handler always forwards sanitized request context headers. Called without -options, it only forwards request context. When configured with `sdk` and `consent`, it resolves -consent, performs the server page request once, forwards compact `x-ctfl-opt-server-data` context as -`encodeURIComponent(JSON.stringify({ consent, pageAccepted, profileId }))`, and persists -`ctfl-opt-aid` on the `NextResponse` when persistence is allowed. Set -`request.trustedRequestHandoff` to `true` on the App Router server binding so the request family uses -the forwarded `profileId` without a second `page()` call and uses `pageAccepted` to avoid duplicate -first page events. For manual orchestration, pass `trustedRequestHandoff: true` to the top-level -`createRequestHandoff()` instead. Only opt in on routes covered by this configured request handler; -raw client-supplied `x-ctfl-opt-server-data` is ignored unless the route opts in. +The request-context handler forwards sanitized request context headers and preserves existing +NextResponse chain state. It is context-only: it does not perform server event work, forward +profile or page results, persist `ctfl-opt-aid`, or create trusted request data. Request helpers +create their private preview and browser replay at the rendering boundary instead. Use `createNextjsPublicPermutationCacheMiddleware()` from `@contentful/optimization-nextjs/cache-middleware` when proxy code needs public permutation diff --git a/packages/web/frameworks/nextjs-sdk/package.json b/packages/web/frameworks/nextjs-sdk/package.json index 21113dd8b..59a700562 100644 --- a/packages/web/frameworks/nextjs-sdk/package.json +++ b/packages/web/frameworks/nextjs-sdk/package.json @@ -129,26 +129,26 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "app-router.cjs": 1700, - "app-router.mjs": 1600, - "app-router-server.cjs": 4900, + "app-router.cjs": 1800, + "app-router.mjs": 1400, + "app-router-server.cjs": 5200, "app-router-server.mjs": 6700, "cache-middleware.cjs": 1900, "cache-middleware.mjs": 1700, "client.cjs": 1100, - "client.mjs": 600, - "edge.cjs": 3100, - "edge.mjs": 3100, + "client.mjs": 300, + "edge.cjs": 3300, + "edge.mjs": 3000, "pages-router.cjs": 1800, - "pages-router.mjs": 1600, + "pages-router.mjs": 1300, "pages-router/server.cjs": 3900, - "pages-router/server.mjs": 5600, - "server.cjs": 2700, - "server.mjs": 3900, - "request-handler.cjs": 2900, - "request-handler.mjs": 5100, + "pages-router/server.mjs": 5300, + "server.cjs": 3100, + "server.mjs": 3700, + "request-handler.cjs": 1400, + "request-handler.mjs": 1200, "tracking-attributes.cjs": 700, - "tracking-attributes.mjs": 400 + "tracking-attributes.mjs": 300 } } }, diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx index 9ccbaa374..b0e2b8c53 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.test.tsx @@ -1,4 +1,4 @@ -import * as reactWeb from '@contentful/optimization-react-web' +import * as nextApp from '@contentful/optimization-react-web/router/next-app' import type { Entry } from 'contentful' import { renderToString } from 'react-dom/server' import * as appRouter from './app-router-client' @@ -65,7 +65,6 @@ describe('Next.js App Router client components', () => { { baselineEntry: createEntry('4ib0hsHWoSOnCVdDkizE8d'), entryId: '4ib0hsHWoSOnCVdDkizE8d' }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -85,12 +84,6 @@ describe('Next.js App Router client components', () => { expect(components.OptimizedEntry).toBe(client.OptimizedEntry) expect(components.NextAppAutoPageTracker).toBe(appRouter.NextAppAutoPageTracker) expect(components.createPublicPermutationHandoff).toBeTypeOf('function') - expect(components).not.toHaveProperty('createCacheMiddleware') - expect(components).not.toHaveProperty('proxy') - expect(components).not.toHaveProperty('config') - expect(components).not.toHaveProperty('NextPagesAutoPageTracker') - expect(components).not.toHaveProperty('createRequestHandoff') - expect(components).not.toHaveProperty('request') expect(element.props).toMatchObject({ api: testConfig.api, children: 'Bound content', @@ -138,7 +131,6 @@ describe('Next.js App Router client components', () => { cache: { scope: 'static' }, entries: [{ baselineEntry, entryId: '4ib0hsHWoSOnCVdDkizE8d' }], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -166,7 +158,6 @@ describe('Next.js App Router client components', () => { const analyticsHandoff = components.createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const root = components.OptimizationRoot({ @@ -188,38 +179,44 @@ describe('Next.js App Router client components', () => { expect(components).not.toHaveProperty('beforeInitialPage') }) - it('adds the request content root only to callback-present bindings', () => { + it('provides the request content root from every binding', () => { const withoutBeforeInitialPage = appRouter.bindNextjsAppRouterClientOptimization(testConfig) const withBeforeInitialPage = appRouter.bindNextjsAppRouterClientOptimization({ ...testConfig, beforeInitialPage: { run: () => undefined }, }) - expect(withoutBeforeInitialPage).not.toHaveProperty('RequestOptimizationRoot') + expect(withoutBeforeInitialPage.RequestOptimizationRoot).toBeTypeOf('function') expect(withBeforeInitialPage.RequestOptimizationRoot).toBeTypeOf('function') expect(withBeforeInitialPage.RequestOptimizationRoot).not.toBe( withBeforeInitialPage.OptimizationRoot, ) }) - it('keeps the low-level client entry free of router-specific exports', () => { - expect(Object.keys(client).sort()).toEqual(Object.keys(reactWeb).sort()) - expect(client).not.toHaveProperty('NextAppAutoPageTracker') - expect(client).not.toHaveProperty('NextPagesAutoPageTracker') - expect(client).not.toHaveProperty('createNextjsOptimizationComponents') - }) - - it('keeps the App Router client entry scoped to client-safe binding helpers', () => { - expect(Object.keys(appRouter).sort()).toEqual([ - 'NextAppAutoPageTracker', - 'bindNextjsAppRouterClientOptimization', - 'createHandoffFromSelections', - 'createOptimizationCacheKey', - 'createPublicPermutationCacheMetadata', - 'createPublicPermutationHandoff', - 'resolveEntriesForSelections', - ]) - expect(appRouter).not.toHaveProperty('getServerTrackingAttributes') + it('forwards before-initial-page work to the request root without a handoff', () => { + const beforeInitialPage = { run: rs.fn(() => undefined) } + const buildPagePayload: NonNullable = () => ({ + properties: { route: '/products' }, + }) + const inputs = rs.spyOn(nextApp, 'useNextAppAutoPageInputs').mockReturnValue({ + buildPagePayload, + routeKey: '/products', + }) + try { + const components = appRouter.bindNextjsAppRouterClientOptimization({ + ...testConfig, + beforeInitialPage, + }) + const root = components.RequestOptimizationRoot({ children: 'Root content' }) + + expect(root.props).toMatchObject({ + beforeInitialPage, + buildPagePayload, + routeKey: '/products', + }) + } finally { + inputs.mockRestore() + } }) it('rejects server-only request configuration', () => { diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts index 1947d3915..bff764a90 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-client.ts @@ -78,6 +78,9 @@ export interface NextjsAppRouterClientOptimization { readonly OptimizationAnalyticsRoot: ( props: BoundNextjsOptimizationAnalyticsRootProps, ) => ReactElement + readonly RequestOptimizationRoot: ( + props: BoundNextjsAppRouterRequestClientRootProps, + ) => ReactElement readonly OptimizedEntry: NextjsBoundOptimizedEntryComponent readonly NextAppAutoPageTracker: typeof NextAppAutoPageTracker readonly createHandoffFromSelections: typeof createHandoffFromSelections @@ -93,9 +96,6 @@ export interface NextjsAppRouterClientOptimizationWithBeforeInitialPage extends readonly OptimizationRoot: ( props: BoundNextjsOptimizationRootWithBeforeInitialPageProps, ) => ReactElement - readonly RequestOptimizationRoot: ( - props: BoundNextjsAppRouterRequestClientRootProps, - ) => ReactElement } export function bindNextjsAppRouterClientOptimization( @@ -180,6 +180,7 @@ export function bindNextjsAppRouterClientOptimization( OptimizationAnalyticsRoot, OptimizationProvider, OptimizedEntry: ReactWebOptimizedEntry, + RequestOptimizationRoot, createHandoffFromSelections, createOptimizationCacheKey, createPublicPermutationHandoff, @@ -193,7 +194,6 @@ export function bindNextjsAppRouterClientOptimization( return { ...commonResult, OptimizationRoot: OptimizationRootWithBeforeInitialPage, - RequestOptimizationRoot, } } diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts b/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts index e4cdc1322..0f6d79643 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-request-handoff.ts @@ -3,29 +3,11 @@ import type { PrivateRequestOptimizationCacheMetadata, StatefulDefaults, } from '@contentful/optimization-react-web/core-sdk' -import { - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - parseNextjsOptimizationRequestContext, -} from './request-context' import type { CoreStatelessRequestConsent } from './server' const REQUEST_HANDOFF_CACHE_SCOPE_ERROR = 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.' -export interface NextjsForwardedServerData { - readonly consent: CoreStatelessRequestConsent - readonly pageAccepted: boolean - readonly profileId?: string -} - -interface ForwardedProfileOptionsInput { - readonly experienceOptions?: { - readonly ip?: string - readonly locale?: string - } - readonly locale?: string -} - export function assertRequestHandoffCacheMetadata( cache: OptimizationCacheMetadata, ): asserts cache is PrivateRequestOptimizationCacheMetadata { @@ -33,21 +15,6 @@ export function assertRequestHandoffCacheMetadata( throw new TypeError(REQUEST_HANDOFF_CACHE_SCOPE_ERROR) } -export function readNextjsForwardedServerData( - headers: Headers, - trustedRequestHandoff: true | undefined, -): NextjsForwardedServerData | undefined { - if (trustedRequestHandoff === undefined) return undefined - - const encodedValue = headers.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) - - if (encodedValue === null) return undefined - - const value = parseNextjsOptimizationRequestContext(encodedValue) - - return isNextjsForwardedServerData(value) ? value : undefined -} - export function toHandoffDefaults(consent: CoreStatelessRequestConsent): StatefulDefaults { if (typeof consent === 'boolean') { return { consent, persistenceConsent: consent } @@ -58,42 +25,3 @@ export function toHandoffDefaults(consent: CoreStatelessRequestConsent): Statefu persistenceConsent: consent.persistence ?? false, } } - -export function toForwardedProfileOptions( - options: ForwardedProfileOptionsInput, - configLocale: string | undefined, -): { readonly ip?: string; readonly locale?: string } { - const locale = options.locale ?? configLocale ?? options.experienceOptions?.locale - - return { - ...(options.experienceOptions?.ip === undefined ? {} : { ip: options.experienceOptions.ip }), - ...(locale === undefined ? {} : { locale }), - } -} - -function isNextjsForwardedServerData(value: unknown): value is NextjsForwardedServerData { - return ( - isRecord(value) && - isCoreStatelessRequestConsent(value.consent) && - typeof value.pageAccepted === 'boolean' && - (value.profileId === undefined || isProfileId(value.profileId)) - ) -} - -function isCoreStatelessRequestConsent(value: unknown): value is CoreStatelessRequestConsent { - if (typeof value === 'boolean') return true - if (!isRecord(value)) return false - - return ( - (value.events === undefined || typeof value.events === 'boolean') && - (value.persistence === undefined || typeof value.persistence === 'boolean') - ) -} - -function isProfileId(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx index 83395fa35..1ffe66668 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-request-runtime.tsx @@ -1,17 +1,12 @@ -import { createRequestHandoffFromData } from '@contentful/optimization-node' import type { + InitialExperienceCommandInput, OptimizationCacheMetadata, PrivateRequestOptimizationCacheMetadata, } from '@contentful/optimization-react-web/core-sdk' import { NextAppAutoPageTracker } from '@contentful/optimization-react-web/router/next-app' import { cookies, headers } from 'next/headers' import { cache, createElement, type ReactElement } from 'react' -import { - assertRequestHandoffCacheMetadata, - readNextjsForwardedServerData, - toForwardedProfileOptions, - toHandoffDefaults, -} from './app-router-request-handoff' +import { assertRequestHandoffCacheMetadata, toHandoffDefaults } from './app-router-request-handoff' import type { BoundNextjsOptimizationProviderProps, BoundNextjsOptimizationRootProps, @@ -24,13 +19,17 @@ import type { NextjsOptimizationServerConsent, NextjsOptimizationServerConsentResolver, } from './bound-component-types' -import { - addBrowserHandoffMetadata, - type BrowserOptimizationHandoff, - type ContentOptimizationHandoff, - type ContentOptimizationHydrationMode, +import type { + BrowserOptimizationHandoff, + ContentOptimizationHandoff, + ContentOptimizationHydrationMode, } from './handoff' import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' +import { + createPrivateRequestPreviewFallbackHandoff, + reportRequestPreviewFallback, + resolveRequestPreview, +} from './request-preview-fallback' import { createNextjsRequestHandoff, type ContentfulOptimization, @@ -49,6 +48,7 @@ export type AppRouterCreateRequestHandoffOptions = Omit< readonly hydration: ContentOptimizationHydrationMode readonly locale?: string readonly request: NextjsRequestLike + /** @deprecated This compatibility field is no longer read. */ readonly trustedRequestHandoff?: true } @@ -97,47 +97,32 @@ export function bindNextjsAppRouterRequestRuntime({ } assertRequestHandoffCacheMetadata(cacheMetadata) - const forwardedServerData = readNextjsForwardedServerData( - options.request.headers, - options.trustedRequestHandoff, - ) - if (forwardedServerData !== undefined) { - const data = - forwardedServerData.profileId === undefined - ? undefined - : await sdk.api.experience.getProfile( - forwardedServerData.profileId, - toForwardedProfileOptions(options, config.locale), - ) - const handoff = addBrowserHandoffMetadata( - createRequestHandoffFromData({ + const result = await resolveRequestPreview( + async () => { + const consent = await resolveServerConsent(config.consent?.server, { + cookies: options.request.cookies ?? EMPTY_COOKIE_READER, + headers: options.request.headers, + }) + const { handoff } = await createNextjsRequestHandoff(sdk, { + ...options, cache: cacheMetadata, - data, + consent, + locale: options.locale ?? config.locale, + request: options.request, + }) + return { defaults: toHandoffDefaults(consent), handoff } + }, + () => ({ + defaults: toHandoffDefaults(false), + handoff: createPrivateRequestPreviewFallbackHandoff({ entries: options.entries, - }), - { hydration: options.hydration, - initialPageEvent: forwardedServerData.pageAccepted ? 'skip' : 'emit', - }, - ) - rememberRequestHandoff(handoff, toHandoffDefaults(forwardedServerData.consent)) - - return handoff - } - - const consent = await resolveServerConsent(config.consent?.server, { - cookies: options.request.cookies ?? EMPTY_COOKIE_READER, - headers: options.request.headers, - }) - const { handoff } = await createNextjsRequestHandoff(sdk, { - ...options, - cache: cacheMetadata, - consent, - locale: options.locale ?? config.locale, - request: options.request, - }) - - rememberRequestHandoff(handoff, toHandoffDefaults(consent)) + }), + }), + ) + const { value } = result + const { defaults, handoff } = value + rememberRequestHandoff(handoff, defaults) return handoff } @@ -148,28 +133,73 @@ export function bindNextjsAppRouterRequestRuntime({ const requestUrl = requestHeaders.get(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER) if (requestUrl === null) { - throw new Error( - 'Missing x-ctfl-opt-request-url. Configure the Contentful Optimization request handler in your Next.js proxy before using request components.', + const hydration = getFallbackHydration(config) + reportRequestPreviewFallback( + new Error( + 'Missing x-ctfl-opt-request-url. Configure the Contentful Optimization request handler in your Next.js proxy before using request components.', + ), ) + const handoff = createPrivateRequestPreviewFallbackHandoff({ hydration }) + rememberRequestHandoff(handoff, toHandoffDefaults(false)) + return { + handoff, + hydration, + pagePayload: undefined, + routeKey: undefined, + } + } + + let url: URL | undefined = undefined + try { + url = new URL(requestUrl) + } catch (error) { + const hydration = getFallbackHydration(config) + reportRequestPreviewFallback(error) + const handoff = createPrivateRequestPreviewFallbackHandoff({ hydration }) + rememberRequestHandoff(handoff, toHandoffDefaults(false)) + return { + handoff, + hydration, + pagePayload: undefined, + routeKey: undefined, + } } - const url = new URL(requestUrl) - const routeKey = `${url.pathname}${url.search}` - const pagePayload = { + const resolvedRouteKey = `${url.pathname}${url.search}` + const resolvedPagePayload = { properties: { path: url.pathname, search: url.search, url: requestUrl }, } - const hydration = - typeof config.request?.hydration === 'function' - ? config.request.hydration({ requestUrl, routeKey }) - : (config.request?.hydration ?? 'preserve-server') - const handoff = await createRequestHandoff({ - hydration, - pagePayload, - request: { cookies: cookieStore, headers: requestHeaders, url: requestUrl }, - trustedRequestHandoff: config.request?.trustedRequestHandoff, - }) + const fallbackHydration = getFallbackHydration(config) + let hydration = fallbackHydration + const result = await resolveRequestPreview( + async () => { + hydration = + typeof config.request?.hydration === 'function' + ? config.request.hydration({ requestUrl, routeKey: resolvedRouteKey }) + : fallbackHydration + const initialExperienceEvents = await resolveInitialExperienceEvents( + config.request?.initialExperienceEvents, + { requestUrl, routeKey: resolvedRouteKey }, + ) + const handoff = await createRequestHandoff({ + hydration, + ...(initialExperienceEvents === undefined ? {} : { initialExperienceEvents }), + pagePayload: resolvedPagePayload, + request: { cookies: cookieStore, headers: requestHeaders, url: requestUrl }, + }) + return { handoff, hydration, pagePayload: resolvedPagePayload, routeKey: resolvedRouteKey } + }, + () => ({ + handoff: createPrivateRequestPreviewFallbackHandoff({ hydration }), + hydration, + pagePayload: resolvedPagePayload, + routeKey: resolvedRouteKey, + }), + ) + const { value } = result + if (result.degraded) rememberRequestHandoff(value.handoff, toHandoffDefaults(false)) - return { handoff, hydration, pagePayload, routeKey } + return value }) async function RequestOptimizationRoot( @@ -189,8 +219,8 @@ export function bindNextjsAppRouterRequestRuntime({ ...rootProps, handoff, hydration, - initialPagePayload: pagePayload, - routeKey, + ...(pagePayload === undefined ? {} : { initialPagePayload: pagePayload }), + ...(routeKey === undefined ? {} : { routeKey }), }) } @@ -217,12 +247,8 @@ export function bindNextjsAppRouterRequestRuntime({ async function RequestNextAppAutoPageTracker( props: Parameters[0], ): Promise { - const { handoff } = await getRequestRenderInputs() - - return createElement(NextAppAutoPageTracker, { - ...props, - initialPageEvent: handoff.initialPageEvent, - }) + await getRequestRenderInputs() + return createElement(NextAppAutoPageTracker, props) } return { @@ -236,6 +262,30 @@ export function bindNextjsAppRouterRequestRuntime({ } } +function getFallbackHydration( + config: NextjsAppRouterServerOptimizationConfig, +): ContentOptimizationHydrationMode { + return typeof config.request?.hydration === 'string' + ? config.request.hydration + : 'preserve-server' +} + +async function resolveInitialExperienceEvents( + input: + | readonly InitialExperienceCommandInput[] + | ((context: { + readonly requestUrl: string + readonly routeKey: string + }) => + | readonly InitialExperienceCommandInput[] + | Promise) + | undefined, + context: { readonly requestUrl: string; readonly routeKey: string }, +): Promise { + if (typeof input === 'function') return await input(context) + return input +} + function resolveServerConsent( consent: NextjsOptimizationServerConsent | NextjsOptimizationServerConsentResolver | undefined, context: Parameters[0], diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx index 4367bdc1e..193b39ee4 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.test.tsx @@ -1,6 +1,7 @@ import ContentfulOptimizationRuntime from '@contentful/optimization-node' import type { MergeTagEntry } from '@contentful/optimization-node/api-schemas' import type { CoreStatelessRequest } from '@contentful/optimization-node/core-sdk' +import { EventBuilder } from '@contentful/optimization-node/core-sdk' import { useConsentState, useSelectedOptimizationsState } from '@contentful/optimization-react-web' import { PassThrough } from 'node:stream' import type { ReactElement } from 'react' @@ -12,16 +13,14 @@ import type { createPublicPermutationCacheMetadata as createPublicPermutationCacheMetadataFactory, createPublicPermutationHandoff as createPublicPermutationHandoffFactory, } from './app-router-server' -import { - NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext, -} from './request-context' +import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' import type { OptimizationData, ServerTrackingBaselineEntry } from './server' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) type CacheableFunction = (...args: never[]) => unknown -type FetchMethod = (input: string | Request, init?: RequestInit) => Promise - let bindNextjsAppRouterServerOptimization: typeof bindNextjsAppRouterServerOptimizationFactory let createStandaloneHandoffFromSelections: typeof createHandoffFromSelectionsFactory let createStandalonePublicPermutationHandoff: typeof createPublicPermutationHandoffFactory @@ -121,13 +120,6 @@ const optimizationData: OptimizationData = { }, } -function setForwardedServerData(headers: Headers, value: unknown): void { - headers.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext(value), - ) -} - afterEach(() => { reactCacheTestGeneration += 1 rs.restoreAllMocks() @@ -247,12 +239,29 @@ function createMergeTagEntry(id: string, selector: string): MergeTagEntry { } } -function mockRequestPage(result: Awaited>): { +type PreviewFixture = + | { readonly accepted: false } + | { readonly accepted: true; readonly data: OptimizationData } + +function mockRequestPreview(result: PreviewFixture): { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly previewInitialExperience: ReturnType< + typeof rs.fn + > } { const originalForRequest = ContentfulOptimizationRuntime.prototype.forRequest - const page = rs.fn(async () => await Promise.resolve(result)) + const previewInitialExperience = rs.fn( + async () => + await Promise.resolve( + result.accepted + ? { + ...result, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + } + : result, + ), + ) const forRequest = rs.spyOn(ContentfulOptimizationRuntime.prototype, 'forRequest') forRequest.mockImplementation(function mockForRequest( @@ -260,11 +269,13 @@ function mockRequestPage(result: Awaited> { - return rs.fn( - async () => - await Promise.resolve( - new Response( - JSON.stringify({ - data: { - changes: data.changes, - experiences: data.selectedOptimizations, - profile: data.profile, - }, - error: null, - message: 'ok', - }), - ), - ), - ) -} - async function renderToHtml(element: ReactElement): Promise { return await new Promise((resolve, reject) => { let html = '' @@ -351,7 +341,6 @@ describe('Next.js App Router v2 binding', () => { const optimization = bindNextjsAppRouterServerOptimization(sdkConfig) expect(appRouterServerExports.bindNextjsAppRouterServerOptimization).toBeTypeOf('function') - expect(appRouterServerExports).not.toHaveProperty('createNextjsAppRouterOptimization') expect(optimization.OptimizationRoot).toBeTypeOf('function') expect(optimization.OptimizationAnalyticsRoot).toBeTypeOf('function') expect(optimization.OptimizedEntry).toBeTypeOf('function') @@ -365,15 +354,12 @@ describe('Next.js App Router v2 binding', () => { expect(optimization.createPublicPermutationHandoff).toBeTypeOf('function') expect(appRouterServerExports.createPublicPermutationCacheMetadata).toBeTypeOf('function') expect(appRouterServerExports.createPublicPermutationHandoff).toBeTypeOf('function') - expect(optimization).not.toHaveProperty('getServerTrackingAttributes') expect(optimization.resolveEntriesForSelections).toBeTypeOf('function') - expect(optimization).not.toHaveProperty('createCacheMiddleware') - expect(optimization).not.toHaveProperty('proxy') }) it('replaces only the request root with an injected serializable client root', async () => { setCurrentNextRequest('https://example.test/page-two?beforeInitialPage=readiness') - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const ClientRequestOptimizationRoot = (_props: { readonly children?: React.ReactNode }): ReactElement => React.createElement(React.Fragment) @@ -391,7 +377,7 @@ describe('Next.js App Router v2 binding', () => { defaults: { consent: false, persistenceConsent: false }, handoff: { hydration: 'preserve-server', - initialPageEvent: 'skip', + replay: expect.objectContaining({ routeKey: '/page-two?beforeInitialPage=readiness' }), }, hydration: 'preserve-server', }) @@ -406,12 +392,20 @@ describe('Next.js App Router v2 binding', () => { it('waits for the shared request handoff when OptimizedEntry starts before the root', async () => { setCurrentNextRequest() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) let resolvePage: ((result: { accepted: true; data: OptimizationData }) => void) | undefined const delayedPage = new Promise<{ accepted: true; data: OptimizationData }>((resolve) => { resolvePage = resolve }) - page.mockImplementationOnce(async () => await delayedPage) + previewInitialExperience.mockImplementationOnce(async () => ({ + accepted: true, + data: (await delayedPage).data, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + })) const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) const entryPromise = request.OptimizedEntry({ @@ -427,7 +421,7 @@ describe('Next.js App Router v2 binding', () => { const [entry, root] = await Promise.all([entryPromise, rootPromise]) const html = await renderToHtml(entry) - expect(page).toHaveBeenCalledTimes(1) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) expect(getElementProps(root).handoff).toBeDefined() expect(html).toContain(`data-ctfl-entry-id="${variantEntry.sys.id}"`) expect(html).toContain(variantEntry.sys.id) @@ -443,10 +437,18 @@ describe('Next.js App Router v2 binding', () => { data: OptimizationData }>() const cdaRelease = Promise.withResolvers() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => { + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) + previewInitialExperience.mockImplementationOnce(async () => { experienceStarted.resolve(undefined) - return await experienceRelease.promise + return { + accepted: true, + data: (await experienceRelease.promise).data, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + } }) const getEntry = rs.fn(async () => { cdaStarted.resolve(undefined) @@ -472,7 +474,7 @@ describe('Next.js App Router v2 binding', () => { void entryPromise.then(settled) await Promise.all([experienceStarted.promise, cdaStarted.promise]) - expect(page).toHaveBeenCalledTimes(1) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) expect(getEntry).toHaveBeenCalledTimes(1) cdaRelease.resolve(optimizedEntry) @@ -500,12 +502,20 @@ describe('Next.js App Router v2 binding', () => { data: OptimizationData }>() const cdaRelease = Promise.withResolvers() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => { + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) + previewInitialExperience.mockImplementationOnce(async () => { experienceStarted.resolve(undefined) - const result = await experienceRelease.promise + const data = (await experienceRelease.promise).data experienceFinished.resolve(undefined) - return result + return { + accepted: true, + data, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + } }) const getEntry = rs.fn(async () => { cdaStarted.resolve(undefined) @@ -526,7 +536,7 @@ describe('Next.js App Router v2 binding', () => { void componentPromise.then(settled) await Promise.all([experienceStarted.promise, cdaStarted.promise]) - expect(page).toHaveBeenCalledTimes(1) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) expect(getEntry).toHaveBeenCalledTimes(1) experienceRelease.resolve({ accepted: true, data: optimizationData }) @@ -549,53 +559,41 @@ describe('Next.js App Router v2 binding', () => { }, ) - it.each(['managed prefetch', 'OptimizedEntry'] as const)( - 'surfaces request initialization failure before %s CDA failure', - async (component) => { - setCurrentNextRequest() - const requestError = new Error('Request initialization failed') - const cdaStarted = Promise.withResolvers() - const requestRelease = Promise.withResolvers<{ - accepted: true - data: OptimizationData - }>() - const { page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockImplementationOnce(async () => await requestRelease.promise) - const getEntry = rs.fn(async () => { - cdaStarted.resolve(undefined) - return await Promise.reject(new Error('CDA failed')) - }) - const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) - const { request } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - contentful: { cache: false, client: { getEntry, getEntries } }, - }) - const settled = rs.fn() - const result = ( - component === 'managed prefetch' - ? request.OptimizationRoot({ - children: null, - prefetchManagedEntries: [baselineEntry.sys.id], - }) - : request.OptimizedEntry({ children: null, entryId: baselineEntry.sys.id }) - ).catch((error: unknown) => error) - void result.then(settled) + it('falls back to a profileless handoff when request preview rejects', async () => { + setCurrentNextRequest() + const requestError = new Error('Request initialization failed') + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) + previewInitialExperience.mockRejectedValueOnce(requestError) + const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) - await cdaStarted.promise - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() + const root = await request.OptimizationRoot({ children: null }) - requestRelease.reject(requestError) - expect(await result).toBe(requestError) - }, - ) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) + expect(getElementProps(root)).toMatchObject({ + defaults: { consent: false, persistenceConsent: false }, + handoff: { cache: { scope: 'private-request' }, hydration: 'preserve-server' }, + initialPagePayload: { + properties: { + path: '/products', + search: '?tab=featured', + url: 'https://example.test/products?tab=featured', + }, + }, + routeKey: '/products?tab=featured', + }) + expect(getElementProps(root).handoff).not.toHaveProperty('state') + expect(getElementProps(root).handoff).not.toHaveProperty('replay') + }) it.each(['managed prefetch', 'OptimizedEntry'] as const)( 'surfaces %s CDA failure after successful request initialization', async (component) => { setCurrentNextRequest() const cdaError = new Error('CDA failed') - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const getEntry = rs.fn(async () => await Promise.reject(cdaError)) const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) const { request } = bindNextjsAppRouterServerOptimization({ @@ -617,25 +615,26 @@ describe('Next.js App Router v2 binding', () => { it('initializes all request wrappers from one cached resource', async () => { setCurrentNextRequest() - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) + const { forRequest, previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) - const [root, provider, entry, tracker] = await Promise.all([ + const [root, provider, entry] = await Promise.all([ request.OptimizationRoot({ children: 'Root' }), request.OptimizationProvider({ children: 'Provider' }), request.OptimizedEntry({ baselineEntry: optimizedEntry, children: (resolvedEntry) => resolvedEntry.sys.id, }), - request.NextAppAutoPageTracker({}), ]) expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) expect(getElementProps(root).handoff).toBe( provider === null ? undefined : getElementProps(provider).handoff, ) - expect(getElementProps(tracker).initialPageEvent).toBe('skip') expect(await renderToHtml(entry)).toContain(variantEntry.sys.id) expect(readNextCookies).toHaveBeenCalledTimes(1) expect(readNextHeaders).toHaveBeenCalledTimes(1) @@ -653,7 +652,7 @@ describe('Next.js App Router v2 binding', () => { ] as const)('uses %s request hydration', async (_label, hydration, expectedHydration) => { const url = 'https://example.test/products?tab=featured' setCurrentNextRequest(url) - mockRequestPage({ accepted: true }) + mockRequestPreview({ accepted: true, data: optimizationData }) const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, request: hydration === undefined ? undefined : { hydration }, @@ -670,31 +669,18 @@ describe('Next.js App Router v2 binding', () => { }) }) - it('fails with request-handler setup guidance when the forwarded URL is missing', async () => { + it('leaves page inputs to the browser when the forwarded URL is missing', async () => { const { request } = bindNextjsAppRouterServerOptimization(sdkConfig) - await expect(request.OptimizationRoot({ children: null })).rejects.toThrow( - 'Missing x-ctfl-opt-request-url. Configure the Contentful Optimization request handler in your Next.js proxy before using request components.', - ) - }) + const root = await request.OptimizationRoot({ children: null }) - it('uses trusted forwarded handoff state and preserves page-event ownership only when opted in', async () => { - const forwardedRequest = setCurrentNextRequest() - setForwardedServerData(forwardedRequest.headers, { - consent: true, - pageAccepted: true, - }) - const { forRequest, page } = mockRequestPage({ accepted: false }) - const { request } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - request: { trustedRequestHandoff: true }, + expect(getElementProps(root)).toMatchObject({ + defaults: { consent: false, persistenceConsent: false }, + handoff: { cache: { scope: 'private-request' }, hydration: 'preserve-server' }, }) - - const tracker = await request.NextAppAutoPageTracker({}) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getElementProps(tracker).initialPageEvent).toBe('skip') + expect(getElementProps(root)).not.toHaveProperty('initialPagePayload') + expect(getElementProps(root)).not.toHaveProperty('routeKey') + expect(getElementProps(root).handoff).not.toHaveProperty('state') }) it('isolates request URL, profile, handoff, and selected-entry state across RSC requests', async () => { @@ -708,9 +694,22 @@ describe('Next.js App Router v2 binding', () => { profile: secondProfile, selectedOptimizations: [], } - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - page.mockResolvedValueOnce({ accepted: true, data: optimizationData }) - page.mockResolvedValueOnce({ accepted: true, data: secondData }) + const { forRequest, previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) + previewInitialExperience.mockResolvedValueOnce({ + accepted: true, + data: optimizationData, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }) + previewInitialExperience.mockResolvedValueOnce({ + accepted: true, + data: secondData, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }) const hydration = rs.fn(() => 'preserve-server' as const) const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, @@ -733,7 +732,7 @@ describe('Next.js App Router v2 binding', () => { }) expect(forRequest).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenCalledTimes(2) + expect(previewInitialExperience).toHaveBeenCalledTimes(2) expect(hydration.mock.calls).toEqual([ [{ requestUrl: 'https://example.test/first?segment=a', routeKey: '/first?segment=a' }], [{ requestUrl: 'https://example.test/second?segment=b', routeKey: '/second?segment=b' }], @@ -751,7 +750,10 @@ describe('Next.js App Router v2 binding', () => { }) it('keeps top-level static, public, analytics, and manual paths free of Next.js request reads', async () => { - const { page } = mockRequestPage({ accepted: true }) + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) const { OptimizationAnalyticsRoot, OptimizationRoot, @@ -762,18 +764,15 @@ describe('Next.js App Router v2 binding', () => { const staticHandoff = createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) createPublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) const publicHandoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) @@ -786,7 +785,7 @@ describe('Next.js App Router v2 binding', () => { request: createRequest(), }) - expect(page).toHaveBeenCalledTimes(1) + expect(previewInitialExperience).toHaveBeenCalledTimes(1) expect(readNextCookies).not.toHaveBeenCalled() expect(readNextHeaders).not.toHaveBeenCalled() }) @@ -809,7 +808,6 @@ describe('Next.js App Router v2 binding', () => { cache: { scope: 'static' }, entries: [{ baselineEntry: variantEntry, entryId: variantEntry.sys.id }], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -890,214 +888,50 @@ describe('Next.js App Router v2 binding', () => { expect(element?.props).not.toHaveProperty('prefetchManagedEntries') }) - it.each([ - ['accepted with data', { accepted: true, data: optimizationData }, 'skip'], - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], - ['pre-consent accepted', { accepted: true, data: optimizationData }, 'skip'], - ] as const)( - 'creates request handoff with initialPageEvent from page acceptance: %s', - async (_label, pageResult, expectedInitialPageEvent) => { - const { forRequest, page } = mockRequestPage(pageResult) - const serverConsent = _label !== 'pre-consent accepted' - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - consent: { server: serverConsent }, - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request: createRequest(), - }) - - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) - expect(forRequest).toHaveBeenCalledWith( - expect.objectContaining({ - consent: serverConsent, - eventContext: expect.objectContaining({ - page: expect.objectContaining({ - path: '/products', - search: '?tab=featured', - }), - userAgent: 'app-router-agent', - }), - profile: { id: 'incoming-id' }, - }), - ) - expect(handoff.initialPageEvent).toBe(expectedInitialPageEvent) - expect(handoff.cache).toEqual({ scope: 'private-request' }) - }, - ) - - it.each([ - ['accepted with data', true], - ['not accepted with data', false], - ] as const)( - 'creates request handoff from trusted forwarded server data while browser owns page payload: %s', - async (_label, pageAccepted) => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const getProfile = mockProfileFetch() - const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - fetchOptions: { fetchMethod: getProfile }, - }) - - setForwardedServerData(request.headers, { - consent: { events: true, persistence: false }, - pageAccepted, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, - }) - const element = await OptimizationRoot({ children: 'Server content', handoff }) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getProfile).toHaveBeenCalledTimes(1) - const profileUrl = getProfile.mock.calls[0]?.[0] - if (typeof profileUrl !== 'string') throw new Error('Expected getProfile URL string.') - expect(profileUrl).toContain('/profiles/f0837d7dc6344c36a3a0a06c4cde754b') - expect(profileUrl).toContain('locale=en-US') - expect(handoff.initialPageEvent).toBe(pageAccepted ? 'skip' : 'emit') - expect(handoff.cache).toEqual({ scope: 'private-request' }) - expect(handoff.state).toEqual({ - changes: optimizationData.changes, - profile: optimizationData.profile, - selectedOptimizations: optimizationData.selectedOptimizations, - }) - expect(element.props).toMatchObject({ - defaults: { consent: true, persistenceConsent: false }, - }) - }, - ) - - it.each([ - [ - 'accepted without persistence consent', - { - consent: { events: true }, - defaults: { consent: true, persistenceConsent: false }, - pageAccepted: true, - }, - ], - [ - 'accepted without data', - { - consent: { events: true, persistence: false }, - defaults: { consent: true, persistenceConsent: false }, - pageAccepted: true, - }, - ], - [ - 'blocked without data', + it('resolves request initial experience events from the App Router context', async () => { + setCurrentNextRequest() + const events = [ { - consent: false, - defaults: { consent: false, persistenceConsent: false }, - pageAccepted: false, + event: 'initial-preview', + properties: { source: 'app-router' }, + type: 'track', }, - ], - ] as const)( - 'creates request handoff from trusted forwarded no-data server result while browser owns page payload: %s', - async (_label, { consent, defaults, pageAccepted }) => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const getProfile = mockProfileFetch() - const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - fetchOptions: { fetchMethod: getProfile }, - }) - - setForwardedServerData(request.headers, { - consent, - pageAccepted, - }) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, + ] as const + const resolveEvents = rs.fn(({ requestUrl, routeKey }) => { + expect({ requestUrl, routeKey }).toEqual({ + requestUrl: 'https://example.test/products?tab=featured', + routeKey: '/products?tab=featured', }) - const element = await OptimizationRoot({ children: 'Server content', handoff }) - - expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - expect(getProfile).not.toHaveBeenCalled() - expect(handoff.initialPageEvent).toBe(pageAccepted ? 'skip' : 'emit') - expect(handoff.cache).toEqual({ scope: 'private-request' }) - expect(handoff.state).toBeUndefined() - expect(element.props).toMatchObject({ defaults }) - }, - ) - - it('ignores raw forwarded server data without trusted opt-in', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ - ...sdkConfig, - consent: { server: true }, + return events }) - - request.headers.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext({ - consent: false, - pageAccepted: false, - profileId: 'a19c3f54d2b84e37a93f6d1c0e5b7284', - }), - ) - - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, }) - - expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) - expect(handoff.initialPageEvent).toBe('skip') - }) - - it('ignores forwarded server data without a pageAccepted signal', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) - const request = createRequest() - const { createRequestHandoff } = bindNextjsAppRouterServerOptimization({ + const { request } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, - consent: { server: true }, + request: { initialExperienceEvents: resolveEvents }, }) - setForwardedServerData(request.headers, { - consent: false, - pageAccepted: undefined, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) + await request.OptimizationRoot({ children: null }) - const handoff = await createRequestHandoff({ - cache: { scope: 'private-request' }, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - request, - trustedRequestHandoff: true, + expect(previewInitialExperience).toHaveBeenCalledWith({ + events, + page: { + properties: { + path: '/products', + search: '?tab=featured', + url: 'https://example.test/products?tab=featured', + }, + }, }) - - expect(forRequest).toHaveBeenCalledTimes(1) - expect(page).toHaveBeenCalledTimes(1) - expect(handoff.initialPageEvent).toBe('skip') }) it('rejects public request handoff cache metadata before request evaluation', async () => { - const { forRequest, page } = mockRequestPage({ accepted: true, data: optimizationData }) + const { forRequest, previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) const { createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) await expect( @@ -1112,14 +946,38 @@ describe('Next.js App Router v2 binding', () => { 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', ) expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() + expect(previewInitialExperience).not.toHaveBeenCalled() + }) + + it('falls back when the public request handoff preview rejects', async () => { + const entries = [{ baselineEntry, entryId: baselineEntry.sys.id }] as const + const { previewInitialExperience } = mockRequestPreview({ + accepted: true, + data: optimizationData, + }) + previewInitialExperience.mockRejectedValueOnce(new Error('Preview unavailable')) + const { createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) + + const handoff = await createRequestHandoff({ + entries, + hydration: 'preserve-server', + pagePayload: { properties: { path: '/products' } }, + request: createRequest(), + }) + + expect(handoff).toMatchObject({ + cache: { scope: 'private-request' }, + entries, + hydration: 'preserve-server', + }) + expect(handoff).not.toHaveProperty('state') + expect(handoff).not.toHaveProperty('replay') }) it('creates analytics-only public permutation handoffs without mounting content personalization', () => { const { OptimizationAnalyticsRoot } = bindNextjsAppRouterServerOptimization(sdkConfig) const handoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], }) @@ -1145,7 +1003,6 @@ describe('Next.js App Router v2 binding', () => { it('preserves caller-owned public permutation tags', () => { const handoff = createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], tags: ['segment-a', 'products'], @@ -1159,7 +1016,6 @@ describe('Next.js App Router v2 binding', () => { createStandaloneHandoffFromSelections({ cache: { key: 'segment-a', scope: 'public-permutation', tags: ['segment,a'] }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }), ).toThrow(TypeError) @@ -1175,7 +1031,6 @@ describe('Next.js App Router v2 binding', () => { expect(() => createStandalonePublicPermutationHandoff({ hydration: 'analytics-only', - initialPageEvent: 'emit', permutationKey: 'segment-a', selectedOptimizations: [], tags, @@ -1190,13 +1045,11 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) const handoff = createStandaloneHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations, }) OptimizationAnalyticsRoot({ @@ -1245,7 +1098,6 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'empty-variant' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: emptyVariantSelectedOptimizations, }) const html = await renderToHtml( @@ -1275,7 +1127,7 @@ describe('Next.js App Router v2 binding', () => { }) it('resolves server OptimizedEntry from request handoff selections', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) @@ -1306,7 +1158,7 @@ describe('Next.js App Router v2 binding', () => { }) it('defaults server merge-tag helpers to the request handoff profile', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const mergeTagEntry = createMergeTagEntry('merge-tag', 'traits.continent') const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = bindNextjsAppRouterServerOptimization(sdkConfig) @@ -1341,7 +1193,6 @@ describe('Next.js App Router v2 binding', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) @@ -1381,7 +1232,6 @@ describe('Next.js App Router v2 binding', () => { createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations, }) cdaRelease.resolve(optimizedEntry) @@ -1394,7 +1244,7 @@ describe('Next.js App Router v2 binding', () => { }) it('uses request handoff selections when resolving managed server entries', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const getEntry = rs.fn(async () => await Promise.resolve(optimizedEntry)) const getEntries = rs.fn(async () => await Promise.resolve(createEntryCollection([]))) const { OptimizationRoot, OptimizedEntry, createRequestHandoff } = @@ -1431,7 +1281,7 @@ describe('Next.js App Router v2 binding', () => { }) it('resolves slug-managed server entries with request selections and tracking IDs', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const getEntry = rs.fn(async () => await Promise.resolve(createEntry('unused'))) const getEntries = rs.fn( async () => await Promise.resolve(createEntryCollection([optimizedEntry])), @@ -1493,7 +1343,7 @@ describe('Next.js App Router v2 binding', () => { }) it('makes request handoff consent and selections available during server render', async () => { - mockRequestPage({ accepted: true, data: optimizationData }) + mockRequestPreview({ accepted: true, data: optimizationData }) const { OptimizationRoot, createRequestHandoff } = bindNextjsAppRouterServerOptimization({ ...sdkConfig, consent: { server: true, clientDefaults: { consent: false, persistenceConsent: false } }, @@ -1531,7 +1381,6 @@ describe('Next.js App Router v2 binding', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) diff --git a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx index 0c2805f0f..894e9df00 100644 --- a/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/app-router-server.tsx @@ -290,7 +290,6 @@ export function bindNextjsAppRouterServerOptimization( cache: { scope: 'static' }, entries, hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) } diff --git a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts index 4aeb73544..1764e54ea 100644 --- a/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts +++ b/packages/web/frameworks/nextjs-sdk/src/bound-component-types.ts @@ -1,3 +1,4 @@ +import type { InitialExperienceCommandInput } from '@contentful/optimization-node/core-sdk' import type { BeforeInitialPageOptions, OptimizationAnalyticsRootProps, @@ -84,6 +85,14 @@ export type NextjsAppRouterRequestHydration = export interface NextjsAppRouterRequestConfig { readonly hydration?: NextjsAppRouterRequestHydration + readonly initialExperienceEvents?: + | readonly InitialExperienceCommandInput[] + | (( + context: NextjsAppRouterRequestContext, + ) => + | readonly InitialExperienceCommandInput[] + | Promise) + /** @deprecated This compatibility field is no longer read. */ readonly trustedRequestHandoff?: true } @@ -175,10 +184,8 @@ export type NextjsAppRouterRequestOptimizationProviderProps = Omit< 'handoff' | 'hydration' > -export type NextjsAppRouterRequestAutoPageTrackerProps = Omit< - NextAppAutoPageTrackerProps, - 'initialPageEvent' -> +/** @deprecated `initialPageEvent` remains accepted but is inert. */ +export type NextjsAppRouterRequestAutoPageTrackerProps = NextAppAutoPageTrackerProps export interface NextjsAppRouterRequestOptimization { readonly OptimizationRoot: ( diff --git a/packages/web/frameworks/nextjs-sdk/src/edge.test.ts b/packages/web/frameworks/nextjs-sdk/src/edge.test.ts index bc27c398b..c817c0395 100644 --- a/packages/web/frameworks/nextjs-sdk/src/edge.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/edge.test.ts @@ -1,12 +1,15 @@ +import { EventBuilder } from '@contentful/optimization-node/core-sdk' import { CoreStateless, type CoreStatelessRequest, type OptimizationData, } from '@contentful/optimization-react-web/core-sdk' import { NextRequest } from 'next/server' -import * as edgeExports from './edge' - -const { configureNextjsEdgeOptimization } = edgeExports +import { configureNextjsEdgeOptimization } from './edge' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const SDK_CONFIG = { spaceId: 'key_123', @@ -46,38 +49,39 @@ afterEach(() => { }) function mockEdgeRequestPage( - result: Awaited> = { + result: Awaited> = { accepted: true, data: OPTIMIZATION_DATA, + experience: [replayEventBuilder.buildPageView({})], + insights: [], }, ): { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly previewInitialExperience: ReturnType< + typeof rs.fn + > readonly runtimes: CoreStateless[] } { const originalForRequest = CoreStateless.prototype.forRequest - const page = rs.fn(async () => await Promise.resolve(result)) + const previewInitialExperience = rs.fn( + async () => await Promise.resolve(result), + ) const forRequest = rs.spyOn(CoreStateless.prototype, 'forRequest') const runtimes: CoreStateless[] = [] forRequest.mockImplementation(function mockForRequest(this: CoreStateless, options) { runtimes.push(this) const requestOptimization = originalForRequest.call(this, options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(requestOptimization, 'previewInitialExperience').mockImplementation( + previewInitialExperience, + ) return requestOptimization }) - return { forRequest, page, runtimes } + return { forRequest, previewInitialExperience, runtimes } } describe('Next.js Edge runtime helpers', () => { - it('exports the Edge configure helper without the removed create helper name', () => { - expect(edgeExports.configureNextjsEdgeOptimization).toBeTypeOf('function') - expect(edgeExports.createPublicPermutationHandoff).toBeTypeOf('function') - expect(edgeExports.createPublicPermutationCacheMetadata).toBeTypeOf('function') - expect(edgeExports).not.toHaveProperty('createNextjsEdgeOptimization') - }) - it('uses the build-time package version for Edge event library metadata', async () => { const { runtimes } = mockEdgeRequestPage() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization(SDK_CONFIG) @@ -94,8 +98,17 @@ describe('Next.js Edge runtime helpers', () => { }) }) - it('builds request handoff from a Web Request, reads cookies, and persists a Response cookie', async () => { - const { forRequest, page } = mockEdgeRequestPage() + it('builds a preview handoff from a Web Request without persisting identity', async () => { + const { forRequest, previewInitialExperience } = mockEdgeRequestPage() + const events = [ + { event: 'initial-preview', properties: { source: 'edge' }, type: 'track' }, + ] as const + const resolveInitialExperienceEvents = rs.fn((context) => { + expect(context).toMatchObject({ + url: 'https://example.com/products?tab=featured', + }) + return events + }) const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: { events: true, persistence: true } }, @@ -111,13 +124,19 @@ describe('Next.js Edge runtime helpers', () => { const result = await createEdgeRequestHandoff({ cache: { scope: 'private-request' }, hydration: 'preserve-server', + initialExperienceEvents: resolveInitialExperienceEvents, pagePayload: { properties: { route: '/products' } }, request, }) - expect(result.handoff.initialPageEvent).toBe('skip') + expect(result.handoff.replay).toMatchObject({ + routeKey: '/products?tab=featured', + }) expect(result.handoff.state?.profile?.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) + expect(previewInitialExperience).toHaveBeenCalledWith({ + events, + page: { properties: { route: '/products' } }, + }) expect(forRequest).toHaveBeenCalledWith( expect.objectContaining({ consent: { events: true, persistence: true }, @@ -142,11 +161,7 @@ describe('Next.js Edge runtime helpers', () => { }) result.persist(response) - expect(response.headers.get('set-cookie')).toContain( - 'ctfl-opt-aid=f0837d7dc6344c36a3a0a06c4cde754b', - ) - expect(response.headers.get('set-cookie')).toContain('Path=/') - expect(response.headers.get('set-cookie')).toContain('SameSite=Lax') + expect(response.headers.get('set-cookie')).toBeNull() }) it('reads anonymous ID from framework cookie snapshots', async () => { @@ -173,10 +188,36 @@ describe('Next.js Edge runtime helpers', () => { ) }) + it('falls back to browser-owned tracking when the initial-event resolver rejects', async () => { + const initialEventError = new Error('Initial events unavailable') + const { forRequest, previewInitialExperience } = mockEdgeRequestPage() + const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization(SDK_CONFIG) + + const result = await createEdgeRequestHandoff({ + hydration: 'client-only-hidden-until-ready', + initialExperienceEvents: async () => await Promise.reject(initialEventError), + pagePayload: {}, + request: new Request('https://example.com/products'), + }) + + expect(forRequest).toHaveBeenCalledTimes(2) + expect(previewInitialExperience).toHaveBeenCalledTimes(0) + expect(result).toMatchObject({ + data: undefined, + handoff: { + cache: { scope: 'private-request' }, + hydration: 'client-only-hidden-until-ready', + }, + pageResult: { accepted: false }, + }) + expect(result.handoff).not.toHaveProperty('state') + expect(result.handoff).not.toHaveProperty('replay') + }) + it.each([{ key: 'segment-a', scope: 'public-permutation' }, { scope: 'static' }] as const)( 'rejects $scope request handoff cache metadata before request evaluation', async (cache) => { - const { forRequest, page } = mockEdgeRequestPage() + const { forRequest, previewInitialExperience } = mockEdgeRequestPage() const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: true }, @@ -194,17 +235,25 @@ describe('Next.js Edge runtime helpers', () => { 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', ) expect(forRequest).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() + expect(previewInitialExperience).not.toHaveBeenCalled() }, ) it.each([ - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], + [ + 'accepted', + { + accepted: true, + data: OPTIMIZATION_DATA, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }, + ], + ['blocked', { accepted: false }], ] as const)( - 'sets initialPageEvent from page acceptance for %s', - async (_label, pageResult, expectedInitialPageEvent) => { - mockEdgeRequestPage(pageResult) + 'creates replay handoff from preview result for %s', + async (_label, previewResult) => { + mockEdgeRequestPage(previewResult) const { createEdgeRequestHandoff } = configureNextjsEdgeOptimization({ ...SDK_CONFIG, consent: { server: true }, @@ -216,7 +265,10 @@ describe('Next.js Edge runtime helpers', () => { request: new Request('https://example.com/products'), }) - expect(result.handoff.initialPageEvent).toBe(expectedInitialPageEvent) + expect(result.pageResult.accepted).toBe(previewResult.accepted) + expect(result.handoff.replay).toEqual( + previewResult.accepted ? expect.objectContaining({ routeKey: '/products' }) : undefined, + ) }, ) @@ -230,14 +282,12 @@ describe('Next.js Edge runtime helpers', () => { const handoff = createHandoffFromSelections({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const permutationHandoff = createPublicPermutationHandoff({ cacheVersion: 'version 1', entryIds: ['4ib0hsHWoSOnCVdDkizE8d'], hydration: 'preserve-server', - initialPageEvent: 'emit', locale: 'en-US', permutationKey: 'segment a', selectedOptimizations: [], @@ -252,7 +302,6 @@ describe('Next.js Edge runtime helpers', () => { expect(handoff).toEqual({ cache: { scope: 'public-permutation', key: 'segment-a' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, }) expect(permutationHandoff).toMatchObject({ @@ -261,7 +310,6 @@ describe('Next.js Edge runtime helpers', () => { scope: 'public-permutation', }, hydration: 'preserve-server', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, }) expect(permutationHandoff.cache.tags).toBeUndefined() diff --git a/packages/web/frameworks/nextjs-sdk/src/edge.ts b/packages/web/frameworks/nextjs-sdk/src/edge.ts index 9083d8625..319029026 100644 --- a/packages/web/frameworks/nextjs-sdk/src/edge.ts +++ b/packages/web/frameworks/nextjs-sdk/src/edge.ts @@ -1,8 +1,9 @@ import type { App } from '@contentful/optimization-react-web/api-schemas' import { - assertOptimizationCacheSafety, CoreStateless, createPageContextFromUrl, + createRequestHandoffFromData, + createRequestHandoffFromPreview, type CoreStatelessConfig, type CoreStatelessInsightsOptions, type CoreStatelessRequest, @@ -10,10 +11,10 @@ import { type CoreStatelessRequestOptions, type EventEmissionResult, type EventType, + type InitialExperienceCommandInput, type ManagedEntryHandoff, type OptimizationCacheMetadata, type OptimizationData, - type OptimizationHandoff, type PageViewBuilderArgs, type PartialProfile, type PrivateRequestOptimizationCacheMetadata, @@ -29,11 +30,8 @@ import type { import { OPTIMIZATION_NEXTJS_SDK_VERSION } from './constants' import { createCookieReaderFromHeader, - createNextjsAnonymousIdSetCookieHeader, DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, isNextjsCookieReader, - type NextjsAnonymousIdCookieOptions, - type PersistNextjsAnonymousIdOptions, } from './cookies' import { addBrowserHandoffMetadata, @@ -44,13 +42,16 @@ import { type BrowserOptimizationHandoff, type OptimizationHydrationMode, } from './handoff' +import { + createPrivateRequestPreviewFallbackHandoff, + resolveRequestPreview, +} from './request-preview-fallback' const DEFAULT_EDGE_ALLOWED_EVENT_TYPES: EventType[] = ['identify', 'page'] const EDGE_SDK_NAME = '@contentful/optimization-nextjs' const EMPTY_COOKIE_READER = { get: () => undefined, } -const SECONDS_IN_DAY = 86_400 export type NextjsEdgeRequest = Request | NextjsEdgeRequestSnapshot @@ -71,13 +72,21 @@ export interface NextjsEdgeOptimizationConfig extends Omit + | readonly InitialExperienceCommandInput[] + | Promise) readonly locale?: string readonly pagePayload: PageViewBuilderArgs readonly profile?: PartialProfile @@ -88,6 +97,7 @@ export interface NextjsEdgeRequestHandoff { readonly data: OptimizationData | undefined readonly handoff: BrowserOptimizationHandoff readonly pageResult: EventEmissionResult + /** @deprecated Preview identity is not persisted by Edge request handoffs. */ readonly persist: (response: Response) => void readonly requestOptimization: CoreStatelessRequest } @@ -113,47 +123,62 @@ export function configureNextjsEdgeOptimization( assertEdgeRequestHandoffCacheMetadata(cache) const request = createEdgeRequestSnapshot(options.request) - const consent = await resolveServerConsent(config.consent?.server, { - cookies: request.cookies ?? EMPTY_COOKIE_READER, - headers: request.headers, - }) - const anonymousId = readNextjsAnonymousId(request.cookies, options.anonymousIdCookieName) - const profile = options.profile ?? (anonymousId === undefined ? undefined : { id: anonymousId }) - const requestOptimization = sdk.forRequest({ - consent, - eventContext: createEdgeRequestContext(request, options, options.locale ?? config.locale), - experienceOptions: options.experienceOptions, - insightsOptions: options.insightsOptions, - locale: options.locale ?? config.locale, - profile, - }) - const pageResult = await requestOptimization.page(options.pagePayload) - const { data } = pageResult - const handoff = addBrowserHandoffMetadata( - createEdgeRequestOptimizationHandoff({ - cache, - data, - entries: options.entries, - }), - { - hydration: options.hydration, - initialPageEvent: pageResult.accepted ? 'skip' : 'emit', + const result = await resolveRequestPreview( + async () => { + const consent = await resolveServerConsent(config.consent?.server, { + cookies: request.cookies ?? EMPTY_COOKIE_READER, + headers: request.headers, + }) + const profile = resolveEdgeProfile(options, request.cookies) + const requestOptimization = sdk.forRequest({ + consent, + eventContext: createEdgeRequestContext(request, options, options.locale ?? config.locale), + experienceOptions: options.experienceOptions, + insightsOptions: options.insightsOptions, + locale: options.locale ?? config.locale, + profile, + }) + const initialExperienceEvents = await resolveInitialExperienceEvents( + options.initialExperienceEvents, + request, + ) + const preview = await requestOptimization.previewInitialExperience({ + ...(initialExperienceEvents === undefined ? {} : { events: initialExperienceEvents }), + page: options.pagePayload, + }) + const data = preview.accepted ? preview.data : undefined + const pageResult: EventEmissionResult = preview.accepted + ? { accepted: true, data: preview.data } + : { accepted: false } + const handoff = createEdgeRequestHandoffFromPreview({ cache, options, preview, request }) + + return { + data, + handoff, + pageResult, + persist: (_response: Response) => undefined, + requestOptimization, + } }, + () => ({ + data: undefined, + handoff: createPrivateRequestPreviewFallbackHandoff({ + entries: options.entries, + hydration: options.hydration, + }), + pageResult: { accepted: false }, + persist: (_response: Response) => undefined, + requestOptimization: sdk.forRequest({ + consent: false, + eventContext: options.eventContext, + experienceOptions: options.experienceOptions, + insightsOptions: options.insightsOptions, + locale: options.locale ?? config.locale, + }), + }), ) - return { - data, - handoff, - pageResult, - persist: (response) => { - persistEdgeAnonymousId(response, requestOptimization, data, { - anonymousIdCookieName: options.anonymousIdCookieName, - cookieOptions: options.cookieOptions ?? toConfigCookieOptions(config.cookie), - deleteWhenProfileCannotPersist: options.deleteWhenProfileCannotPersist, - }) - }, - requestOptimization, - } + return result.value } return { @@ -215,6 +240,45 @@ function readNextjsAnonymousId( return value && value.length > 0 ? value : undefined } +function resolveEdgeProfile( + options: NextjsEdgeRequestHandoffOptions, + cookies: NextjsCookieReader | undefined, +): PartialProfile | undefined { + const anonymousId = readNextjsAnonymousId(cookies, options.anonymousIdCookieName) + return options.profile ?? (anonymousId === undefined ? undefined : { id: anonymousId }) +} + +async function resolveInitialExperienceEvents( + input: NextjsEdgeRequestHandoffOptions['initialExperienceEvents'], + request: NextjsEdgeRequestSnapshot, +): Promise { + if (typeof input === 'function') return await input(request) + return input +} + +function createEdgeRequestHandoffFromPreview({ + cache, + options, + preview, + request, +}: { + readonly cache: PrivateRequestOptimizationCacheMetadata + readonly options: NextjsEdgeRequestHandoffOptions + readonly preview: Awaited> + readonly request: NextjsEdgeRequestSnapshot +}): BrowserOptimizationHandoff { + const handoff = preview.accepted + ? createRequestHandoffFromPreview({ + cache, + entries: options.entries, + preview, + routeKey: createEdgeRequestRouteKey(request.url), + }) + : createRequestHandoffFromData({ cache, entries: options.entries }) + + return addBrowserHandoffMetadata(handoff, { hydration: options.hydration }) +} + function createEdgeRequestContext( request: NextjsEdgeRequestSnapshot, options: NextjsEdgeRequestHandoffOptions, @@ -238,28 +302,9 @@ function mergeEdgeRequestPage( return eventPage === undefined ? requestPage : { ...requestPage, ...eventPage } } -function createEdgeRequestOptimizationHandoff(input: { - readonly cache?: PrivateRequestOptimizationCacheMetadata - readonly data?: OptimizationData - readonly entries?: readonly ManagedEntryHandoff[] -}): OptimizationHandoff { - const handoff: OptimizationHandoff = { - cache: input.cache ?? { scope: 'private-request' }, - ...(input.entries === undefined ? {} : { entries: input.entries }), - ...(input.data === undefined - ? {} - : { - state: { - changes: input.data.changes, - profile: input.data.profile, - selectedOptimizations: input.data.selectedOptimizations, - }, - }), - } - - assertOptimizationCacheSafety(handoff) - - return handoff +function createEdgeRequestRouteKey(requestUrl: string): string { + const url = new URL(requestUrl) + return `${url.pathname}${url.search}` } function assertEdgeRequestHandoffCacheMetadata( @@ -281,31 +326,6 @@ function resolveServerConsent( return typeof consent === 'function' ? consent(context) : consent } -function persistEdgeAnonymousId( - response: Response, - requestOptimization: CoreStatelessRequest, - data: OptimizationData | undefined, - options: PersistNextjsAnonymousIdOptions, -): void { - const setCookie = createNextjsAnonymousIdSetCookieHeader(requestOptimization, data, options) - if (setCookie === undefined) return - - response.headers.append('set-cookie', setCookie) -} - -function toConfigCookieOptions( - cookie: NextjsOptimizationCookieConfig | undefined, -): NextjsAnonymousIdCookieOptions | undefined { - if (cookie === undefined) return undefined - - return { - ...(cookie.domain ? { domain: cookie.domain } : {}), - ...(typeof cookie.expires === 'number' && Number.isFinite(cookie.expires) - ? { maxAge: Math.trunc(cookie.expires * SECONDS_IN_DAY) } - : {}), - } -} - export { createHandoffFromSelections, createOptimizationCacheKey, diff --git a/packages/web/frameworks/nextjs-sdk/src/handoff.ts b/packages/web/frameworks/nextjs-sdk/src/handoff.ts index 534ed3475..4a36b4dd9 100644 --- a/packages/web/frameworks/nextjs-sdk/src/handoff.ts +++ b/packages/web/frameworks/nextjs-sdk/src/handoff.ts @@ -32,7 +32,8 @@ export type NextjsInitialPageEvent = 'emit' | 'skip' export interface NextjsBrowserHandoffMetadata { readonly hydration: OptimizationHydrationMode - readonly initialPageEvent: NextjsInitialPageEvent + /** @deprecated Initial page emission is coordinated by the React SDK. */ + readonly initialPageEvent?: NextjsInitialPageEvent } export interface NextjsCreateHandoffFromSelectionsOptions extends NextjsBrowserHandoffMetadata { @@ -86,7 +87,6 @@ export function addBrowserHandoffMetadata( const browserHandoff: BrowserOptimizationHandoff = { ...handoff, hydration: metadata.hydration, - initialPageEvent: metadata.initialPageEvent, } return browserHandoff @@ -106,7 +106,7 @@ export function createHandoffFromSelections( export function createHandoffFromSelections( input: NextjsCreateHandoffFromSelectionsOptions, ): BrowserOptimizationHandoff { - const { hydration, initialPageEvent, selectedOptimizations, changes, entries, cache } = input + const { hydration, selectedOptimizations, changes, entries, cache } = input if (cache.scope === 'public-permutation') validateNextjsPublicPermutationCacheTags(cache.tags) const handoff = createCoreHandoffFromSelections({ @@ -116,7 +116,7 @@ export function createHandoffFromSelections( selectedOptimizations, }) - return addBrowserHandoffMetadata(handoff, { hydration, initialPageEvent }) + return addBrowserHandoffMetadata(handoff, { hydration }) } export function createPublicPermutationHandoff( @@ -138,7 +138,6 @@ export function createPublicPermutationHandoff( changes: input.changes, entries: input.entries, hydration: input.hydration, - initialPageEvent: input.initialPageEvent, selectedOptimizations: input.selectedOptimizations, }) } diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts index 9f9c3fbd2..6e85b0ff8 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.test.ts @@ -1,18 +1,22 @@ -import ContentfulOptimizationRuntime from '@contentful/optimization-node' +import { EventBuilder } from '@contentful/optimization-node/core-sdk' import type { Entry } from 'contentful' import type { GetServerSidePropsContext } from 'next' import { IncomingMessage, ServerResponse } from 'node:http' import { Socket } from 'node:net' -import * as pagesRouterServerExports from './pages-router-server' +import { + bindNextjsPagesRouterServerOptimization, + createNextjsPagesRouterRequestHandoff, +} from './pages-router-server' import { configureNextjsServerOptimization, type ContentfulOptimization, type CoreStatelessRequest, type OptimizationData, } from './server' - -const { bindNextjsPagesRouterServerOptimization, createNextjsPagesRouterRequestHandoff } = - pagesRouterServerExports +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const SDK_CONFIG = { spaceId: 'key_123', @@ -53,14 +57,22 @@ afterEach(() => { interface CreatedSdk { readonly forRequest: ReturnType - readonly page: ReturnType> + readonly previewInitialExperience: ReturnType< + typeof rs.fn + > readonly sdk: ContentfulOptimization } type NextjsOptimizationConfig = Parameters[0] function createSdk( - page = rs.fn( - async () => await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }), + previewInitialExperience = rs.fn( + async () => + await Promise.resolve({ + accepted: true, + data: OPTIMIZATION_DATA, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }), ), config: NextjsOptimizationConfig = SDK_CONFIG, ): CreatedSdk { @@ -70,33 +82,13 @@ function createSdk( forRequest.mockImplementation((options) => { const requestOptimization = originalForRequest(options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) - return requestOptimization - }) - - return { forRequest, page, sdk } -} - -function mockPrototypeRequestPage(): { - readonly forRequest: ReturnType - readonly page: ReturnType> -} { - const originalForRequest = ContentfulOptimizationRuntime.prototype.forRequest - const page = rs.fn( - async () => await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }), - ) - const forRequest = rs.spyOn(ContentfulOptimizationRuntime.prototype, 'forRequest') - - forRequest.mockImplementation(function mockForRequest( - this: ContentfulOptimizationRuntime, - options, - ) { - const requestOptimization = originalForRequest.call(this, options) - rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(requestOptimization, 'previewInitialExperience').mockImplementation( + previewInitialExperience, + ) return requestOptimization }) - return { forRequest, page } + return { forRequest, previewInitialExperience, sdk } } function createEntry(id: string): Entry { @@ -167,51 +159,31 @@ function createContext({ } describe('Next.js Pages Router server handoff helpers', () => { - it('exports the server binding helper without the removed Pages Router create* name', () => { - expect(pagesRouterServerExports.bindNextjsPagesRouterServerOptimization).toBeTypeOf('function') - expect(pagesRouterServerExports.createPublicPermutationCacheMetadata).toBeTypeOf('function') - expect(pagesRouterServerExports.createPublicPermutationHandoff).toBeTypeOf('function') - expect(pagesRouterServerExports.resolveEntriesForSelections).toBeTypeOf('function') - expect(pagesRouterServerExports).not.toHaveProperty('createNextjsPagesRouterOptimization') - }) - - it('creates a config-bound request handoff helper', async () => { - const { forRequest } = mockPrototypeRequestPage() - const resolveConsent = rs.fn( - (context: { readonly cookies: { get: (name: string) => unknown } }) => - context.cookies.get('consent') ? { events: true, persistence: true } : false, - ) + it('falls back to a profileless handoff when the consent resolver rejects', async () => { + const consentError = new Error('Consent service unavailable') const { createRequestHandoff } = bindNextjsPagesRouterServerOptimization({ ...SDK_CONFIG, - consent: { server: resolveConsent }, - cookie: { domain: 'example.test', expires: 1 }, - locale: 'de-DE', + consent: { server: async () => await Promise.reject(consentError) }, }) - const context = createContext({ cookies: { consent: 'yes' } }) - const handoff = await createRequestHandoff(context, { + const handoff = await createRequestHandoff(createContext(), { + entries: [{ baselineEntry: createEntry('baseline-entry'), entryId: 'baseline-entry' }], hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, + pagePayload: {}, }) - expect(resolveConsent).toHaveBeenCalled() - expect(forRequest).toHaveBeenCalledWith( - expect.objectContaining({ - consent: { events: true, persistence: true }, - locale: 'de-DE', - }), - ) - expect(handoff.initialPageEvent).toBe('skip') expect(handoff).toMatchObject({ - defaults: { consent: true, persistenceConsent: true }, + cache: { scope: 'private-request' }, + defaults: { consent: false, persistenceConsent: false }, + entries: [{ entryId: 'baseline-entry' }], + hydration: 'preserve-server', }) - expect(context.res.getHeader('Set-Cookie')).toEqual( - expect.stringContaining('Domain=example.test'), - ) + expect(handoff).not.toHaveProperty('state') + expect(handoff).not.toHaveProperty('replay') }) - it('builds request context from getServerSideProps context and calls page', async () => { - const { forRequest, page, sdk } = createSdk() + it('maps getServerSideProps URL and context into the request preview', async () => { + const { forRequest, previewInitialExperience, sdk } = createSdk() const result = await createNextjsPagesRouterRequestHandoff( sdk, @@ -231,7 +203,6 @@ describe('Next.js Pages Router server handoff helpers', () => { }, ) - expect(result.handoff.initialPageEvent).toBe('skip') expect(result.handoff.state?.profile?.id).toBe('f0837d7dc6344c36a3a0a06c4cde754b') expect(forRequest).toHaveBeenCalledWith( expect.objectContaining({ @@ -250,29 +221,11 @@ describe('Next.js Pages Router server handoff helpers', () => { locale: 'de-DE', }), ) - expect(page).toHaveBeenCalledWith({ properties: { route: '/products' } }) + expect(previewInitialExperience).toHaveBeenCalledWith({ + page: { properties: { route: '/products' } }, + }) }) - it.each([ - ['accepted without data', { accepted: true }, 'skip'], - ['blocked', { accepted: false }, 'emit'], - ] as const)( - 'sets initialPageEvent from page acceptance for %s', - async (_label, pageResult, expectedInitialPageEvent) => { - const { sdk } = createSdk( - rs.fn(async () => await Promise.resolve(pageResult)), - ) - - const result = await createNextjsPagesRouterRequestHandoff(sdk, createContext(), { - consent: true, - hydration: 'preserve-server', - pagePayload: { properties: { route: '/products' } }, - }) - - expect(result.handoff.initialPageEvent).toBe(expectedInitialPageEvent) - }, - ) - it('defaults missing object persistence consent to false', async () => { const { sdk } = createSdk() @@ -317,11 +270,18 @@ describe('Next.js Pages Router server handoff helpers', () => { calls.push('fetch') return await Promise.resolve(createEntryCollection([baselineEntry])) }) - const page = rs.fn(async () => { - calls.push('page') - return await Promise.resolve({ accepted: true, data: OPTIMIZATION_DATA }) - }) - const { sdk } = createSdk(page, { + const previewInitialExperience = rs.fn( + async () => { + calls.push('preview') + return await Promise.resolve({ + accepted: true, + data: OPTIMIZATION_DATA, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }) + }, + ) + const { sdk } = createSdk(previewInitialExperience, { ...SDK_CONFIG, contentful: { client: { getEntry, getEntries }, cache: false }, }) @@ -347,7 +307,7 @@ describe('Next.js Pages Router server handoff helpers', () => { ], }) - expect(calls).toEqual(['page', 'fetch']) + expect(calls).toEqual(['preview', 'fetch']) expect(getEntry).not.toHaveBeenCalled() expect(getEntries).toHaveBeenCalledTimes(1) expect(getEntries).toHaveBeenCalledWith({ @@ -385,7 +345,7 @@ describe('Next.js Pages Router server handoff helpers', () => { ]) }) - it('appends Set-Cookie without clobbering existing response cookies', async () => { + it('leaves existing response cookies untouched when preview identity is not persisted', async () => { const { sdk } = createSdk() const context = createContext({ setCookie: ['app-cookie=1; Path=/'] }) @@ -395,9 +355,6 @@ describe('Next.js Pages Router server handoff helpers', () => { pagePayload: {}, }) - expect(context.res.getHeader('Set-Cookie')).toEqual([ - 'app-cookie=1; Path=/', - expect.stringContaining('ctfl-opt-aid=f0837d7dc6344c36a3a0a06c4cde754b'), - ]) + expect(context.res.getHeader('Set-Cookie')).toEqual(['app-cookie=1; Path=/']) }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts index 569a6657a..e7ca7f8b4 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router-server.ts @@ -1,20 +1,18 @@ import { resolveEntriesForSelections } from '@contentful/optimization-react-web/core-sdk' import type { GetServerSidePropsContext } from 'next' import type { IncomingHttpHeaders } from 'node:http' -import { toHandoffDefaults } from './app-router-request-handoff' +import { assertRequestHandoffCacheMetadata, toHandoffDefaults } from './app-router-request-handoff' import type { NextjsOptimizationComponentsConfig, - NextjsOptimizationCookieConfig, NextjsOptimizationServerConsent, NextjsOptimizationServerConsentResolver, } from './bound-component-types' -import { - createCookieReaderFromHeader, - createCookieReaderFromRecord, - createNextjsAnonymousIdSetCookieHeader, - type PersistNextjsAnonymousIdOptions, -} from './cookies' +import { createCookieReaderFromHeader, createCookieReaderFromRecord } from './cookies' import type { BrowserOptimizationHandoff } from './handoff' +import { + createPrivateRequestPreviewFallbackHandoff, + resolveRequestPreview, +} from './request-preview-fallback' import { configureNextjsServerOptimization, createNextjsRequestHandoff, @@ -29,7 +27,6 @@ import { type OptimizationNodeConfig, } from './server' -const SECONDS_IN_DAY = 86_400 const EMPTY_COOKIE_READER = { get: () => undefined, } @@ -53,11 +50,10 @@ export { resolveEntriesForSelections } export type NextjsPagesRouterRequestHandoffOptions = Omit< NextjsRequestHandoffOptions, 'consent' | 'cookies' | 'headers' | 'locale' | 'request' -> & - PersistNextjsAnonymousIdOptions & { - readonly locale?: string - readonly prefetchManagedEntries?: readonly ManagedEntryDescriptor[] - } +> & { + readonly locale?: string + readonly prefetchManagedEntries?: readonly ManagedEntryDescriptor[] +} export interface NextjsPagesRouterOptimization { readonly createRequestHandoff: ( @@ -73,15 +69,29 @@ export function bindNextjsPagesRouterServerOptimization( return { createRequestHandoff: async (context, options) => { - const consent = await resolveServerConsent(config.consent?.server, context) - const { handoff } = await createNextjsPagesRouterRequestHandoff(sdk, context, { - ...options, - consent, - cookieOptions: options.cookieOptions ?? toAnonymousIdCookieOptions(config.cookie), - locale: options.locale ?? config.locale ?? context.locale, - }) - - return handoff + const cache = options.cache ?? { scope: 'private-request' } + assertRequestHandoffCacheMetadata(cache) + const result = await resolveRequestPreview( + async () => { + const consent = await resolveServerConsent(config.consent?.server, context) + const { handoff } = await createNextjsPagesRouterRequestHandoff(sdk, context, { + ...options, + consent, + locale: options.locale ?? config.locale ?? context.locale, + }) + return handoff + }, + () => + addRequestDefaultsToHandoff( + createPrivateRequestPreviewFallbackHandoff({ + entries: options.entries, + hydration: options.hydration, + }), + false, + ), + ) + + return result.value }, } } @@ -93,13 +103,7 @@ export async function createNextjsPagesRouterRequestHandoff( readonly consent: CoreStatelessRequestConsent }, ): Promise { - const { - cookieOptions, - deleteWhenProfileCannotPersist, - locale, - prefetchManagedEntries, - ...requestOptions - } = options + const { locale, prefetchManagedEntries, ...requestOptions } = options const request = createPagesRouterRequest(context) const result = await createNextjsRequestHandoff(sdk, { ...requestOptions, @@ -107,17 +111,6 @@ export async function createNextjsPagesRouterRequestHandoff( request, }) const requestHandoff = addRequestDefaultsToHandoff(result.handoff, requestOptions.consent) - const setCookie = createNextjsAnonymousIdSetCookieHeader( - result.requestOptimization, - result.data, - { - anonymousIdCookieName: requestOptions.anonymousIdCookieName, - cookieOptions, - deleteWhenProfileCannotPersist, - }, - ) - if (setCookie !== undefined) appendSetCookie(context, setCookie) - if (prefetchManagedEntries === undefined) { return { ...result, @@ -231,20 +224,6 @@ function getCookieHeader(value: string | string[] | undefined): string | undefin return Array.isArray(value) ? value.join('; ') : value } -function appendSetCookie(context: GetServerSidePropsContext, setCookie: string): void { - const existingSetCookie = context.res.getHeader('Set-Cookie') - - if (existingSetCookie === undefined) { - context.res.setHeader('Set-Cookie', setCookie) - return - } - - context.res.setHeader('Set-Cookie', [ - ...(Array.isArray(existingSetCookie) ? existingSetCookie : [existingSetCookie]).map(String), - setCookie, - ]) -} - function resolveServerConsent( consent: NextjsOptimizationServerConsent | NextjsOptimizationServerConsentResolver | undefined, context: GetServerSidePropsContext, @@ -273,18 +252,3 @@ function toServerOptimizationConfig( return serverConfig as OptimizationNodeConfig } - -function toAnonymousIdCookieOptions( - cookie: NextjsOptimizationCookieConfig | undefined, -): PersistNextjsAnonymousIdOptions['cookieOptions'] { - if (cookie === undefined) return undefined - - const cookieOptions: NonNullable = { - ...(cookie.domain ? { domain: cookie.domain } : {}), - ...(typeof cookie.expires === 'number' && Number.isFinite(cookie.expires) - ? { maxAge: Math.trunc(cookie.expires * SECONDS_IN_DAY) } - : {}), - } - - return Object.keys(cookieOptions).length === 0 ? undefined : cookieOptions -} diff --git a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx index d3ffdcaf7..7891f77dc 100644 --- a/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/pages-router.test.tsx @@ -63,7 +63,6 @@ describe('Next.js Pages Router client components', () => { { baselineEntry: createEntry('4ib0hsHWoSOnCVdDkizE8d'), entryId: '4ib0hsHWoSOnCVdDkizE8d' }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -124,7 +123,6 @@ describe('Next.js Pages Router client components', () => { ...components.createHandoffFromSelections({ cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', selectedOptimizations: [], }), defaults: { consent: true }, @@ -133,7 +131,6 @@ describe('Next.js Pages Router client components', () => { ...components.createHandoffFromSelections({ cache: { scope: 'private-request' }, hydration: 'analytics-only', - initialPageEvent: 'skip', selectedOptimizations: [], }), defaults: { consent: true }, @@ -188,7 +185,6 @@ describe('Next.js Pages Router client components', () => { }, ], hydration: 'preserve-server', - initialPageEvent: 'emit', selectedOptimizations: [], }) @@ -226,7 +222,6 @@ describe('Next.js Pages Router client components', () => { const analyticsHandoff = components.createHandoffFromSelections({ cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', selectedOptimizations: [], }) const root = components.OptimizationRoot({ @@ -247,7 +242,7 @@ describe('Next.js Pages Router client components', () => { expect(components).not.toHaveProperty('beforeInitialPage') }) - it('returns Pages Router v2 helpers only', () => { + it('provides Pages Router v2 helpers', () => { const components = pagesRouter.bindNextjsPagesRouterOptimization(testConfig) expect(components.NextPagesAutoPageTracker).toBe(pagesRouter.NextPagesAutoPageTracker) @@ -256,18 +251,5 @@ describe('Next.js Pages Router client components', () => { expect(components.createOptimizationCacheKey).toBeTypeOf('function') expect(components.createPublicPermutationHandoff).toBeTypeOf('function') expect(components.resolveEntriesForSelections).toBeTypeOf('function') - expect(components).not.toHaveProperty('NextAppAutoPageTracker') - }) - - it('keeps the Pages Router entry scoped to the client binding and pass-through helpers', () => { - expect(Object.keys(pagesRouter).sort()).toEqual([ - 'NextPagesAutoPageTracker', - 'bindNextjsPagesRouterOptimization', - 'createHandoffFromSelections', - 'createOptimizationCacheKey', - 'createPublicPermutationCacheMetadata', - 'createPublicPermutationHandoff', - 'resolveEntriesForSelections', - ]) }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/request-context.ts b/packages/web/frameworks/nextjs-sdk/src/request-context.ts index d8ebe857b..9b1fca7f9 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-context.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-context.ts @@ -1,18 +1,2 @@ export const NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX = 'x-ctfl-opt-' export const NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER = `${NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX}request-url` -export const NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER = `${NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX}server-data` - -export function serializeNextjsOptimizationRequestContext(value: unknown): string { - return encodeURIComponent(JSON.stringify(value)) -} - -export function parseNextjsOptimizationRequestContext(value: string | null): unknown { - if (!value) return undefined - - try { - const parsed: unknown = JSON.parse(decodeURIComponent(value)) - return parsed - } catch (_error) { - return undefined - } -} diff --git a/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts b/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts index c57703e65..d54cad355 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-handler.test.ts @@ -1,404 +1,56 @@ -import { NextFetchEvent as NextFetchEventConstructor } from 'next/dist/server/web/spec-extension/fetch-event.js' -import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server' -import { - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - parseNextjsOptimizationRequestContext, -} from './request-context' -import * as requestHandlerExports from './request-handler' +import { NextRequest, NextResponse } from 'next/server' import { createNextjsOptimizationContextHandler } from './request-handler' -import { - configureNextjsServerOptimization, - type NextjsOptimizationServerConsentResolver, - type OptimizationData, -} from './server' - -type RemovedRequestHandlerPrefix = 'createNextjsOptimization' -type RemovedRequestHandlerSuffix = 'RequestHandler' -type RemovedRequestHandlerExportName = - `${RemovedRequestHandlerPrefix}${RemovedRequestHandlerSuffix}` -type RemovedRequestHandlerExportIsAbsent = - RemovedRequestHandlerExportName extends keyof typeof requestHandlerExports ? false : true - -const removedRequestHandlerExportIsAbsent: RemovedRequestHandlerExportIsAbsent = true -const removedRequestHandlerExportName = ['createNextjsOptimization', 'RequestHandler'].join('') - -const sdkConfig = { - spaceId: 'key_123', - environment: 'main', -} - -const optimizationData: OptimizationData = { - changes: [], - selectedOptimizations: [], - profile: { - id: 'f0837d7dc6344c36a3a0a06c4cde754b', - stableId: 'f0837d7dc6344c36a3a0a06c4cde754b', - random: 1, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'e77eab64-93ca-4f6e-8492-037c1ff67caa', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'https://example.test/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, - }, -} - -function createNextFetchEvent(request: NextRequest): NextFetchEvent { - return new NextFetchEventConstructor({ - context: { waitUntil: rs.fn() }, - page: '/', - request, - }) -} - -afterEach(() => { - rs.restoreAllMocks() -}) +import { configureNextjsServerOptimization } from './server' describe('createNextjsOptimizationContextHandler', () => { - it('exports only the context handler and not the removed page-producing request handler', () => { - expect(removedRequestHandlerExportIsAbsent).toBe(true) - expect(requestHandlerExports.createNextjsOptimizationContextHandler).toBeTypeOf('function') - expect(removedRequestHandlerExportName in requestHandlerExports).toBe(false) - }) - - it('forwards sanitized request URL context without performing SDK work', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const requestHandler = createNextjsOptimizationContextHandler() - const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - 'x-ctfl-opt-request-url': 'https://attacker.test/forged', - 'x-ctfl-opt-extra': 'forged-extra', - }, - }) - - const response = await requestHandler(request) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - - expect(response).toBeInstanceOf(Response) - expect(forwardedHeaders?.get('user-agent')).toBe('test-agent') - expect(forwardedHeaders?.get('x-ctfl-opt-extra')).toBeNull() - expect(forwardedHeaders?.get('x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', - ) - expect(Array.from(forwardedHeaders?.keys() ?? [])).toContain('user-agent') - expect(Array.from(forwardedHeaders?.keys() ?? [])).toContain('x-ctfl-opt-request-url') - expect(Array.from(forwardedHeaders?.keys() ?? [])).not.toContain('x-ctfl-opt-extra') - }) - - it('applies forwarded request context to an existing response while preserving response chain state', async () => { - const requestHandler = createNextjsOptimizationContextHandler() - const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - }, - }) - const existingRequestHeaders = new Headers(request.headers) - existingRequestHeaders.set('x-existing-request-handler', 'preserved') - existingRequestHeaders.set('x-ctfl-opt-extra', 'stale-sdk-context') - const existingResponse = NextResponse.next({ request: { headers: existingRequestHeaders } }) - existingResponse.headers.set('x-existing-handler', 'preserved') - existingResponse.headers.set( - 'x-middleware-override-headers', - Array.from(existingRequestHeaders.keys()).join(','), - ) - - for (const [name, value] of existingRequestHeaders) { - existingResponse.headers.set(`x-middleware-request-${name}`, value) - } - - const response = await requestHandler(request, existingResponse) - const overrideHeaders = response.headers.get('x-middleware-override-headers')?.split(',') - - expect(response).toBe(existingResponse) - expect(response.headers.get('x-existing-handler')).toBe('preserved') - expect(overrideHeaders).toContain('x-existing-request-handler') - expect(overrideHeaders).toContain('user-agent') - expect(overrideHeaders).toContain('x-ctfl-opt-request-url') - expect(overrideHeaders).not.toContain('x-ctfl-opt-extra') - expect(response.headers.get('x-middleware-request-x-existing-request-handler')).toBe( - 'preserved', - ) - expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() - expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', - ) - }) - - it('applies SDK context and cookie persistence to an existing rewrite response', async () => { - const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, - sdk, - }) - const request = new NextRequest('https://example.com/products?tab=featured', { - headers: { - 'user-agent': 'test-agent', - }, - }) - const rewriteUrl = new URL('/rewritten', request.url) - const existingResponse = new NextResponse(null) - existingResponse.headers.set('x-middleware-rewrite', rewriteUrl.toString()) - - const response = await requestHandler(request, existingResponse) - const context = parseNextjsOptimizationRequestContext( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), - ) - - expect(response).toBe(existingResponse) - expect(response.headers.get('x-middleware-rewrite')).toBe(rewriteUrl.toString()) - expect(response.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( - 'https://example.com/products?tab=featured', - ) - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - }) - - it('preserves an existing JSON response without SDK work or request-header mutation', async () => { - const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockRejectedValue(new Error('terminal response should not call SDK')) - const consent = rs.fn(() => ({ events: true, persistence: true })) - const requestHandler = createNextjsOptimizationContextHandler({ consent, sdk }) - const terminalResponse = NextResponse.json({ error: 'unauthorized' }, { status: 401 }) - - const response = await requestHandler( - new NextRequest('https://example.com/products', { - headers: { - 'x-ctfl-opt-extra': 'stale-sdk-context', - }, - }), - terminalResponse, - ) - - expect(response).toBe(terminalResponse) - expect(consent).not.toHaveBeenCalled() - expect(upsertProfile).not.toHaveBeenCalled() - expect(response.headers.get('x-middleware-override-headers')).toBeNull() - expect(response.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() - expect( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), - ).toBeNull() - expect(response.headers.get('set-cookie')).toBeNull() - }) - - it('ignores the Next middleware/proxy event argument and returns a response', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const requestHandler = createNextjsOptimizationContextHandler() - const request = new NextRequest('https://example.com/products') - - const response = await requestHandler(request, createNextFetchEvent(request)) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - - expect(response).toBeInstanceOf(Response) - expect(forwardedHeaders?.get('x-ctfl-opt-request-url')).toBe('https://example.com/products') - }) - - it('calls Experience once, forwards compact server context, and persists the returned profile ID', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, - locale: 'en-US', + it('forwards a sanitized request context without Experience work, server data, or cookies', async () => { + const sdk = configureNextjsServerOptimization({ environment: 'main', spaceId: 'key_123' }) + const upsertProfile = rs.spyOn(sdk.api.experience, 'upsertProfile') + const handler = createNextjsOptimizationContextHandler({ + consent: true, sdk, }) + const next = rs.spyOn(NextResponse, 'next') - const response = await requestHandler( + const response = await handler( new NextRequest('https://example.com/products?tab=featured', { headers: { 'user-agent': 'test-agent', - [NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER]: 'forged', + 'x-ctfl-opt-request-url': 'https://forged.example/', + 'x-ctfl-opt-server-data': 'forged', }, }), ) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const context = parseNextjsOptimizationRequestContext( - forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null, - ) - - expect(forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER)).not.toBe('forged') - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: undefined }), - expect.objectContaining({ locale: 'en-US' }), - ) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - }) - - it('does not forward oversized OptimizationData in the trusted server context header', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const sdk = configureNextjsServerOptimization(sdkConfig) - const largeTrait = 'x'.repeat(20_000) - const largeData: OptimizationData = { - ...optimizationData, - profile: { - ...optimizationData.profile, - traits: { largeTrait }, - }, - } - rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue(largeData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: true, - sdk, - }) - - await requestHandler(new NextRequest('https://example.com/products')) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const header = forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null - const context = parseNextjsOptimizationRequestContext(header) - - expect(decodeURIComponent(header ?? '')).not.toContain(largeTrait) - expect(header?.length).toBeLessThan(300) - expect(context).toEqual({ - consent: true, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - }) - - it('forwards blocked no-data server results with pageAccepted false', async () => { - const nextSpy = rs.spyOn(NextResponse, 'next') - const sdk = configureNextjsServerOptimization({ ...sdkConfig, allowedEventTypes: [] }) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockRejectedValue(new Error('blocked page should not call Experience')) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: false, - sdk, - }) - - await requestHandler(new NextRequest('https://example.com/products')) - const forwardedHeaders = ( - nextSpy.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined - )?.request?.headers - const context = parseNextjsOptimizationRequestContext( - forwardedHeaders?.get(NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER) ?? null, - ) + const headers = (next.mock.calls[0]?.[0] as { request?: { headers?: Headers } } | undefined) + ?.request?.headers + expect(response).toBeInstanceOf(Response) expect(upsertProfile).not.toHaveBeenCalled() - expect(context).toEqual({ - consent: false, - pageAccepted: false, - }) + expect(headers?.get('x-ctfl-opt-request-url')).toBe('https://example.com/products?tab=featured') + expect(headers?.get('x-ctfl-opt-server-data')).toBeNull() + expect(response.headers.get('set-cookie')).toBeNull() }) - it('binds an incoming anonymous ID before persisting the returned profile ID', async () => { - const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: true }, - sdk, - }) - + it('preserves chained rewrite state and non-SDK request overrides', async () => { + const handler = createNextjsOptimizationContextHandler() const request = new NextRequest('https://example.com/products') - request.cookies.set('ctfl-opt-aid', 'incoming-profile') - - await requestHandler(request) - - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: 'incoming-profile' }), - undefined, - ) - }) - - it('uses forwarded cookie overrides for consent and profile binding on an existing response', async () => { - const sdk = configureNextjsServerOptimization(sdkConfig) - const upsertProfile = rs - .spyOn(sdk.api.experience, 'upsertProfile') - .mockResolvedValue(optimizationData) - const seenConsentCookies: Array = [] - const consent: NextjsOptimizationServerConsentResolver = ({ cookies }) => { - const value = cookies.get('consent')?.value - seenConsentCookies.push(value) - return value === 'yes' ? { events: true, persistence: true } : false - } - const requestHandler = createNextjsOptimizationContextHandler({ consent, sdk }) - const request = new NextRequest('https://example.com/products', { - headers: { - 'user-agent': 'test-agent', - }, + const requestHeaders = new Headers({ 'x-existing': 'preserved', 'x-ctfl-opt-extra': 'forged' }) + const response = NextResponse.next({ + request: { headers: requestHeaders }, }) - request.cookies.set('ctfl-opt-aid', 'a19c3f54d2b84e37a93f6d1c0e5b7284') - request.cookies.set('consent', 'no') - const forwardedCookie = 'ctfl-opt-aid=f0837d7dc6344c36a3a0a06c4cde754b; consent=yes' - const forwardedHeaders = new Headers(request.headers) - forwardedHeaders.set('cookie', forwardedCookie) - const existingResponse = NextResponse.next({ request: { headers: forwardedHeaders } }) - existingResponse.headers.set('x-middleware-override-headers', 'cookie,user-agent') - existingResponse.headers.set('x-middleware-request-cookie', forwardedCookie) - existingResponse.headers.set('x-middleware-request-user-agent', 'test-agent') + response.headers.set('x-middleware-override-headers', 'x-existing,x-ctfl-opt-extra') + response.headers.set('x-middleware-request-x-existing', 'preserved') + response.headers.set('x-middleware-request-x-ctfl-opt-extra', 'forged') + response.headers.set('x-middleware-rewrite', 'https://example.com/rewritten') - const response = await requestHandler(request, existingResponse) - const context = parseNextjsOptimizationRequestContext( - response.headers.get(`x-middleware-request-${NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER}`), - ) + const result = await handler(request, response) - expect(response).toBe(existingResponse) - expect(seenConsentCookies).toEqual(['yes']) - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(upsertProfile).toHaveBeenCalledWith( - expect.objectContaining({ profileId: 'f0837d7dc6344c36a3a0a06c4cde754b' }), - undefined, + expect(result).toBe(response) + expect(result.headers.get('x-middleware-rewrite')).toBe('https://example.com/rewritten') + expect(result.headers.get('x-middleware-request-x-existing')).toBe('preserved') + expect(result.headers.get('x-middleware-request-x-ctfl-opt-extra')).toBeNull() + expect(result.headers.get('x-middleware-request-x-ctfl-opt-request-url')).toBe( + 'https://example.com/products', ) - expect(context).toEqual({ - consent: { events: true, persistence: true }, - pageAccepted: true, - profileId: 'f0837d7dc6344c36a3a0a06c4cde754b', - }) - expect(response.cookies.get('ctfl-opt-aid')?.value).toBe('f0837d7dc6344c36a3a0a06c4cde754b') - }) - - it('clears the profile cookie when persistence is not allowed', async () => { - const sdk = configureNextjsServerOptimization(sdkConfig) - rs.spyOn(sdk.api.experience, 'upsertProfile').mockResolvedValue(optimizationData) - const requestHandler = createNextjsOptimizationContextHandler({ - consent: { events: true, persistence: false }, - sdk, - }) - - const response = await requestHandler(new NextRequest('https://example.com/products')) - - expect(response.headers.get('set-cookie')).toContain('ctfl-opt-aid=') - expect(response.headers.get('set-cookie')).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT') }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/request-handler.ts b/packages/web/frameworks/nextjs-sdk/src/request-handler.ts index c51679968..e7f7a5410 100644 --- a/packages/web/frameworks/nextjs-sdk/src/request-handler.ts +++ b/packages/web/frameworks/nextjs-sdk/src/request-handler.ts @@ -1,5 +1,4 @@ import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server' -import { createCookieReaderFromHeader } from './cookies' import { applyForwardedRequestHeaders, clearForwardedRequestHeaders, @@ -9,20 +8,13 @@ import { import { NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX, NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext, } from './request-context' -import { - getNextjsServerOptimizationData, - persistNextjsAnonymousId, - type ContentfulOptimization, - type CoreStatelessRequest, - type CoreStatelessRequestConsent, - type NextjsAnonymousIdCookieOptions, - type NextjsCookieReader, - type NextjsOptimizationServerConsentResolver, - type OptimizationData, - type PersistNextjsAnonymousIdOptions, +import type { + ContentfulOptimization, + CoreStatelessRequestConsent, + NextjsAnonymousIdCookieOptions, + NextjsOptimizationServerConsentResolver, + PersistNextjsAnonymousIdOptions, } from './server' export type MaybePromise = T | Promise @@ -32,185 +24,69 @@ export type NextjsOptimizationRequestHandler = ( responseOrEvent?: NextResponse | NextFetchEvent, ) => MaybePromise -const EMPTY_COOKIE_READER: NextjsCookieReader = { - get: () => undefined, -} const NEXTJS_MIDDLEWARE_NEXT_HEADER = 'x-middleware-next' const NEXTJS_MIDDLEWARE_REWRITE_HEADER = 'x-middleware-rewrite' const NEXTJS_MIDDLEWARE_REDIRECT_HEADER = 'location' const REDIRECT_STATUS_MIN = 300 const REDIRECT_STATUS_MAX = 400 +/** @deprecated Request personalization now runs in the App Router server request resource. */ export interface NextjsOptimizationContextHandlerOptions extends PersistNextjsAnonymousIdOptions { - readonly consent: CoreStatelessRequestConsent | NextjsOptimizationServerConsentResolver + /** @deprecated Inert compatibility input. */ + readonly consent?: CoreStatelessRequestConsent | NextjsOptimizationServerConsentResolver + /** @deprecated Inert compatibility input. */ readonly cookieOptions?: NextjsAnonymousIdCookieOptions + /** @deprecated Inert compatibility input. */ readonly locale?: string - readonly sdk: ContentfulOptimization + /** @deprecated Inert compatibility input. */ + readonly sdk?: ContentfulOptimization } +/** Forward trusted, sanitized Next request context without SDK work. */ export function createNextjsOptimizationContextHandler( - options?: NextjsOptimizationContextHandlerOptions, + _options?: NextjsOptimizationContextHandlerOptions, ): NextjsOptimizationRequestHandler { - return async (request, responseOrEvent) => { + return (request, responseOrEvent) => { const response = getExistingNextResponse(responseOrEvent) if (response && hasExistingTerminalMiddlewareTarget(response)) return response const requestHeaders = createSanitizedForwardedRequestHeaders(request, response) - const result = - options === undefined - ? undefined - : await getRequestOptimizationData( - request, - requestHeaders, - hasRequestHeaderOverrides(response), - options, - ) - - if (result !== undefined) { - requestHeaders.set( - NEXTJS_OPTIMIZATION_SERVER_DATA_HEADER, - serializeNextjsOptimizationRequestContext({ - consent: result.consent, - pageAccepted: result.pageAccepted, - profileId: result.profileId, - }), - ) - } - - if (!response) { - const nextResponse = NextResponse.next({ request: { headers: requestHeaders } }) - if (options !== undefined && result !== undefined) { - persistNextjsAnonymousId(nextResponse, result.requestOptimization, result.data, options) - } - return nextResponse - } + if (response === undefined) return NextResponse.next({ request: { headers: requestHeaders } }) - applyNextjsOptimizationRequestContext(response, requestHeaders) - if (options !== undefined && result !== undefined) { - persistNextjsAnonymousId(response, result.requestOptimization, result.data, options) - } + clearForwardedRequestHeaders(response) + applyForwardedRequestHeaders(response, requestHeaders) return response } } -interface RequestOptimizationData { - readonly consent: CoreStatelessRequestConsent - readonly data: OptimizationData | undefined - readonly pageAccepted: boolean - readonly profileId: string | undefined - readonly requestOptimization: CoreStatelessRequest -} - -async function getRequestOptimizationData( - request: NextRequest, - headers: Headers, - requestHeaderOverrides: boolean, - options: NextjsOptimizationContextHandlerOptions, -): Promise { - const cookies = createEffectiveRequestCookies(request, headers, requestHeaderOverrides) - const consent = await resolveServerConsent(options.consent, { - cookies, - headers, - }) - const { data, pageResult, requestOptimization } = await getNextjsServerOptimizationData( - options.sdk, - { - anonymousIdCookieName: options.anonymousIdCookieName, - consent, - locale: options.locale, - request: { - cookies, - headers, - url: request.url, - }, - }, - ) - - return { - consent, - data, - pageAccepted: pageResult.accepted, - profileId: data?.profile.id ?? requestOptimization.profile?.id, - requestOptimization, - } -} - -function createEffectiveRequestCookies( - request: NextRequest, - headers: Headers, - requestHeaderOverrides: boolean, -): NextjsCookieReader { - const cookieHeader = headers.get('cookie') - - if (cookieHeader !== null) - return createCookieReaderFromHeader(cookieHeader) ?? EMPTY_COOKIE_READER - if (requestHeaderOverrides) return EMPTY_COOKIE_READER - - return request.cookies -} - -function resolveServerConsent( - consent: CoreStatelessRequestConsent | NextjsOptimizationServerConsentResolver, - context: Parameters[0], -): CoreStatelessRequestConsent | Promise { - return typeof consent === 'function' ? consent(context) : consent -} - function getExistingNextResponse( responseOrEvent: NextResponse | NextFetchEvent | undefined, ): NextResponse | undefined { return responseOrEvent instanceof Response ? responseOrEvent : undefined } -function hasRequestHeaderOverrides(response: NextResponse | undefined): boolean { - if (response === undefined) return false - - return response.headers.has(NEXTJS_MIDDLEWARE_OVERRIDE_HEADERS) -} - function hasExistingTerminalMiddlewareTarget(response: NextResponse): boolean { const { headers } = response - - if ( - response.status >= REDIRECT_STATUS_MIN && - response.status < REDIRECT_STATUS_MAX && - headers.has(NEXTJS_MIDDLEWARE_REDIRECT_HEADER) - ) { - return true - } - return ( - !headers.has(NEXTJS_MIDDLEWARE_NEXT_HEADER) && - !headers.has(NEXTJS_MIDDLEWARE_REWRITE_HEADER) && - !headers.has(NEXTJS_MIDDLEWARE_OVERRIDE_HEADERS) + (response.status >= REDIRECT_STATUS_MIN && + response.status < REDIRECT_STATUS_MAX && + headers.has(NEXTJS_MIDDLEWARE_REDIRECT_HEADER)) || + (!headers.has(NEXTJS_MIDDLEWARE_NEXT_HEADER) && + !headers.has(NEXTJS_MIDDLEWARE_REWRITE_HEADER) && + !headers.has(NEXTJS_MIDDLEWARE_OVERRIDE_HEADERS)) ) } -function applyNextjsOptimizationRequestContext( - response: NextResponse, - requestHeaders: Headers, -): void { - clearForwardedRequestHeaders(response) - applyForwardedRequestHeaders(response, requestHeaders) -} - function createSanitizedForwardedRequestHeaders( request: NextRequest, response?: NextResponse, ): Headers { const requestHeaders = createBaseForwardedRequestHeaders(request.headers, response) - - sanitizeForwardedRequestHeaders(requestHeaders, request.url) - - return requestHeaders -} - -function sanitizeForwardedRequestHeaders(requestHeaders: Headers, requestUrl: string): void { for (const name of Array.from(requestHeaders.keys())) { if (name.toLowerCase().startsWith(NEXTJS_OPTIMIZATION_REQUEST_HEADER_PREFIX)) { requestHeaders.delete(name) } } - - requestHeaders.set(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, requestUrl) + requestHeaders.set(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, request.url) + return requestHeaders } diff --git a/packages/web/frameworks/nextjs-sdk/src/request-handoff-support.ts b/packages/web/frameworks/nextjs-sdk/src/request-handoff-support.ts new file mode 100644 index 000000000..3d31473f5 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/request-handoff-support.ts @@ -0,0 +1,62 @@ +import { + createPageContextFromUrl, + type UniversalEventBuilderArgs, +} from '@contentful/optimization-node/core-sdk' +import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' +import type { NextjsPageContextInput, NextjsRequestHandoffOptions } from './server' + +export function getForwardedRequestPage( + headers: Headers | undefined, + referrer: string | undefined, +): NonNullable | undefined { + const requestUrl = headers?.get(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER) + if (!requestUrl) return undefined + return createPageContextFromUrl(requestUrl, { referrer }) +} + +export function createNextjsRequestRouteKey( + options: NextjsRequestHandoffOptions, + getExplicitPage: ( + page: NextjsPageContextInput | undefined, + ) => NonNullable | undefined, +): string | undefined { + const requestUrl = getRequestUrl(options) + const requestRouteKey = requestUrl === undefined ? undefined : toRouteKey(requestUrl) + if (requestRouteKey !== undefined) return requestRouteKey + + const explicitPageRouteKey = toExplicitPageRouteKey(options, getExplicitPage) + if (explicitPageRouteKey !== undefined) return explicitPageRouteKey + + return toPagePayloadRouteKey(options) +} + +function getRequestUrl(options: NextjsRequestHandoffOptions): string | undefined { + if (options.request !== undefined) return options.request.url + return options.headers?.get(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER) ?? undefined +} + +function toExplicitPageRouteKey( + options: NextjsRequestHandoffOptions, + getExplicitPage: ( + page: NextjsPageContextInput | undefined, + ) => NonNullable | undefined, +): string | undefined { + const page = getExplicitPage(options.page) + return page === undefined ? undefined : `${page.path}${page.search}` +} + +function toPagePayloadRouteKey(options: NextjsRequestHandoffOptions): string | undefined { + const { path, search } = options.pagePayload.properties ?? {} + if (typeof path !== 'string') return undefined + + return `${path}${typeof search === 'string' ? search : ''}` +} + +function toRouteKey(value: string): string | undefined { + try { + const url = new URL(value, 'http://localhost') + return `${url.pathname}${url.search}` + } catch { + return undefined + } +} diff --git a/packages/web/frameworks/nextjs-sdk/src/request-preview-fallback.ts b/packages/web/frameworks/nextjs-sdk/src/request-preview-fallback.ts new file mode 100644 index 000000000..929e00fe0 --- /dev/null +++ b/packages/web/frameworks/nextjs-sdk/src/request-preview-fallback.ts @@ -0,0 +1,70 @@ +import { + createRequestHandoffFromData, + type ManagedEntryHandoff, +} from '@contentful/optimization-react-web/core-sdk' +import { createScopedLogger } from '@contentful/optimization-react-web/logger' +import type { + AnalyticsOptimizationHandoff, + BrowserOptimizationHandoff, + ContentOptimizationHandoff, + ContentOptimizationHydrationMode, + OptimizationHydrationMode, +} from './handoff' +import { addBrowserHandoffMetadata } from './handoff' + +const logger = createScopedLogger('Next.js:RequestPreview') + +export interface RequestPreviewFallbackResult { + readonly degraded: boolean + readonly value: T +} + +export async function resolveRequestPreview( + attempt: () => Promise, + fallback: () => T, +): Promise> { + try { + return { degraded: false, value: await attempt() } + } catch (error) { + logger.warn( + 'Request personalization failed; continuing without server personalization so the browser can track the page.', + String(error), + ) + return { degraded: true, value: fallback() } + } +} + +export function createPrivateRequestPreviewFallbackHandoff(input: { + readonly entries?: readonly ManagedEntryHandoff[] + readonly hydration: 'analytics-only' +}): AnalyticsOptimizationHandoff +export function createPrivateRequestPreviewFallbackHandoff(input: { + readonly entries?: readonly ManagedEntryHandoff[] + readonly hydration: ContentOptimizationHydrationMode +}): ContentOptimizationHandoff +export function createPrivateRequestPreviewFallbackHandoff(input: { + readonly entries?: readonly ManagedEntryHandoff[] + readonly hydration: OptimizationHydrationMode +}): BrowserOptimizationHandoff +export function createPrivateRequestPreviewFallbackHandoff({ + entries, + hydration, +}: { + readonly entries?: readonly ManagedEntryHandoff[] + readonly hydration: OptimizationHydrationMode +}): BrowserOptimizationHandoff { + return addBrowserHandoffMetadata( + createRequestHandoffFromData({ + cache: { scope: 'private-request' }, + ...(entries === undefined ? {} : { entries }), + }), + { hydration }, + ) +} + +export function reportRequestPreviewFallback(error: unknown): void { + logger.warn( + 'Request personalization could not start; continuing without server personalization so the browser can track the page.', + String(error), + ) +} diff --git a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts index 26b241942..6972b6ed4 100644 --- a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts @@ -1,4 +1,8 @@ import type NodeContentfulOptimization from '@contentful/optimization-node' +import { + createRequestHandoffFromData, + createRequestHandoffFromPreview, +} from '@contentful/optimization-node' import type { ResolvedData } from '@contentful/optimization-node/core-sdk' import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' import type { ReactElement } from 'react' @@ -216,7 +220,6 @@ export function acceptBoundPublicPermutationHandoffOverload( ): ContentOptimizationHandoff { return components.createPublicPermutationHandoff({ hydration: 'preserve-server', - initialPageEvent: 'emit', permutationKey: 'new-visitor', selectedOptimizations: [], }) @@ -267,10 +270,7 @@ export function rejectAppRouterRequestOwnedProps( // @ts-expect-error Request providers own hydration. hydration: 'preserve-server', }) - void components.request.NextAppAutoPageTracker({ - // @ts-expect-error Request page trackers own the initial page event. - initialPageEvent: 'emit', - }) + void components.request.NextAppAutoPageTracker({ initialPageEvent: 'emit' }) } export function acceptAppRouterRootPageEventProps( @@ -420,9 +420,7 @@ export function assertAppRouterClientConfigBranches( presentBound.OptimizationRoot({ buildPagePayload, routeKey: '/products' }) presentBound.RequestOptimizationRoot({ children: null }) - // @ts-expect-error Callback-absent App bindings do not expose a request client root. - const absentRequestRoot = absentLiteral.RequestOptimizationRoot - void absentRequestRoot + absentLiteral.RequestOptimizationRoot({ children: null }) const widenedBound = bindNextjsAppRouterClientOptimization(widenedExport) widenedBound.OptimizationRoot({ buildPagePayload, routeKey: '/products' }) @@ -630,6 +628,32 @@ export function rejectInvalidAppRouterRequestClientRootReference( }) } +export function acceptHydrationOptionsWithoutAssertions( + preview: Parameters[0]['preview'], +): void { + const handoff = createRequestHandoffFromPreview({ + preview, + routeKey: '/page', + hydration: 'preserve-server', + }) + const fallback = createRequestHandoffFromData({ hydration: 'client-only-hidden-until-ready' }) + const contentProps: BoundNextjsOptimizationRootProps = { handoff } + const fallbackProps: BoundNextjsOptimizationRootProps = { handoff: fallback } + const mode: 'preserve-server' = handoff.hydration + const analytics = createRequestHandoffFromPreview({ + preview, + routeKey: '/page', + hydration: 'analytics-only', + }) + const analyticsProps: BoundNextjsOptimizationAnalyticsRootProps = { + handoff: analytics, + routeKey: '/page', + } + void [contentProps, fallbackProps, mode, analyticsProps] + // @ts-expect-error Unsupported hydration strings remain rejected by the SDK. + createRequestHandoffFromData({ hydration: 'unsupported-mode' }) +} + describe('Next.js runtime type contracts', () => { it('keeps client and server runtimes distinct', () => { expect(true).toBe(true) diff --git a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx index 28a5e6a19..6143fe734 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx @@ -1,5 +1,10 @@ +import { EventBuilder } from '@contentful/optimization-node/core-sdk' import * as serverExports from './server' import type { ServerTrackingBaselineEntry, ServerTrackingResolvedData } from './tracking-attributes' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, @@ -7,6 +12,7 @@ const { bindNextjsOptimizationRequest, configureNextjsServerOptimization, createNextjsPageContext, + createNextjsRequestHandoff, createNextjsRequestContext, getNextjsServerOptimizationData, persistNextjsAnonymousId, @@ -23,6 +29,9 @@ const sdkConfig = { interface CreatedSdk { readonly forRequest: ReturnType> + readonly previewInitialExperience: ReturnType< + typeof rs.fn + > readonly sdk: ContentfulOptimization } @@ -114,6 +123,15 @@ function createSdk( }, }), ), + previewInitialExperience = rs.fn( + async () => + await Promise.resolve({ + accepted: true, + data: optimizationData, + experience: [replayEventBuilder.buildPageView({})], + insights: [], + }), + ), ): CreatedSdk { const sdk = configureNextjsServerOptimization(sdkConfig) const requestOptimization = sdk.forRequest({ consent: true }) @@ -123,19 +141,18 @@ function createSdk( rs.spyOn(sdk, 'forRequest').mockImplementation(forRequest) rs.spyOn(requestOptimization, 'page').mockImplementation(page) + rs.spyOn(requestOptimization, 'previewInitialExperience').mockImplementation( + previewInitialExperience, + ) return { forRequest, + previewInitialExperience, sdk, } } describe('Next.js server helpers', () => { - it('exports the server configure helper without the removed create helper name', () => { - expect(serverExports.configureNextjsServerOptimization).toBeTypeOf('function') - expect(serverExports).not.toHaveProperty('createNextjsOptimization') - }) - it('builds request context from a Next-like request', () => { expect( createNextjsRequestContext({ @@ -398,6 +415,136 @@ describe('Next.js server helpers', () => { expect(result.data?.profile.id).toBe('profile-from-page') }) + it('creates a route-bound replay handoff from the preview command sequence', async () => { + const previewInitialExperience = rs.fn( + async () => + await Promise.resolve({ + accepted: true, + data: optimizationData, + experience: [replayEventBuilder.buildPageView({ properties: { route: '/products' } })], + insights: [], + }), + ) + const { sdk } = createSdk(undefined, previewInitialExperience) + + const result = await createNextjsRequestHandoff(sdk, { + consent: true, + initialExperienceEvents: [ + { event: 'initial-preview', properties: { plan: 'pro' }, type: 'track' }, + ], + pagePayload: { properties: { route: '/products' } }, + request: { + headers: new Headers(), + url: 'https://example.test/products?tab=featured', + }, + hydration: 'preserve-server', + }) + + expect(previewInitialExperience).toHaveBeenCalledWith({ + events: [{ event: 'initial-preview', properties: { plan: 'pro' }, type: 'track' }], + page: { properties: { route: '/products' } }, + }) + expect(result.handoff.replay).toEqual({ + experience: [ + expect.objectContaining({ + type: 'page', + properties: expect.objectContaining({ route: '/products' }), + }), + ], + insights: [], + routeKey: '/products?tab=featured', + }) + }) + + it('omits replay when preview is blocked', async () => { + const previewInitialExperience = rs.fn( + async () => await Promise.resolve({ accepted: false }), + ) + const { sdk } = createSdk(undefined, previewInitialExperience) + + const result = await createNextjsRequestHandoff(sdk, { + consent: true, + pagePayload: {}, + request: { headers: new Headers(), url: 'https://example.test/products' }, + hydration: 'preserve-server', + }) + + expect(result.handoff.replay).toBeUndefined() + }) + + it('keeps accepted preview state and entries when no replay route can be resolved', async () => { + const { sdk } = createSdk() + const entries = [{ baselineEntry, entryId: baselineEntry.sys.id }] as const + + const result = await createNextjsRequestHandoff(sdk, { + consent: true, + entries, + hydration: 'preserve-server', + pagePayload: { properties: { route: '/products' } }, + }) + + expect(result.pageResult).toEqual({ accepted: true, data: optimizationData }) + expect(result.handoff).toMatchObject({ + cache: { scope: 'private-request' }, + entries, + state: { + changes: optimizationData.changes, + profile: optimizationData.profile, + selectedOptimizations: optimizationData.selectedOptimizations, + }, + }) + expect(result.handoff.replay).toBeUndefined() + }) + + it.each([ + [ + 'the complete explicit page before the payload route', + { + page: { + path: '/explicit', + query: {}, + referrer: '', + search: '?source=page', + url: 'https://example.test/explicit?source=page', + }, + pagePayload: { properties: { path: '/payload', search: '?source=payload' } }, + }, + '/explicit?source=page', + ], + [ + 'the payload path and search when no request or complete page is available', + { pagePayload: { properties: { path: '/payload', search: '?source=payload' } } }, + '/payload?source=payload', + ], + ] as const)('resolves replay routes from %s', async (_label, input, routeKey) => { + const { sdk } = createSdk() + + const result = await createNextjsRequestHandoff(sdk, { + consent: true, + hydration: 'preserve-server', + ...input, + }) + + expect(result.handoff.replay).toMatchObject({ routeKey }) + }) + + it('rejects a public request cache before previewing the request', async () => { + const { previewInitialExperience, sdk } = createSdk() + + await expect( + createNextjsRequestHandoff(sdk, { + // @ts-expect-error -- testing runtime validation for invalid request cache scope. + cache: { key: 'segment-a', scope: 'public-permutation' }, + consent: true, + hydration: 'preserve-server', + pagePayload: {}, + }), + ).rejects.toThrow( + 'Request handoffs must use private-request cache scope. Use public permutation handoffs for public cache scopes, or a non-request handoff for static output.', + ) + expect(previewInitialExperience).not.toHaveBeenCalled() + }) + it('persists anonymous ID when the Node request allows persistence', () => { const set = rs.fn() const { sdk } = createSdk() diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index 29a3c52ba..281532dc5 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -1,5 +1,6 @@ import ContentfulOptimizationRuntime, { createRequestHandoffFromData, + createRequestHandoffFromPreview, type OptimizationNodeConfig, } from '@contentful/optimization-node' import type { OptimizationData, PartialProfile } from '@contentful/optimization-node/api-schemas' @@ -10,7 +11,9 @@ import type { CoreStatelessRequestOptions, EventEmissionResult, FetchOptimizedEntryResult, + InitialExperienceCommandInput, ManagedEntryHandoff, + OptimizationCacheMetadata, PageViewBuilderArgs, PrivateRequestOptimizationCacheMetadata, UniversalEventBuilderArgs, @@ -18,6 +21,7 @@ import type { import { createPageContextFromUrl } from '@contentful/optimization-node/core-sdk' import type { ChainModifiers, EntrySkeletonType, LocaleCode } from 'contentful' import type { JSX, ReactElement, ReactNode } from 'react' +import { assertRequestHandoffCacheMetadata } from './app-router-request-handoff' import type { NextjsCookieReader } from './bound-component-types' import { DEFAULT_NEXTJS_ANONYMOUS_ID_COOKIE, @@ -32,7 +36,7 @@ import type { OptimizationHydrationMode, } from './handoff' import { addBrowserHandoffMetadata } from './handoff' -import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER } from './request-context' +import { createNextjsRequestRouteKey, getForwardedRequestPage } from './request-handoff-support' import { renderOptimizedEntryOnServer } from './server-entry-renderer' import type { ServerTrackingAttributeOptions, @@ -127,6 +131,8 @@ export interface NextjsRequestHandoffOptions extends NextjsServerOptimizationDat readonly entries?: readonly ManagedEntryHandoff[] readonly hydration: OptimizationHydrationMode readonly pagePayload: PageViewBuilderArgs + /** Commands to preflight before the initial page and replay in the browser. */ + readonly initialExperienceEvents?: readonly InitialExperienceCommandInput[] } export interface NextjsRequestHandoffResult extends NextjsServerOptimizationData { @@ -344,26 +350,14 @@ function isCompletePageContext( return 'query' in page && 'search' in page && 'url' in page } -function getForwardedRequestPage( - headers: Headers | undefined, - referrer: string | undefined, -): NonNullable | undefined { - const requestUrl = headers?.get(NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER) - if (!requestUrl) return undefined - - return createPageContextFromUrl(requestUrl, { referrer }) -} - export async function getNextjsServerOptimizationData( sdk: ContentfulOptimization, options: NextjsServerOptimizationDataOptions, ): Promise { const requestOptimization = bindNextjsOptimizationRequest(sdk, options) const pageResult = await requestOptimization.page(options.pagePayload) - return { data: pageResult.data, pageResult, requestOptimization } } - export function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions & { readonly hydration: 'analytics-only' }, @@ -380,26 +374,45 @@ export async function createNextjsRequestHandoff( sdk: ContentfulOptimization, options: NextjsRequestHandoffOptions, ): Promise { - const { cache, entries, hydration, ...requestOptions } = options - const { data, pageResult, requestOptimization } = await getNextjsServerOptimizationData( - sdk, - requestOptions, - ) + const { cache, entries, hydration, initialExperienceEvents, ...requestOptions } = options + const cacheMetadata: OptimizationCacheMetadata = cache ?? { scope: 'private-request' } + assertRequestHandoffCacheMetadata(cacheMetadata) + const requestOptimization = bindNextjsOptimizationRequest(sdk, requestOptions) + const preview = await requestOptimization.previewInitialExperience({ + ...(initialExperienceEvents === undefined ? {} : { events: initialExperienceEvents }), + page: options.pagePayload, + }) + const data = preview.accepted ? preview.data : undefined + const pageResult: EventEmissionResult = preview.accepted + ? { accepted: true, data: preview.data } + : { accepted: false } + const routeKey = preview.accepted + ? createNextjsRequestRouteKey(options, getExplicitPage) + : undefined const handoff = addBrowserHandoffMetadata( - createRequestHandoffFromData({ - ...(cache === undefined ? {} : { cache }), - data, - ...(entries === undefined ? {} : { entries }), - }), - { - hydration, - initialPageEvent: pageResult.accepted ? 'skip' : 'emit', - }, + createRequestHandoffFromPreviewOrData(preview, routeKey, cacheMetadata, entries), + { hydration }, ) - return { data, handoff, pageResult, requestOptimization } } +function createRequestHandoffFromPreviewOrData( + preview: Awaited>, + routeKey: string | undefined, + cache: PrivateRequestOptimizationCacheMetadata, + entries: readonly ManagedEntryHandoff[] | undefined, +): ReturnType { + if (preview.accepted && routeKey !== undefined) { + return createRequestHandoffFromPreview({ cache, entries, preview, routeKey }) + } + + return createRequestHandoffFromData({ + cache, + entries, + ...(preview.accepted ? { data: preview.data } : {}), + }) +} + export function persistNextjsAnonymousId( response: NextjsResponseLike, requestOptimization: CoreStatelessRequest, diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 98e4a9a00..80475815e 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -348,9 +348,8 @@ component-local UI state, keep using hooks and React effects under the provider. ### Work before the initial page decision Use `beforeInitialPage` on an owned `OptimizationRoot` when browser identity or custom Experience -event work must finish before that root's 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. After the root's live owned runtime exists, the callback receives receiver-safe +event work must finish before its initial page coordination. After the root's live owned runtime +exists, the callback receives receiver-safe `identify`, `screen`, and `track` methods, so you can destructure and call them without losing the SDK receiver: @@ -371,13 +370,7 @@ SDK receiver: ``` -A root with `beforeInitialPage` requires `routeKey` and lazy `buildPagePayload`, and it does not -accept `initialPagePayload`. The app owns `routeKey` as the stable identity of the current route and -owns `buildPagePayload` as a lazy read of current page data. The root awaits the work returned by -`run` or its watchdog, reads the latest route and payload builder for one direct page attempt, -activates the existing page emitter with a non-emitting initial `skip` mark for the attempted route, -and emits normally for later route changes. A successfully applied same-route handoff can make the -direct page decision a `skip`; otherwise, the direct page attempt uses `emit`. +A root with `beforeInitialPage` requires `routeKey` and lazy `buildPagePayload`; it does not accept `initialPagePayload`. The app owns the route identity and payload builder. The combined operation hydrates preview state and registers subscriptions before events. An accepted matching page replay supplies the initial work; otherwise the root awaits returned callback work or its watchdog before an ordinary page attempt. This does not gate preview rendering. Newer handoffs preserve earlier admitted events. Hydration is memory-only, and durable continuity requires a successful live response and persistence consent. Later routes use ordinary tracking. This root is the sole page owner for its subtree. Do not also mount a React Router, TanStack Router, Next.js App Router, or Next.js Pages Router automatic page tracker. Omit `beforeInitialPage` when a @@ -393,15 +386,12 @@ The sequence is best-effort. Return every promise or thenable that must finish b fire-and-forget work continues as later activity. Callback rejection or watchdog expiry is reported through `onError` when supplied. While the root remains mounted and the same live owned runtime is current, the page is still attempted. The watchdog stops waiting but does not cancel callback code -or an in-flight request. If the root unmounts or its runtime is replaced, only unsent local page and -readiness continuation is suppressed. +or an in-flight request. If the root unmounts or its runtime is replaced, only unsent local page work is suppressed. -A route change after the direct page attempt starts neither cancels that attempt nor starts a -competing 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; a route change -after readiness emits normally. The existing optimized-entry deadline can commit baseline content -first, and with default `liveUpdates={false}`, that fallback remains frozen after the -before-initial-page work later succeeds. +A route change does not cancel events already handed off. Ordinary route effects wait for the +initial operation, discard unsent work for disposed route effects, and then use the existing +accepted/in-flight route tracker. The current route can emit after initial delivery settles. +Preview content and entry resolution do not wait for event completion. ### Analytics-only handoff @@ -556,7 +546,7 @@ Router adapters emit `page()` events for supported client-side routers: | TanStack Router | `@contentful/optimization-react-web/router/tanstack-router` | Mount under the TanStack router tree and inside `OptimizationRoot` | The `next-pages` tracker remains available for low-level Pages Router wiring. For full Next.js Pages -Router SSR setup with `getServerSideProps`, request handoff, and anonymous ID cookie writes, +Router SSR setup with `getServerSideProps` and request handoff, prefer the [`@contentful/optimization-nextjs/pages-router`](../nextjs-sdk/README.md#pages-router-setup) adapter path. @@ -620,3 +610,5 @@ behavior. browser authoring workflows - [React Web reference implementation](../../../../implementations/react-web-sdk/README.md) - application using providers, router tracking, optimized entries, live updates, and entry tracking + +A root with route inputs owns the combined paired replay and initial-page decision. `OptimizationProvider` and the standalone state-hydration helpers publish state without retaining or submitting replay instructions. Custom route owners use the live SDK's `hydrateAndTrackCurrentPage()` operation for paired delivery. Recoverable hydration errors retain a usable owned or injected runtime and report the error through context; they do not require replacing rendered preview content. diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json index 4d45ac3d9..dadafc247 100644 --- a/packages/web/frameworks/react-web-sdk/package.json +++ b/packages/web/frameworks/react-web-sdk/package.json @@ -152,16 +152,16 @@ "buildTools": { "bundleSize": { "gzipBudgets": { - "index.cjs": 7100, - "index.mjs": 6200, - "router/next-app.cjs": 2000, - "router/next-app.mjs": 2000, - "router/next-pages.cjs": 1900, - "router/next-pages.mjs": 1900, - "router/react-router.cjs": 1900, - "router/react-router.mjs": 1800, - "router/tanstack-router.cjs": 1900, - "router/tanstack-router.mjs": 1900, + "index.cjs": 6400, + "index.mjs": 5500, + "router/next-app.cjs": 1900, + "router/next-app.mjs": 1800, + "router/next-pages.cjs": 1800, + "router/next-pages.mjs": 1700, + "router/react-router.cjs": 1800, + "router/react-router.mjs": 1600, + "router/tanstack-router.cjs": 1800, + "router/tanstack-router.mjs": 1700, "handoff.cjs": 800, "handoff.mjs": 200, "analytics.cjs": 800, diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx index cf3085575..65fddb31d 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.test.tsx @@ -6,11 +6,7 @@ import { renderWithOptimizationProviders, } from '../test/sdkTestUtils' import type { AutoPagePayload } from './types' -import { - resetAutoPageEmitterState, - useAutoPageEmitter, - type InitialAutoPageEvent, -} from './useAutoPageEmitter' +import { useAutoPageEmitter, type InitialAutoPageEvent } from './useAutoPageEmitter' function TestAutoPageEmitter({ enabled = true, @@ -35,10 +31,9 @@ function TestAutoPageEmitter({ return null } -function TestSkipOnlyAutoPageEmitter({ routeKey }: { routeKey: string }): null { +function TestAutoPageEmitterWithoutBuilder({ routeKey }: { routeKey: string }): null { useAutoPageEmitter({ enabled: true, - initialPageEvent: 'skip', routeKey, }) @@ -46,10 +41,6 @@ function TestSkipOnlyAutoPageEmitter({ routeKey }: { routeKey: string }): null { } describe('useAutoPageEmitter', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('emits on first eligible render', async () => { const page = rs.fn(async (_payload?: AutoPagePayload) => { await Promise.resolve() @@ -228,7 +219,7 @@ describe('useAutoPageEmitter', () => { await second.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { + it('treats the deprecated initial-page input as inert', async () => { const page = rs.fn(async (_payload?: AutoPagePayload) => { await Promise.resolve() return undefined @@ -240,43 +231,26 @@ describe('useAutoPageEmitter', () => { sdk, ) - expect(buildPayload).not.toHaveBeenCalled() - expect(page).not.toHaveBeenCalled() - - await rendered.rerender( - , - ) - expect(buildPayload).toHaveBeenCalledTimes(1) expect(page).toHaveBeenCalledTimes(1) - await rendered.rerender( - , - ) - - expect(buildPayload).toHaveBeenCalledTimes(2) - expect(page).toHaveBeenCalledTimes(2) - await rendered.unmount() }) - it('marks a skipped initial route without a payload builder', async () => { + it('emits an empty payload when the builder is omitted', async () => { const trackCurrentPage = rs.fn(async () => { await Promise.resolve() return { accepted: true as const } }) const sdk = createOptimizationSdk({ trackCurrentPage }) const rendered = await renderWithOptimizationProviders( - , + , sdk, ) expect(trackCurrentPage).toHaveBeenCalledWith({ - initialPageEvent: 'skip', + buildPayload: undefined, + isCurrent: expect.any(Function), routeKey: '/', }) diff --git a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts index 3d76e5ae1..f6500c8ab 100644 --- a/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts +++ b/packages/web/frameworks/react-web-sdk/src/auto-page/useAutoPageEmitter.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { useOptimization } from '../hooks/useOptimization' import { useConsentState } from '../hooks/useOptimizationState' import type { AutoPagePayload } from './types' @@ -22,17 +22,9 @@ export interface UseAutoPageEmitterArgs { * double-effect invocations. */ readonly routeKey: string - /** - * Controls the first eligible route emission. SSR integrations can use - * `skip` when the server already emitted the mounted route's page event. - * Later client-side route changes still emit when a payload builder is - * available. - */ + /** @deprecated This legacy input is inert. */ readonly initialPageEvent?: InitialAutoPageEvent - /** - * Builds the page event payload to emit. Required for emitted routes and not - * called for skip-only initial route marking. - */ + /** Builds the page event payload. Omit it to use the legacy empty payload. */ readonly buildPayload?: (metadata: AutoPageEmissionMetadata) => AutoPagePayload } @@ -47,46 +39,27 @@ export interface UseAutoPageEmitterArgs { */ export function useAutoPageEmitter({ enabled, - initialPageEvent = 'emit', routeKey, buildPayload, }: UseAutoPageEmitterArgs): void { const sdk = useOptimization() const consent = useConsentState() - const skippedInitialRouteKey = useRef(undefined) useEffect(() => { if (!enabled) { return } - if (skippedInitialRouteKey.current === undefined) { - skippedInitialRouteKey.current = initialPageEvent === 'skip' ? routeKey : null - } - - const currentInitialPageEvent = skippedInitialRouteKey.current === routeKey ? 'skip' : 'emit' - - if (skippedInitialRouteKey.current !== routeKey) { - skippedInitialRouteKey.current = null - } - - if (currentInitialPageEvent === 'skip') { - void sdk.trackCurrentPage({ initialPageEvent: 'skip', routeKey }).catch(() => undefined) - return - } - - if (buildPayload === undefined) return - + let active = true void sdk .trackCurrentPage({ buildPayload, - initialPageEvent: 'emit', + isCurrent: () => active, routeKey, }) .catch(() => undefined) - }, [buildPayload, consent, enabled, initialPageEvent, routeKey, sdk]) -} - -export function resetAutoPageEmitterState(): void { - // Current-page state is owned by each Web SDK instance. + return () => { + active = false + } + }, [buildPayload, consent, enabled, routeKey, sdk]) } diff --git a/packages/web/frameworks/react-web-sdk/src/context/BeforeInitialPageContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/BeforeInitialPageContext.tsx deleted file mode 100644 index ccf78da63..000000000 --- a/packages/web/frameworks/react-web-sdk/src/context/BeforeInitialPageContext.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext, useContext } from 'react' - -export const BeforeInitialPageContext = createContext(true) - -export function useBeforeInitialPageReady(): boolean { - return useContext(BeforeInitialPageContext) -} diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index dd552d42b..6c6893e6a 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -205,7 +205,6 @@ describe('OptimizedEntry', () => { return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: createServerOptimizationState(), ...overrides, } diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx index 673342211..b22dfdb7a 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx @@ -6,7 +6,6 @@ import { type OptimizedEntrySnapshot, } from '@contentful/optimization-web/presentation' import { act, useLayoutEffect, useRef, useState } from 'react' -import { BeforeInitialPageContext } from '../context/BeforeInitialPageContext' import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext' import { OptimizationContext, @@ -86,65 +85,6 @@ async function renderHook(params: { } } -async function renderSnapshotWithBeforeInitialPage(params: { - baselineEntry: ReturnType - hasCustomLoadingFallback?: boolean - hydration?: 'client-only-hidden-until-ready' | 'preserve-server' - beforeInitialPageReady: boolean - optimization: OptimizationSdk -}): Promise<{ - getSnapshot: () => OptimizedEntrySnapshot - setBeforeInitialPageReady: (isReady: boolean) => Promise - unmount: () => Promise -}> { - const { - baselineEntry, - hasCustomLoadingFallback, - hydration = 'client-only-hidden-until-ready', - beforeInitialPageReady, - optimization, - } = params - let captured: OptimizedEntrySnapshot | undefined = undefined - let updateBeforeInitialPageReady: ((isReady: boolean) => void) | undefined = undefined - - function Probe(): null { - captured = useOptimizedEntrySnapshot({ baselineEntry, hasCustomLoadingFallback }) - return null - } - - function Harness(): React.JSX.Element { - const [isReady, setIsReady] = useState(beforeInitialPageReady) - updateBeforeInitialPageReady = setIsReady - - return ( - - - - - - ) - } - - const view = await renderWithOptimizationProviders(, optimization) - - return { - getSnapshot() { - if (!captured) { - throw new Error('Expected optimized-entry snapshot to be captured') - } - - return captured - }, - async setBeforeInitialPageReady(isReady) { - await act(async () => { - updateBeforeInitialPageReady?.(isReady) - await Promise.resolve() - }) - }, - unmount: view.unmount, - } -} - describe('useOptimizedEntry', () => { afterEach(() => { rs.useRealTimers() @@ -215,162 +155,6 @@ describe('useOptimizedEntry', () => { await view.unmount() }) - it('holds an open presentation until the before initial page sequence is ready', async () => { - const baselineEntry = makeOptimizableEntry('4ib0hsHWoSOnCVdDkizE8d') - const variantEntry = makeEntry('4k6ZyFQnR2POY5IJLLlJRb') - const variantState: SelectedOptimizationArray = [ - { - experienceId: '6IueRX1pS3iMJncbhUQTba', - sticky: true, - variantIndex: 1, - variants: { '4ib0hsHWoSOnCVdDkizE8d': '4k6ZyFQnR2POY5IJLLlJRb' }, - }, - ] - const { emit, optimization } = createRuntime((entry, selectedOptimizations) => ({ - entry: selectedOptimizations ? variantEntry : entry, - selectedOptimization: selectedOptimizations?.[0], - })) - const rendered = await renderSnapshotWithBeforeInitialPage({ - baselineEntry, - beforeInitialPageReady: false, - optimization, - }) - - await emit(variantState) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: variantEntry, - hostAttributes: {}, - isLoading: true, - isPresentationReady: false, - isResolved: false, - selectedOptimizations: variantState, - }) - - await rendered.setBeforeInitialPageReady(true) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: variantEntry, - isLoading: false, - isPresentationReady: true, - isResolved: true, - selectedOptimizations: variantState, - }) - - await rendered.unmount() - }) - - it('keeps preserve-server content immediate while before initial page work is pending', async () => { - const baselineEntry = makeOptimizableEntry('4ib0hsHWoSOnCVdDkizE8d') - const { optimization, setExperienceRequestState } = createRuntime((entry) => ({ entry })) - const rendered = await renderSnapshotWithBeforeInitialPage({ - baselineEntry, - hydration: 'preserve-server', - beforeInitialPageReady: false, - optimization, - }) - - await setExperienceRequestState({ status: 'pending' }) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: baselineEntry, - isLoading: false, - isPresentationReady: false, - isResolved: true, - }) - - await rendered.unmount() - }) - - it('keeps a pre-existing synchronous seed immediate while before initial page work is pending', async () => { - const baselineEntry = makeOptimizableEntry('4ib0hsHWoSOnCVdDkizE8d') - const variantEntry = makeEntry('4k6ZyFQnR2POY5IJLLlJRb') - const variantState: SelectedOptimizationArray = [ - { - experienceId: '6IueRX1pS3iMJncbhUQTba', - sticky: true, - variantIndex: 1, - variants: { '4ib0hsHWoSOnCVdDkizE8d': '4k6ZyFQnR2POY5IJLLlJRb' }, - }, - ] - const { emit, optimization } = createRuntime((entry, selectedOptimizations) => ({ - entry: selectedOptimizations ? variantEntry : entry, - selectedOptimization: selectedOptimizations?.[0], - })) - await emit(variantState) - - const rendered = await renderSnapshotWithBeforeInitialPage({ - baselineEntry, - beforeInitialPageReady: false, - optimization, - }) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: variantEntry, - isLoading: false, - isPresentationReady: false, - isResolved: true, - selectedOptimizations: variantState, - }) - - await rendered.unmount() - }) - - it('keeps a deadline fallback frozen after late before initial page readiness', async () => { - rs.useFakeTimers() - const baselineEntry = makeOptimizableEntry('4ib0hsHWoSOnCVdDkizE8d') - const variantEntry = makeEntry('4k6ZyFQnR2POY5IJLLlJRb') - const variantState: SelectedOptimizationArray = [ - { - experienceId: '6IueRX1pS3iMJncbhUQTba', - sticky: true, - variantIndex: 1, - variants: { '4ib0hsHWoSOnCVdDkizE8d': '4k6ZyFQnR2POY5IJLLlJRb' }, - }, - ] - const { emit, optimization } = createRuntime((entry, selectedOptimizations) => ({ - entry: selectedOptimizations ? variantEntry : entry, - selectedOptimization: selectedOptimizations?.[0], - })) - const rendered = await renderSnapshotWithBeforeInitialPage({ - baselineEntry, - hasCustomLoadingFallback: true, - beforeInitialPageReady: false, - optimization, - }) - - expect(rendered.getSnapshot()).toMatchObject({ - isLoading: true, - isPresentationReady: false, - isResolved: false, - }) - - await act(async () => { - await rs.advanceTimersByTimeAsync(5000) - }) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: baselineEntry, - isLoading: false, - isPresentationReady: false, - isResolved: true, - selectedOptimizations: undefined, - }) - - await emit(variantState) - await rendered.setBeforeInitialPageReady(true) - - expect(rendered.getSnapshot()).toMatchObject({ - entry: baselineEntry, - isLoading: false, - isPresentationReady: true, - isResolved: true, - selectedOptimizations: undefined, - }) - - await rendered.unmount() - }) - it('keeps preserve-server content visible through snapshot-to-live adoption', async () => { const baselineEntry = makeOptimizableEntry('4ib0hsHWoSOnCVdDkizE8d') const { optimization } = createRuntime((entry) => ({ entry })) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index ea622dfc9..1d8d523e1 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -17,7 +17,6 @@ import { } from '@contentful/optimization-web/presentation' import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useBeforeInitialPageReady } from '../context/BeforeInitialPageContext' import { useOptimizationHydrationMode } from '../context/OptimizationHydrationContext' import { useLiveUpdates } from '../hooks/useLiveUpdates' import { useOptimizationContext } from '../hooks/useOptimization' @@ -244,10 +243,9 @@ export function useOptimizedEntrySnapshot< readonly sdk?: OptimizedEntrySdk } const hydration = useOptimizationHydrationMode() - const isBeforeInitialPageReady = useBeforeInitialPageReady() const liveUpdatesContext = useLiveUpdates() const isSdkReady = sdk !== undefined - const currentPresentationReady = isSdkReady && isBeforeInitialPageReady + const currentPresentationReady = isSdkReady const [isPresentationReady, setIsPresentationReady] = useState(currentPresentationReady) const controllerOptions = useMemo( diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c395cbfc1..c95a6e7d9 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -2,13 +2,13 @@ import { optimizedEntry } from '@contentful/optimization-core/test/fixtures/opti import { selectedOptimizations } from '@contentful/optimization-core/test/fixtures/selectedOptimizations' import ContentfulOptimization from '@contentful/optimization-web' import type { OptimizationData } from '@contentful/optimization-web/api-schemas' -import { InterceptorManager } from '@contentful/optimization-web/core-sdk' +import { EventBuilder, InterceptorManager } from '@contentful/optimization-web/core-sdk' import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' -import { beforeEach, describe, expect, it, rs } from '@rstest/core' +import { describe, expect, it, rs } from '@rstest/core' import { act, type ReactElement, useContext } from 'react' import { createRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { resetAutoPageEmitterState, useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' +import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' import type { OptimizationContextValue } from '../context/OptimizationContext' import { OptimizationHydrationContext } from '../context/OptimizationHydrationContext' import { @@ -27,6 +27,10 @@ import { requireOptimizationContext, requireOptimizationSdk, } from '../test/sdkTestUtils' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const testConfig = { spaceId: 'test-space-id', @@ -117,7 +121,6 @@ function createContentHandoff( return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: createServerOptimizationState(profileId), ...overrides, } @@ -192,10 +195,6 @@ async function renderClientAsync( } describe('OptimizationProvider onStatesReady', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('accepts onStatesReady on OptimizationProvider and OptimizationRoot props', () => { const onStatesReady = rs.fn() const providerProps: OptimizationProviderProps = { @@ -265,6 +264,37 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) + it('binds onStatesReady subscribers before an ordinary page effect consumes a handoff replay', async () => { + const observedTypes: string[] = [] + const handoff = createContentHandoff('f0837d7dc6344c36a3a0a06c4cde754b', { + replay: { + experience: [ + replayEventBuilder.buildIdentify({ userId: 'handoff-user' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: '/handoff', + }, + }) + const rendered = await renderClientAsync( + + states.eventStream.subscribe((event) => { + if (event) observedTypes.push(event.type) + }).unsubscribe + } + > +
+ , + ) + + expect(observedTypes).toEqual(['identify', 'page']) + rendered.unmount() + }) + it('prefetches managed entries after the live SDK is ready', async () => { const prefetchManagedEntries = rs.fn(async () => await Promise.resolve([])) const sdk = createOptimizationSdk({ prefetchManagedEntries }) @@ -417,9 +447,6 @@ describe('OptimizationProvider onStatesReady', () => { expect(context).toEqual(expect.objectContaining({ error: hydrationError, isLive: true })) const sdk = requireOptimizationSdk(context.sdk) expect(sdk).toBeInstanceOf(ContentfulOptimization) - await expect( - sdk.trackCurrentPage({ initialPageEvent: 'skip', routeKey: '/failed-handoff' }), - ).resolves.toEqual({ accepted: true }) expect(destroy).not.toHaveBeenCalled() rendered.unmount() @@ -428,7 +455,7 @@ describe('OptimizationProvider onStatesReady', () => { destroy.mockRestore() }) - it('keeps injected initial-handoff failure behavior unchanged', async () => { + it('keeps an injected runtime usable after recoverable initial hydration failure', async () => { const hydrationError = new Error('injected handoff apply failed') const sdk = new ContentfulOptimization(testConfig) sdk.interceptors.state.add(async () => await Promise.reject(hydrationError)) @@ -448,7 +475,7 @@ describe('OptimizationProvider onStatesReady', () => { ) expect(capturedContext).toEqual( - expect.objectContaining({ error: hydrationError, isLive: false, sdk: undefined }), + expect.objectContaining({ error: hydrationError, isLive: true, sdk }), ) expect(destroy).not.toHaveBeenCalled() diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 05454003e..d4ca319dc 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -54,7 +54,16 @@ interface ProviderSdkInitialization { readonly sdkBinding: ProviderSdkBinding } +/** One SDK initialization callback; state readiness is independent of its delivery promise. @internal */ +export type InitializeProviderSdk = ( + sdk: OptimizationSdk, + ready: (error?: unknown) => void, + isCurrent: () => boolean, +) => Promise + interface OptimizationHandoffProps { + /** @internal */ + readonly initializeSdk?: InitializeProviderSdk /** * Server/static/edge Optimization handoff to apply before provider children mount. */ @@ -119,6 +128,7 @@ function createOwnedSdkBinding(props: OptimizationProviderConfigProps): Provider onStatesReady: _onStatesReady, sdk: _sdk, handoff: _handoff, + initializeSdk: _initializeSdk, hydration: _hydration, prefetchManagedEntries: _prefetchManagedEntries, trackEntryInteraction, @@ -149,62 +159,65 @@ function bindOnStatesReady( return { ...sdkBinding, cleanup } } -async function initializeServerOptimizationState( +function bindReadyState( sdkBinding: ProviderSdkBinding, - handoff: ContentOptimizationHandoff, onStatesReady: OnStatesReady | undefined, - retainOwnedSdkOnHydrationError: boolean, -): Promise { + error?: unknown, +): ProviderSdkInitialization { try { - const hydrationResult: unknown = Reflect.apply(hydrateOptimizationHandoff, undefined, [ - sdkBinding.sdk, - handoff, - ]) - - if (isPromiseLike(hydrationResult)) { - await hydrationResult + return { + error: error === undefined ? undefined : toError(error), + sdkBinding: bindOnStatesReady(sdkBinding, onStatesReady), } - } catch (error: unknown) { - if (retainOwnedSdkOnHydrationError) { - return { error: toError(error), sdkBinding } - } - - disposeSdkBinding(sdkBinding) - throw error - } - - try { - return { sdkBinding: bindOnStatesReady(sdkBinding, onStatesReady) } - } catch (error: unknown) { - disposeSdkBinding(sdkBinding) - throw error + } catch (callbackError: unknown) { + return { error: toError(callbackError), sdkBinding } } } function initializeProviderSdk( props: OptimizationProviderProps, -): ProviderSdkInitialization | Promise { - const ownsSdk = props.sdk === undefined - const sdkBinding = ownsSdk ? createOwnedSdkBinding(props) : createInjectedSdkBinding(props) - - if (props.handoff === undefined) { + onBinding: (binding: ProviderSdkBinding) => void, + onReady: (value: ProviderSdkInitialization) => void, + isCurrent: () => boolean, +): void { + const sdkBinding = + props.sdk === undefined ? createOwnedSdkBinding(props) : createInjectedSdkBinding(props) + onBinding(sdkBinding) + let initialized: ProviderSdkInitialization = { sdkBinding } + const ready = (error?: unknown): void => { + if (!isCurrent()) return + initialized = bindReadyState(sdkBinding, props.onStatesReady, error) + onReady(initialized) + } + if (props.initializeSdk === undefined && props.handoff === undefined) { try { - return { sdkBinding: bindOnStatesReady(sdkBinding, props.onStatesReady) } + onReady({ sdkBinding: bindOnStatesReady(sdkBinding, props.onStatesReady) }) } catch (error: unknown) { disposeSdkBinding(sdkBinding) throw error } + return + } + if (props.initializeSdk !== undefined) { + void props.initializeSdk(sdkBinding.sdk, ready, isCurrent).catch((error: unknown) => { + if (isCurrent()) onReady({ ...initialized, error: toError(error) }) + }) + } else if (props.handoff !== undefined) { + void Promise.resolve( + Reflect.apply(hydrateOptimizationHandoff, undefined, [sdkBinding.sdk, props.handoff]), + ).then(() => { + ready() + }, ready) } - - return initializeServerOptimizationState(sdkBinding, props.handoff, props.onStatesReady, ownsSdk) -} - -function isPromiseLike(value: T | Promise): value is Promise { - return value instanceof Promise } function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean { - return props.sdk !== undefined && props.onStatesReady === undefined && props.handoff === undefined + return ( + props.sdk !== undefined && + props.onStatesReady === undefined && + props.handoff === undefined && + props.initializeSdk === undefined + ) } function injectedSdkBacksInitialRender(props: OptimizationProviderProps): boolean { @@ -251,6 +264,7 @@ function createPrefetchedManagedEntries( export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { const { children } = props const initialPropsRef = useRef(props) + const mountedRef = useRef(false) const hydratedHandoffRef = useRef(props.handoff) const liveLocale = props.sdk === undefined ? props.locale : undefined const [state, setState] = useState(() => ({ @@ -264,11 +278,13 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle ) useLayoutEffect(() => { + mountedRef.current = true const { current: initialProps } = initialPropsRef - if (canUseInjectedSdkDuringInitialRender(initialProps)) { - return - } + if (canUseInjectedSdkDuringInitialRender(initialProps)) + return () => { + mountedRef.current = false + } const setupState = { disposed: false } let sdkBinding: ProviderSdkBinding | undefined = undefined @@ -296,30 +312,30 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle function setInitializationError(error: unknown): void { if (!setupState.disposed) { - setState({ error: toError(error), isLive: false, runtime: undefined }) + setState({ + error: toError(error), + isLive: false, + runtime: undefined, + }) } } try { - const initializedBinding = initializeProviderSdk(initialProps) - - if (!isPromiseLike(initializedBinding)) { - setInitializedState(initializedBinding) - - return () => { - setupState.disposed = true - disposeOnce(sdkBinding) - } - } - - void initializedBinding.then(setInitializedState, setInitializationError) + initializeProviderSdk( + initialProps, + (binding) => { + sdkBinding = binding + }, + setInitializedState, + () => !setupState.disposed, + ) } catch (error: unknown) { setInitializationError(error) - return } return () => { setupState.disposed = true + mountedRef.current = false disposeOnce(sdkBinding) } }, []) @@ -341,21 +357,28 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle function setHydrationError(error: unknown): void { if (!disposed) { - setState({ error: toError(error), isLive: true, runtime }) + setState({ + error: toError(error), + isLive: true, + runtime, + }) } } - try { - const hydrationResult: unknown = Reflect.apply(hydrateOptimizationHandoff, undefined, [ - runtime, - handoff, - ]) - - if (isPromiseLike(hydrationResult)) { - void hydrationResult.catch(setHydrationError) - } - } catch (error: unknown) { - setHydrationError(error) + if (props.initializeSdk !== undefined) { + void props + .initializeSdk( + runtime, + (error) => { + if (error !== undefined) setHydrationError(error) + }, + () => mountedRef.current, + ) + .catch(setHydrationError) + } else { + void Promise.resolve( + Reflect.apply(hydrateOptimizationHandoff, undefined, [runtime, handoff]), + ).catch(setHydrationError) } return () => { @@ -375,7 +398,11 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle try { state.runtime.setLocale(liveLocale) } catch (error: unknown) { - setState({ error: toError(error), isLive: true, runtime: state.runtime }) + setState({ + error: toError(error), + isLive: true, + runtime: state.runtime, + }) } }, [liveLocale, props.sdk, state.isLive, state.runtime]) @@ -394,7 +421,11 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle .prefetchManagedEntries(props.prefetchManagedEntries) .catch((error: unknown) => { if (!disposed) { - setState({ error: toError(error), isLive: true, runtime: state.runtime }) + setState({ + error: toError(error), + isLive: true, + runtime: state.runtime, + }) } }) diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx index b5807fb73..9e988a517 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.test.tsx @@ -20,7 +20,6 @@ const testConfig = { const analyticsHandoff: AnalyticsOptimizationHandoff = { cache: { scope: 'static' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { selectedOptimizations: [] }, } @@ -110,9 +109,9 @@ async function renderClientAsync(element: ReactElement): Promise<{ } describe('OptimizationAnalyticsRoot', () => { - it('hydrates analytics handoff and tracks the initial route without content context', async () => { + it('hydrates analytics handoff and tracks initial and changed routes without content context', async () => { const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const resolveOptimizedEntry = rs.spyOn( ContentfulOptimization.prototype, @@ -131,44 +130,14 @@ describe('OptimizationAnalyticsRoot', () => { , ) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: buildPagePayload, - initialPageEvent: 'emit', - routeKey: '/segments/a', - }) + expect(trackCurrentPage).toHaveBeenCalledWith({ properties: { route: '/segments/a' } }) expect(resolveOptimizedEntry).not.toHaveBeenCalled() - rendered.unmount() - trackCurrentPage.mockRestore() - resolveOptimizedEntry.mockRestore() - }) - - it('skips only the initially hydrated analytics route', async () => { - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) - const buildPagePayload = rs.fn(() => ({})) - const handoff: AnalyticsOptimizationHandoff = { - ...analyticsHandoff, - initialPageEvent: 'skip', - } - - const rendered = await renderClientAsync( - -
- , - ) - await rendered.rerender(
@@ -176,29 +145,20 @@ describe('OptimizationAnalyticsRoot', () => { ) expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: buildPagePayload, - initialPageEvent: 'skip', - routeKey: '/', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - buildPayload: buildPagePayload, - initialPageEvent: 'emit', - routeKey: '/products', - }) + expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { properties: { route: '/segments/a' } }) rendered.unmount() trackCurrentPage.mockRestore() + resolveOptimizedEntry.mockRestore() }) - it('keeps a skipped initial analytics route skipped through StrictMode replay', async () => { + it('deduplicates the StrictMode mount while tracking a route change', async () => { const page = rs.spyOn(ContentfulOptimization.prototype, 'page').mockResolvedValue({ accepted: true, }) const buildPagePayload = rs.fn(() => ({ properties: { route: 'client' } })) const handoff: AnalyticsOptimizationHandoff = { ...analyticsHandoff, - initialPageEvent: 'skip', } const rendered = await renderClientAsync( @@ -215,7 +175,7 @@ describe('OptimizationAnalyticsRoot', () => { , ) - expect(page).not.toHaveBeenCalled() + expect(page).toHaveBeenCalledTimes(1) await rendered.rerender( @@ -231,7 +191,7 @@ describe('OptimizationAnalyticsRoot', () => { , ) - expect(page).toHaveBeenCalledTimes(1) + expect(page).toHaveBeenCalledTimes(2) expect(page).toHaveBeenCalledWith({ properties: { route: 'client' } }) rendered.unmount() @@ -240,7 +200,7 @@ describe('OptimizationAnalyticsRoot', () => { it('hydrates analytics handoff with a serializable initial payload', async () => { const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const initialPagePayload = { properties: { route: '/segments/a' } } @@ -255,29 +215,20 @@ describe('OptimizationAnalyticsRoot', () => { , ) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/segments/a', - }) - const firstCall = trackCurrentPage.mock.calls[0] - if (firstCall === undefined) throw new Error('Expected trackCurrentPage to be called.') - const [{ buildPayload }] = firstCall - if (buildPayload === undefined) throw new Error('Expected buildPayload to be provided.') - expect(buildPayload({ isInitialEmission: true })).toBe(initialPagePayload) + expect(trackCurrentPage).toHaveBeenCalledWith(initialPagePayload) rendered.unmount() trackCurrentPage.mockRestore() }) - it('does not track an older route when analytics hydration resolves after a newer handoff', async () => { + it('keeps latest state while replay-less page attempts finish out of order', async () => { const firstProfile = createProfile('first-profile') const secondProfile = createProfile('second-profile') const firstHydration = createDeferred() const secondHydration = createDeferred() const buildPagePayload = rs.fn(() => ({})) const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const runInterceptors = InterceptorManager.prototype.run const runInterceptorsSpy = rs @@ -329,11 +280,7 @@ describe('OptimizationAnalyticsRoot', () => { }) expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: buildPagePayload, - initialPageEvent: 'emit', - routeKey: '/segments/b', - }) + expect(trackCurrentPage).toHaveBeenCalledWith({}) firstHydration.resolve() await act(async () => { @@ -341,7 +288,40 @@ describe('OptimizationAnalyticsRoot', () => { await Promise.resolve() }) + expect(trackCurrentPage).toHaveBeenCalledTimes(2) + + rendered.unmount() + trackCurrentPage.mockRestore() + runInterceptorsSpy.mockRestore() + }) + + it('tracks the initial page when private-request analytics hydration fails', async () => { + const hydrationError = new Error('handoff failed') + const trackCurrentPage = rs + .spyOn(ContentfulOptimization.prototype, 'page') + .mockResolvedValue({ accepted: true }) + const runInterceptorsSpy = rs + .spyOn(InterceptorManager.prototype, 'run') + .mockRejectedValue(hydrationError) + + const rendered = await renderClientAsync( + ({ properties: { ordinary: true } })} + > +
+ , + ) + + expect(signals.selectedOptimizations.value).toBeUndefined() expect(trackCurrentPage).toHaveBeenCalledTimes(1) + expect(trackCurrentPage).toHaveBeenCalledWith({ properties: { ordinary: true } }) rendered.unmount() trackCurrentPage.mockRestore() @@ -353,7 +333,7 @@ describe('OptimizationAnalyticsRoot', () => { const hydration = createDeferred() const buildPagePayload = rs.fn(() => ({})) const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const runInterceptors = InterceptorManager.prototype.run const runInterceptorsSpy = rs diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx index 29381cb45..1f16ca4e4 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationAnalyticsRoot.tsx @@ -48,10 +48,9 @@ function initializeAnalyticsRuntime( export function OptimizationAnalyticsRoot(props: OptimizationAnalyticsRootProps): ReactElement { const { buildPagePayload, children, handoff, initialPagePayload, routeKey } = props - const hydrationGeneration = useRef(0) const initialPropsRef = useRef(props) + const lastHandoffRef = useRef(undefined) const runtimeRef = useRef(undefined) - const skippedInitialRouteKey = useRef(undefined) const resolvedBuildPagePayload = useMemo( () => buildPagePayload ?? (() => initialPagePayload), [buildPagePayload, initialPagePayload], @@ -60,6 +59,7 @@ export function OptimizationAnalyticsRoot(props: OptimizationAnalyticsRootProps) useLayoutEffect(() => { const runtime = initializeAnalyticsRuntime(initialPropsRef.current) runtimeRef.current = runtime + lastHandoffRef.current = undefined return () => { runtimeRef.current = undefined @@ -71,40 +71,21 @@ export function OptimizationAnalyticsRoot(props: OptimizationAnalyticsRootProps) const { current: runtime } = runtimeRef if (runtime === undefined) return - let disposed = false - const generation = (hydrationGeneration.current += 1) - const isCurrent = (): boolean => !disposed && hydrationGeneration.current === generation - - if (skippedInitialRouteKey.current === undefined) { - skippedInitialRouteKey.current = handoff.initialPageEvent === 'skip' ? routeKey : null - } - - const initialPageEvent = skippedInitialRouteKey.current === routeKey ? 'skip' : 'emit' - - if (skippedInitialRouteKey.current !== routeKey) { - skippedInitialRouteKey.current = null - } - - void hydrateOptimizationAnalyticsHandoff( - runtime, - { - ...handoff, - initialPageEvent, - }, - { - buildPagePayload: resolvedBuildPagePayload, - isCurrent, - routeKey, - }, - ).catch((error: unknown) => { - if (isCurrent()) { - logger.warn('OptimizationAnalyticsRoot failed to hydrate handoff.', error) - } + const isCurrent = (): boolean => runtimeRef.current === runtime + + const delivery = + lastHandoffRef.current === handoff + ? runtime.trackCurrentPage({ buildPayload: resolvedBuildPagePayload, routeKey }) + : hydrateOptimizationAnalyticsHandoff(runtime, handoff, { + buildPagePayload: resolvedBuildPagePayload, + isCurrent, + routeKey, + }) + lastHandoffRef.current = handoff + void delivery.catch((error: unknown) => { + if (isCurrent()) + logger.warn('OptimizationAnalyticsRoot failed to deliver browser events.', error) }) - - return () => { - disposed = true - } }, [handoff, resolvedBuildPagePayload, routeKey]) return <>{children} diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx index 45277f602..21efa3f17 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.test.tsx @@ -1,5 +1,7 @@ import ContentfulOptimization from '@contentful/optimization-web' -import { InterceptorManager } from '@contentful/optimization-web/core-sdk' +import { ExperienceApiClient } from '@contentful/optimization-web/api-client' +import type { OptimizationData } from '@contentful/optimization-web/api-schemas' +import { EventBuilder, InterceptorManager } from '@contentful/optimization-web/core-sdk' import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' import { logger } from '@contentful/optimization-web/logger' import { afterEach, describe, expect, it, rs } from '@rstest/core' @@ -7,11 +9,12 @@ import { act, StrictMode, useContext, type ReactElement } from 'react' import { createRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' import type { BeforeInitialPageOptions } from '../before-initial-page/beforeInitialPage' -import { useBeforeInitialPageReady } from '../context/BeforeInitialPageContext' -import type { OptimizationContextValue } from '../context/OptimizationContext' import { OptimizationHydrationContext } from '../context/OptimizationHydrationContext' -import { useOptimizationContext } from '../hooks/useOptimization' import { OptimizationRoot } from './OptimizationRoot' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const testConfig = { spaceId: 'test-space-id', @@ -28,12 +31,23 @@ function createContentHandoff( return { cache: { scope: 'private-request' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state: { selectedOptimizations: [] }, ...overrides, } } +function createReplayHandoff(replay = createReplay('/products')): ContentOptimizationHandoff { + return createContentHandoff({ replay }) +} + +function createReplay(routeKey: string): NonNullable { + return { + experience: [replayEventBuilder.buildPageView({})], + insights: [], + routeKey, + } +} + interface ClientRenderResult { readonly rerender: (element: ReactElement) => Promise readonly unmount: () => void @@ -126,14 +140,14 @@ afterEach(() => { describe('OptimizationRoot handoff', () => { it('emits the initial browser page event from explicit route payload props', async () => { const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const buildPagePayload = rs.fn(() => ({ properties: { route: '/products' } })) const rendered = await renderClientAsync( @@ -141,11 +155,8 @@ describe('OptimizationRoot handoff', () => { , ) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: buildPagePayload, - initialPageEvent: 'emit', - routeKey: '/products', - }) + expect(trackCurrentPage).toHaveBeenCalledWith({ properties: { route: '/products' } }) + expect(buildPagePayload).toHaveBeenCalledWith({ isInitialEmission: true }) rendered.unmount() trackCurrentPage.mockRestore() @@ -153,14 +164,14 @@ describe('OptimizationRoot handoff', () => { it('emits the initial browser page event from a serializable initial payload', async () => { const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const initialPagePayload = { properties: { route: '/products' } } const rendered = await renderClientAsync( @@ -168,42 +179,26 @@ describe('OptimizationRoot handoff', () => { , ) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/products', - }) - const firstCall = trackCurrentPage.mock.calls[0] - if (firstCall === undefined) throw new Error('Expected trackCurrentPage to be called.') - const [{ buildPayload }] = firstCall - if (buildPayload === undefined) throw new Error('Expected buildPayload to be provided.') - expect(buildPayload({ isInitialEmission: true })).toBe(initialPagePayload) + expect(trackCurrentPage).toHaveBeenCalledWith(initialPagePayload) rendered.unmount() trackCurrentPage.mockRestore() }) - it('marks the skipped initial route without route payload props', async () => { + it('emits an empty initial payload without explicit route payload props', async () => { const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const warn = rs.spyOn(logger, 'warn').mockImplementation(() => undefined) const rendered = await renderClientAsync( - +
, ) expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledWith({ - initialPageEvent: 'skip', - routeKey: '/products', - }) + expect(trackCurrentPage).toHaveBeenCalledWith({}) expect(warn).not.toHaveBeenCalled() rendered.unmount() @@ -211,28 +206,19 @@ describe('OptimizationRoot handoff', () => { warn.mockRestore() }) - it('warns and skips initial browser page emission without route payload props', async () => { - const trackCurrentPage = rs.spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - const warn = rs.spyOn(logger, 'warn').mockImplementation(() => undefined) + it('leaves a handoff without a route inert without legacy warnings', async () => { + const trackCurrentPage = rs.spyOn(ContentfulOptimization.prototype, 'page') const rendered = await renderClientAsync( - +
, ) expect(trackCurrentPage).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - 'React:OptimizationRoot', - expect.stringContaining('without routeKey and buildPagePayload'), - ) rendered.unmount() trackCurrentPage.mockRestore() - warn.mockRestore() }) it('lets the root hydration prop override handoff hydration for children', async () => { @@ -278,420 +264,315 @@ describe('OptimizationRoot handoff', () => { }) }) -describe('OptimizationRoot before initial page', () => { - it('awaits callback work before the direct page and releases readiness after page terminality', async () => { +const replayData: OptimizationData = { + changes: [], + selectedOptimizations: [], + profile: { + id: 'profile', + stableId: 'profile', + random: 1, + audiences: [], + traits: {}, + location: {}, + session: { + id: 'session', + isReturningVisitor: false, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'https://example.test/', + }, + }, + }, +} + +describe('OptimizationRoot initial operation', () => { + it('renders children while callback work and page delivery are pending', async () => { const callback = createDeferred() const page = createDeferred<{ accepted: true }>() - const order: string[] = [] - const readiness: boolean[] = [] const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockImplementationOnce(async () => { - order.push('page') - return await page.promise - }) - .mockResolvedValue({ accepted: true }) - - function ReadinessProbe(): null { - readiness.push(useBeforeInitialPageReady()) - return null + .spyOn(ContentfulOptimization.prototype, 'page') + .mockImplementation(async () => await page.promise) + function Probe(): ReactElement { + return
preview content
} - const rendered = await renderClientAsync( createBeforeInitialPageRoot({ - children: , + children: , beforeInitialPage: { run: async () => { - order.push('callback') await callback.promise - return 'ready' }, }, }), ) - - expect(order).toEqual(['callback']) + expect(document.body.textContent).toContain('preview content') expect(trackCurrentPage).not.toHaveBeenCalled() - expect(readiness.at(-1)).toBe(false) - callback.resolve(undefined) await flushMicrotasks() - - expect(order).toEqual(['callback', 'page']) expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(readiness.at(-1)).toBe(false) - + expect(document.body.textContent).toContain('preview content') page.resolve({ accepted: true }) await flushMicrotasks() + expect(trackCurrentPage).toHaveBeenCalledTimes(1) + rendered.unmount() + }) - expect(readiness.at(-1)).toBe(true) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', + it('registers state observers before replay and skips the browser prerequisite for a matching page', async () => { + const upsert = rs + .spyOn(ExperienceApiClient.prototype, 'upsertProfile') + .mockResolvedValue(replayData) + const observed: string[] = [] + const run = rs.fn(() => undefined) + const handoff = createReplayHandoff({ + routeKey: '/products', + experience: [ + replayEventBuilder.buildIdentify({ userId: 'visitor' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], }) - + const rendered = await renderClientAsync( + ({})} + beforeInitialPage={{ run }} + onStatesReady={(states) => { + const subscription = states.eventStream.subscribe((event) => { + if (event) observed.push(event.type) + }) + return () => { + subscription.unsubscribe() + } + }} + > +
preview
+
, + ) + await flushMicrotasks() + expect(run).not.toHaveBeenCalled() + expect(observed).toEqual(['identify', 'page']) + expect(upsert).toHaveBeenCalledTimes(1) rendered.unmount() }) - it.each([ - { - name: 'synchronous callback throw', - run(error: Error): undefined { - throw error - }, - }, - { - name: 'callback rejection', - async run(error: Error): Promise { - return await Promise.reject(error) - }, - }, - ])('reports one $name and still attempts the page', async ({ run }) => { - const callbackError = new Error('callback failed') - const onError = rs.fn() - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) + it('runs prerequisite work before a mismatched handoff falls back', async () => { + const order: string[] = [] + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') + .mockImplementation(async () => { + order.push('page') + await Promise.resolve() + return { accepted: true } + }) const rendered = await renderClientAsync( createBeforeInitialPageRoot({ + handoff: createReplayHandoff(createReplay('/server')), + routeKey: '/browser', beforeInitialPage: { - onError, - run: (): ReturnType => { - const result = run(callbackError) - return result + run: () => { + order.push('callback') }, }, }), ) + await flushMicrotasks() + expect(order).toEqual(['callback', 'page']) + expect(track).toHaveBeenCalledTimes(1) + rendered.unmount() + }) - expect(onError).toHaveBeenCalledTimes(1) - expect(onError).toHaveBeenCalledWith(callbackError) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', + it('delivers the admitted journal and then tracks navigation during hydration', async () => { + const hydration = createDeferred() + const runInterceptors = InterceptorManager.prototype.run + rs.spyOn(InterceptorManager.prototype, 'run').mockImplementationOnce(async function run( + this: InterceptorManager, + state: unknown, + ) { + await hydration.promise + const result: unknown = await runInterceptors.call(this, state) + return result }) - + const upsert = rs + .spyOn(ExperienceApiClient.prototype, 'upsertProfile') + .mockResolvedValue(replayData) + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') + .mockResolvedValue({ accepted: true }) + const run = rs.fn(() => undefined) + const handoff = createReplayHandoff(createReplay('/initial')) + const rendered = await renderClientAsync( + createBeforeInitialPageRoot({ handoff, beforeInitialPage: { run } }), + ) + await rendered.rerender( + createBeforeInitialPageRoot({ handoff, routeKey: '/latest', beforeInitialPage: { run } }), + ) + hydration.resolve(undefined) + await flushMicrotasks() + expect(run).not.toHaveBeenCalled() + expect(upsert).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith({ properties: { route: '/latest' } }) rendered.unmount() }) - it.each([ - { configured: undefined, beforeDeadline: 2_999, deadline: 1 }, - { configured: 25, beforeDeadline: 24, deadline: 1 }, - ])( - 'starts the page only when the $configured watchdog expires', - async ({ beforeDeadline, configured, deadline }) => { - rs.useFakeTimers() - const callback = createDeferred() - const onError = rs.fn() - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockResolvedValue({ accepted: true }) - const rendered = await renderClientAsync( - createBeforeInitialPageRoot({ - beforeInitialPage: { - maxWaitMs: configured, - onError, - run: async () => { - await callback.promise - return 'ready' - }, - }, - }), - ) - - await act(async () => { - await rs.advanceTimersByTimeAsync(beforeDeadline) - }) - expect(onError).not.toHaveBeenCalled() - expect(trackCurrentPage).not.toHaveBeenCalled() - - await act(async () => { - await rs.advanceTimersByTimeAsync(deadline) - }) - expect(onError).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - - callback.resolve(undefined) - await flushMicrotasks() - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - - rendered.unmount() - }, - ) - - it('reads the latest route and payload builder after callback work settles', async () => { + it('reads the latest page payload after prerequisite work', async () => { const callback = createDeferred() const beforeInitialPage = { run: async () => { await callback.promise - return 'ready' }, } - const firstBuilder = rs.fn(() => ({ properties: { route: '/initial' } })) - const latestBuilder = rs.fn(() => ({ properties: { route: '/latest' } })) - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) - const handoff = createContentHandoff({ initialPageEvent: 'skip' }) + const handoff = createContentHandoff() const rendered = await renderClientAsync( - createBeforeInitialPageRoot({ - buildPagePayload: firstBuilder, - handoff, - beforeInitialPage, - }), + createBeforeInitialPageRoot({ handoff, beforeInitialPage }), ) - + const payload = (): { properties: { route: string } } => ({ properties: { route: '/latest' } }) await rendered.rerender( createBeforeInitialPageRoot({ - buildPagePayload: latestBuilder, handoff, beforeInitialPage, routeKey: '/latest', + buildPagePayload: payload, }), ) callback.resolve(undefined) await flushMicrotasks() - - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: latestBuilder, - initialPageEvent: 'emit', - routeKey: '/latest', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/latest', - }) - expect(firstBuilder).not.toHaveBeenCalled() - + expect(track).toHaveBeenCalledWith(payload()) rendered.unmount() }) - it('marks the attempted route before observing route changes made during the direct page', async () => { - const page = createDeferred<{ accepted: true }>() - const beforeInitialPage = { run: () => undefined } - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockImplementationOnce(async () => await page.promise) - .mockResolvedValue({ accepted: true }) - const rendered = await renderClientAsync( - createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/attempted' }), - ) - - expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/attempted', - }) - - await rendered.rerender( - createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/during-page' }), - ) - expect(trackCurrentPage).toHaveBeenCalledTimes(1) - - page.resolve({ accepted: true }) - await flushMicrotasks() - - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/attempted', - }) - - await rendered.rerender( - createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/after-readiness' }), - ) - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/after-readiness', - }) - - rendered.unmount() - }) - - it('uses direct skip only after a successful same-route skip handoff', async () => { - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + it('reports callback errors and still attempts the ordinary page', async () => { + const error = new Error('callback failed') + const onError = rs.fn() + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const rendered = await renderClientAsync( createBeforeInitialPageRoot({ - handoff: createContentHandoff({ initialPageEvent: 'skip' }), - beforeInitialPage: { run: () => undefined }, + beforeInitialPage: { + run: () => { + throw error + }, + onError, + }, }), ) - - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - initialPageEvent: 'skip', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', - }) - + await flushMicrotasks() + expect(onError).toHaveBeenCalledWith(error) + expect(track).toHaveBeenCalledTimes(1) rendered.unmount() }) - it('retains a failed owned handoff runtime and emits the direct page', async () => { - const hydrationError = new Error('handoff apply failed') - rs.spyOn(InterceptorManager.prototype, 'run').mockRejectedValueOnce(hydrationError) - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + it('bounds callback waiting without blocking rendered content', async () => { + rs.useFakeTimers() + const callback = createDeferred() + const onError = rs.fn() + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) - let capturedContext: OptimizationContextValue | undefined - - function ContextProbe(): null { - capturedContext = useOptimizationContext() - return null - } - const rendered = await renderClientAsync( createBeforeInitialPageRoot({ - children: , - handoff: createContentHandoff({ initialPageEvent: 'skip' }), - beforeInitialPage: { run: () => undefined }, + children:
visible preview
, + beforeInitialPage: { + run: async () => { + await callback.promise + }, + maxWaitMs: 10, + onError, + }, }), ) - - expect(capturedContext).toEqual( - expect.objectContaining({ error: hydrationError, isLive: true }), - ) - expect(capturedContext?.sdk).toBeInstanceOf(ContentfulOptimization) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', + expect(document.body.textContent).toContain('visible preview') + await act(async () => { + await rs.advanceTimersByTimeAsync(10) }) - + expect(onError).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledTimes(1) + callback.resolve(undefined) rendered.unmount() }) - it.each([ - { - logsError: true, - name: 'rejected', - result: async () => await Promise.reject(new Error('page failed')), - }, - { - logsError: false, - name: 'unaccepted', - result: async () => await Promise.resolve({ accepted: false as const }), - }, - ])( - 'marks the attempted route without a same-route emitting retry after a $name direct page', - async ({ logsError, result }) => { - const logError = rs.spyOn(logger, 'error').mockImplementation(() => undefined) - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - .mockImplementationOnce(result) - .mockResolvedValue({ accepted: true }) - const beforeInitialPage = { run: () => undefined } - const rendered = await renderClientAsync(createBeforeInitialPageRoot({ beforeInitialPage })) - - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/initial', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/initial', - }) - - await rendered.rerender( - createBeforeInitialPageRoot({ beforeInitialPage, routeKey: '/later' }), - ) - - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/later', - }) - expect(logError).toHaveBeenCalledTimes(logsError ? 1 : 0) - - rendered.unmount() - }, - ) - - it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( - 'throws for maxWaitMs %s before provider, callback, page, onError, or timer work', - (maxWaitMs) => { - rs.useFakeTimers() - const run = rs.fn() - const onError = rs.fn() - const trackCurrentPage = rs.spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') - - expect(() => - renderToString( - createBeforeInitialPageRoot({ - beforeInitialPage: { maxWaitMs, onError, run }, - }), - ), - ).toThrow(new TypeError('beforeInitialPage.maxWaitMs must be a positive finite number.')) - expect(window.contentfulOptimization).toBeUndefined() - expect(run).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() - expect(trackCurrentPage).not.toHaveBeenCalled() - expect(rs.getTimerCount()).toBe(0) - }, - ) - - it('suppresses page and readiness continuation after unmount', async () => { + it('cancels not-yet-started page work on unmount', async () => { const callback = createDeferred() - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const rendered = await renderClientAsync( createBeforeInitialPageRoot({ beforeInitialPage: { run: async () => { await callback.promise - return 'ready' }, }, }), ) - rendered.unmount() callback.resolve(undefined) await flushMicrotasks() + expect(track).not.toHaveBeenCalled() + }) - expect(trackCurrentPage).not.toHaveBeenCalled() + it('retries a blocked initial page after live consent changes', async () => { + const upsert = rs + .spyOn(ExperienceApiClient.prototype, 'upsertProfile') + .mockResolvedValue(replayData) + const rendered = await renderClientAsync( + ({})} + beforeInitialPage={{ run: () => undefined }} + > +
preview
+
, + ) + expect(upsert).not.toHaveBeenCalled() + const sdk = window.contentfulOptimization + if (sdk === undefined) throw new Error('Expected the live singleton.') + await act(async () => { + sdk.consent(true) + await Promise.resolve() + await Promise.resolve() + }) + expect(upsert).toHaveBeenCalledTimes(1) + rendered.unmount() }) - it('does not deliberately duplicate callback or page work during StrictMode effect replay', async () => { + it('does not duplicate initial callback or page work in StrictMode', async () => { const run = rs.fn(() => undefined) - const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + const track = rs + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const rendered = await renderClientAsync( {createBeforeInitialPageRoot({ beforeInitialPage: { run } })}, ) - + await flushMicrotasks() expect(run).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) - + expect(track).toHaveBeenCalledTimes(1) rendered.unmount() }) + + it.each([0, -1, Number.POSITIVE_INFINITY, Number.NaN])( + 'rejects invalid callback wait %s before initialization', + (maxWaitMs) => { + expect(() => + renderToString( + createBeforeInitialPageRoot({ beforeInitialPage: { run: () => undefined, maxWaitMs } }), + ), + ).toThrow('beforeInitialPage.maxWaitMs must be a positive finite number.') + }, + ) }) diff --git a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx index 804c9fe7e..4dd7e30c3 100644 --- a/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx +++ b/packages/web/frameworks/react-web-sdk/src/root/OptimizationRoot.tsx @@ -1,16 +1,5 @@ -import type { - TrackCurrentPageOptions, - TrackCurrentPageSkipOptions, -} from '@contentful/optimization-web' -import type { ContentOptimizationHandoff } from '@contentful/optimization-web/handoff' -import { - useCallback, - useEffect, - useRef, - useState, - type PropsWithChildren, - type ReactElement, -} from 'react' +import type { TrackCurrentPageOptions } from '@contentful/optimization-web' +import { useRef, type ReactElement } from 'react' import type { AutoPagePayload } from '../auto-page/types' import { useAutoPageEmitter } from '../auto-page/useAutoPageEmitter' @@ -19,12 +8,10 @@ import { runBeforeInitialPage, type BeforeInitialPageOptions, } from '../before-initial-page/beforeInitialPage' -import { BeforeInitialPageContext } from '../context/BeforeInitialPageContext' -import { useOptimizationContext } from '../hooks/useOptimization' -import { createScopedLogger } from '../logger' import { LiveUpdatesProvider } from '../provider/LiveUpdatesProvider' import { OptimizationProvider, + type InitializeProviderSdk, type OptimizationProviderConfigProps, } from '../provider/OptimizationProvider' @@ -42,7 +29,7 @@ interface OptimizationRootWithoutBeforeInitialPageProps { interface OptimizationRootWithBeforeInitialPageProps { readonly beforeInitialPage: BeforeInitialPageOptions readonly routeKey: string - readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] + readonly buildPagePayload: NonNullable readonly initialPagePayload?: never } @@ -50,302 +37,77 @@ export type OptimizationRootProps = OptimizationProviderConfigProps & OptimizationRootCommonProps & (OptimizationRootWithoutBeforeInitialPageProps | OptimizationRootWithBeforeInitialPageProps) -type DefaultOptimizationRootProps = OptimizationProviderConfigProps & - OptimizationRootCommonProps & - OptimizationRootWithoutBeforeInitialPageProps - -type BeforeInitialPageOptimizationRootProps = OptimizationProviderConfigProps & - OptimizationRootCommonProps & - OptimizationRootWithBeforeInitialPageProps & { - readonly maxWaitMs: number - } - -const logger = createScopedLogger('React:OptimizationRoot') - -function InitialHandoffPageEmitter({ - buildPagePayload, - handoff, - routeKey, -}: { - readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] - readonly handoff: ContentOptimizationHandoff - readonly routeKey: string -}): null { - useAutoPageEmitter({ - buildPayload: buildPagePayload, - enabled: true, - initialPageEvent: handoff.initialPageEvent, - routeKey, - }) - - return null -} - -type InitialHandoffPageEmitterProps = Parameters[0] - -function MissingInitialPagePayloadWarning(): null { - useEffect(() => { - logger.warn( - 'OptimizationRoot handoff requested initial page emission without routeKey and buildPagePayload; skipping initial page event.', - ) - }, []) - - return null -} - -function resolveInitialPageEmitterProps({ - buildPagePayload, - handoff, - initialPagePayload, - initialRouteKey, - routeKey, -}: { - readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff - readonly initialPagePayload?: AutoPagePayload - readonly initialRouteKey?: string - readonly routeKey?: string -}): InitialHandoffPageEmitterProps | undefined { - if (handoff === undefined) return undefined - - const resolvedBuildPagePayload = - buildPagePayload ?? (initialPagePayload === undefined ? undefined : () => initialPagePayload) - - if (routeKey !== undefined && resolvedBuildPagePayload !== undefined) { - return { buildPagePayload: resolvedBuildPagePayload, handoff, routeKey } - } - - if ( - handoff.initialPageEvent === 'skip' && - initialRouteKey !== undefined && - resolvedBuildPagePayload === undefined - ) { - return { handoff, routeKey: initialRouteKey } - } - - return undefined -} - -function shouldWarnMissingInitialPagePayload({ - buildPagePayload, - handoff, - initialPagePayload, - routeKey, -}: { - readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff - readonly initialPagePayload?: AutoPagePayload - readonly routeKey?: string -}): boolean { - return ( - handoff?.initialPageEvent === 'emit' && - (routeKey === undefined || (buildPagePayload === undefined && initialPagePayload === undefined)) - ) -} - -function DefaultOptimizationRoot({ - buildPagePayload, - children, - handoff, - initialPagePayload, - liveUpdates = false, - routeKey, - ...providerProps -}: DefaultOptimizationRootProps): ReactElement { - const initialRouteKey = useRef(undefined) - initialRouteKey.current ??= routeKey - const initialPageEmitterProps = resolveInitialPageEmitterProps({ - buildPagePayload, +/** Preview content and ordinary routing do not wait for initial event delivery. */ +export function OptimizationRoot(props: OptimizationRootProps): ReactElement { + const { + children, handoff, - initialPagePayload, - initialRouteKey: initialRouteKey.current, - routeKey, - }) - const shouldWarnMissingPayload = shouldWarnMissingInitialPagePayload({ + beforeInitialPage, buildPagePayload, - handoff, initialPagePayload, + liveUpdates = false, routeKey, - }) - + ...providerProps + } = props + const latest = useRef({ routeKey, buildPagePayload, initialPagePayload, beforeInitialPage }) + latest.current = { routeKey, buildPagePayload, initialPagePayload, beforeInitialPage } + const maxWaitMs = + beforeInitialPage === undefined + ? undefined + : resolveBeforeInitialPageMaxWaitMs(beforeInitialPage.maxWaitMs) + const initializeSdk: InitializeProviderSdk | undefined = + routeKey === undefined || (handoff === undefined && beforeInitialPage === undefined) + ? undefined + : async (sdk, ready, isCurrent) => { + const currentPage = (): TrackCurrentPageOptions => ({ + routeKey: latest.current.routeKey ?? routeKey, + buildPayload: + latest.current.buildPagePayload ?? + (latest.current.initialPagePayload === undefined + ? undefined + : () => latest.current.initialPagePayload), + }) + return await sdk.hydrateAndTrackCurrentPage(handoff, { + ...currentPage(), + getCurrentPage: currentPage, + isCurrent, + onHydrated: ready, + beforeInitialPage: async () => { + const { + current: { beforeInitialPage: callback }, + } = latest + if (callback !== undefined) + await runBeforeInitialPage( + sdk, + callback, + maxWaitMs ?? resolveBeforeInitialPageMaxWaitMs(callback.maxWaitMs), + ) + }, + }) + } return ( - - {shouldWarnMissingPayload ? : null} - {initialPageEmitterProps ? : null} + + {initializeSdk !== undefined && routeKey !== undefined ? ( + initialPagePayload) + } + /> + ) : null} {children} ) } -interface BeforeInitialPageSequenceState { - readonly emitterRouteKey: string - readonly isReady: boolean -} - -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)) -} - -function logInitialPageError(error: unknown): void { - try { - logger.error('OptimizationRoot failed to track the initial browser page.', toError(error)) - } catch { - // Logging is best-effort and must not block readiness. - } -} - -function BeforeInitialPageSequence({ - buildPagePayload, - children, - handoff, - beforeInitialPage, - initialRouteKey, - maxWaitMs, +function PageEmitter({ routeKey, -}: PropsWithChildren<{ - readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] - readonly handoff?: ContentOptimizationHandoff - readonly beforeInitialPage: BeforeInitialPageOptions - readonly initialRouteKey: string - readonly maxWaitMs: number - readonly routeKey: string -}>): ReactElement { - const { error, isLive, sdk } = useOptimizationContext() - const [sequenceState, setSequenceState] = useState({ - emitterRouteKey: initialRouteKey, - isReady: false, - }) - const buildPagePayloadRef = useRef(buildPagePayload) - const currentRuntimeRef = useRef(sdk) - const currentRuntimeIsLiveRef = useRef(isLive === true) - const initialHandoffRef = useRef(handoff) - const beforeInitialPageRef = useRef(beforeInitialPage) - const initialMaxWaitMsRef = useRef(maxWaitMs) - const lastObservedRouteKeyRef = useRef(routeKey) - const mountedRef = useRef(false) - const routeKeyRef = useRef(routeKey) - const sequenceStartedRef = useRef(false) - buildPagePayloadRef.current = buildPagePayload - currentRuntimeRef.current = sdk - currentRuntimeIsLiveRef.current = isLive === true - routeKeyRef.current = routeKey - - const buildLatestPagePayload = useCallback( - (metadata) => buildPagePayloadRef.current(metadata), - [], - ) - - useEffect(() => { - mountedRef.current = true - - return () => { - mountedRef.current = false - } - }, []) - - useEffect(() => { - if (sequenceStartedRef.current || !isLive || sdk === undefined) return - - sequenceStartedRef.current = true - const sequenceRuntime = sdk - const isCurrentRuntime = (): boolean => - mountedRef.current && - currentRuntimeIsLiveRef.current && - currentRuntimeRef.current === sequenceRuntime - - void (async () => { - await runBeforeInitialPage( - sequenceRuntime, - beforeInitialPageRef.current, - initialMaxWaitMsRef.current, - ) - - if (!isCurrentRuntime()) return - - const { current: attemptedRouteKey } = routeKeyRef - const { current: initialHandoff } = initialHandoffRef - const canSkipDirectPage = - initialHandoff !== undefined && - error === undefined && - initialHandoff.initialPageEvent === 'skip' && - attemptedRouteKey === initialRouteKey - const pageOptions: TrackCurrentPageOptions | TrackCurrentPageSkipOptions = canSkipDirectPage - ? { initialPageEvent: 'skip', routeKey: attemptedRouteKey } - : { - buildPayload: buildPagePayloadRef.current, - initialPageEvent: 'emit', - routeKey: attemptedRouteKey, - } - - try { - await sequenceRuntime.trackCurrentPage(pageOptions) - } catch (pageError: unknown) { - logInitialPageError(pageError) - } - - if (!isCurrentRuntime()) return - - const { current: currentRouteKey } = routeKeyRef - lastObservedRouteKeyRef.current = currentRouteKey - setSequenceState({ emitterRouteKey: attemptedRouteKey, isReady: true }) - })() - }, [error, initialRouteKey, isLive, sdk]) - - useAutoPageEmitter({ - buildPayload: buildLatestPagePayload, - enabled: sequenceState.isReady, - initialPageEvent: 'skip', - routeKey: sequenceState.emitterRouteKey, - }) - - useEffect(() => { - if (!sequenceState.isReady || lastObservedRouteKeyRef.current === routeKey) return - - lastObservedRouteKeyRef.current = routeKey - setSequenceState({ emitterRouteKey: routeKey, isReady: true }) - }, [routeKey, sequenceState.isReady]) - - return ( - - {children} - - ) -} - -function BeforeInitialPageOptimizationRoot({ buildPagePayload, - children, - handoff, - beforeInitialPage, - liveUpdates = false, - maxWaitMs, - routeKey, - ...providerProps -}: BeforeInitialPageOptimizationRootProps): ReactElement { - const initialRouteKey = useRef(routeKey) - - return ( - - - {children} - - - ) -} - -export function OptimizationRoot(props: OptimizationRootProps): ReactElement { - if (props.beforeInitialPage === undefined) { - return - } - - const maxWaitMs = resolveBeforeInitialPageMaxWaitMs(props.beforeInitialPage.maxWaitMs) - - return +}: { + readonly routeKey: string + readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] +}): null { + useAutoPageEmitter({ routeKey, buildPayload: buildPagePayload, enabled: true }) + return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx index c588ac6cc..7fce2097a 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx @@ -2,7 +2,6 @@ import ContentfulOptimization from '@contentful/optimization-web' import { rs } from '@rstest/core' import { act, StrictMode, useEffect, useLayoutEffect, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import type { BeforeInitialPageOptions } from '../before-initial-page/beforeInitialPage' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' @@ -156,7 +155,6 @@ describe('NextAppAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() setCurrentRoute('/') currentRouterState = routerState }) @@ -269,7 +267,7 @@ describe('NextAppAutoPageTracker', () => { it('keeps the attempted latest route across readiness without suppressing a later route', async () => { const callback = createDeferred() const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const beforeInitialPage = { run: async () => { @@ -291,28 +289,25 @@ describe('NextAppAutoPageTracker', () => { await Promise.resolve() }) - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) expect(trackCurrentPage).toHaveBeenNthCalledWith(1, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/', - }) - expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { - initialPageEvent: 'skip', - routeKey: '/', + properties: { path: '/', query: {}, search: '', url: `${window.location.origin}/` }, }) setCurrentRoute('/page-two', new URLSearchParams('beforeInitialPage=readiness'), false) await rendered.rerender() - expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenCalledTimes(1) setCurrentRoute('/page-two') await rendered.rerender() - expect(trackCurrentPage).toHaveBeenCalledTimes(3) - expect(trackCurrentPage).toHaveBeenNthCalledWith(3, { - buildPayload: expect.any(Function), - initialPageEvent: 'emit', - routeKey: '/page-two', + expect(trackCurrentPage).toHaveBeenCalledTimes(2) + expect(trackCurrentPage).toHaveBeenNthCalledWith(2, { + properties: { + path: '/page-two', + query: {}, + search: '', + url: `${window.location.origin}/page-two`, + }, }) await rendered.unmount() @@ -380,35 +375,6 @@ describe('NextAppAutoPageTracker', () => { await rendered.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { - const page = rs.fn(async () => { - await Promise.resolve() - return undefined - }) - const sdk = createOptimizationSdk({ page }) - const rendered = await renderTracker(, sdk) - - expect(page).not.toHaveBeenCalled() - - setCurrentRoute('/products') - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - setCurrentRoute('/') - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(2) - - await rendered.unmount() - }) - it('merges static and dynamic payloads for each emission', async () => { const page = rs.fn(async () => { await Promise.resolve() diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx index 3eb34cecd..09a217ac3 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.tsx @@ -45,6 +45,7 @@ export interface NextAppAutoPageContext { } export interface NextAppAutoPageTrackerProps extends AutoPagePayloadOptions { + /** @deprecated This legacy input is inert. */ readonly initialPageEvent?: InitialAutoPageEvent } @@ -143,7 +144,6 @@ export function useNextAppAutoPageInputs({ } export function NextAppAutoPageTracker({ - initialPageEvent, pagePayload, getPagePayload, }: NextAppAutoPageTrackerProps): ReactElement | null { @@ -155,7 +155,6 @@ export function NextAppAutoPageTracker({ useAutoPageEmitter({ buildPayload: buildPagePayload, enabled: true, - initialPageEvent, routeKey, }) diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx index 8abfd2da5..79fbbf9ee 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx @@ -1,7 +1,6 @@ import { rs } from '@rstest/core' import { act, StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -63,7 +62,6 @@ describe('NextPagesAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() routerState.asPath = '/' routerState.isReady = true routerState.pathname = '/' @@ -139,37 +137,6 @@ describe('NextPagesAutoPageTracker', () => { await rendered.unmount() }) - it('skips only the initial route when server rendering already emitted its page event', async () => { - const page = rs.fn(async () => { - await Promise.resolve() - return undefined - }) - const sdk = createOptimizationSdk({ page }) - const rendered = await renderTracker(, sdk) - - expect(page).not.toHaveBeenCalled() - - routerState.asPath = '/products' - routerState.pathname = '/products' - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(1) - - routerState.asPath = '/' - routerState.pathname = '/' - - await rendered.rerender() - - expect(page).toHaveBeenCalledTimes(2) - - await rendered.unmount() - }) - it('waits until the router is ready', async () => { const page = rs.fn(async () => { await Promise.resolve() diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx index e568e7141..3e9c4315b 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.tsx @@ -47,11 +47,11 @@ export interface NextPagesAutoPageContext { } export interface NextPagesAutoPageTrackerProps extends AutoPagePayloadOptions { + /** @deprecated This legacy input is inert. */ readonly initialPageEvent?: InitialAutoPageEvent } export function NextPagesAutoPageTracker({ - initialPageEvent, pagePayload, getPagePayload, }: NextPagesAutoPageTrackerProps): ReactElement | null { @@ -91,7 +91,7 @@ export function NextPagesAutoPageTracker({ [asPath, getPagePayload, pagePayload, pathname, query, routeKey, router, routerPayload], ) - useAutoPageEmitter({ enabled: isReady, initialPageEvent, routeKey, buildPayload }) + useAutoPageEmitter({ enabled: isReady, routeKey, buildPayload }) return null } diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx index 775210800..b39ff8333 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx @@ -1,7 +1,6 @@ import { rs } from '@rstest/core' import { act, StrictMode, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -65,7 +64,6 @@ describe('ReactRouterAutoPageTracker', () => { }) beforeEach(() => { - resetAutoPageEmitterState() locationState.hash = '' locationState.key = 'default' locationState.pathname = '/' diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx index e5cfa4426..d069e22aa 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx @@ -9,7 +9,6 @@ import { } from '@tanstack/react-router' import { act, StrictMode, type ReactElement, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' -import { resetAutoPageEmitterState } from '../auto-page/useAutoPageEmitter' import { LiveUpdatesContext } from '../context/LiveUpdatesContext' import { OptimizationContext } from '../context/OptimizationContext' import { createOptimizationSdk, defaultLiveUpdatesContext } from '../test/sdkTestUtils' @@ -102,10 +101,6 @@ async function navigateTo(router: TestRouter, path: string): Promise { } describe('TanStackRouterAutoPageTracker', () => { - beforeEach(() => { - resetAutoPageEmitterState() - }) - it('is exported from the router subpath module', () => { expect(TanStackRouterAutoPageTracker).toBeTypeOf('function') }) diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 24a94d079..2f38afaa8 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -209,12 +209,12 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): (async (options) => { const { routeKey } = options - if (options.initialPageEvent === 'skip') { - acceptedRouteKey = routeKey - return { accepted: true } - } - - if (!hasConsent('page') || acceptedRouteKey === routeKey || inFlightRouteKey === routeKey) { + if ( + options.isCurrent?.() === false || + !hasConsent('page') || + acceptedRouteKey === routeKey || + inFlightRouteKey === routeKey + ) { return { accepted: false } } @@ -223,7 +223,9 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): try { const { buildPayload } = options - const result = toEventEmissionResult(await page(buildPayload({ isInitialEmission }))) + const result = toEventEmissionResult( + await page(buildPayload?.({ isInitialEmission }) ?? {}), + ) if (result.accepted) { acceptedRouteKey = routeKey } @@ -297,6 +299,14 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}): return undefined }, trackCurrentPage, + hydrateAndTrackCurrentPage: async ( + _handoff: Parameters[0], + options: Parameters[1], + ) => { + options.onHydrated?.() + await options.beforeInitialPage?.() + return await trackCurrentPage(options.getCurrentPage?.() ?? options) + }, trackView: async () => { await Promise.resolve() return { accepted: true } diff --git a/packages/web/preview-panel/package.json b/packages/web/preview-panel/package.json index cb095a6f7..b028016cd 100644 --- a/packages/web/preview-panel/package.json +++ b/packages/web/preview-panel/package.json @@ -33,7 +33,7 @@ "bundleSize": { "gzipBudgets": { "contentful-optimization-web-preview-panel.umd.js": 37100, - "index.cjs": 24300, + "index.cjs": 24400, "index.mjs": 22700 } } diff --git a/packages/web/web-sdk/README.md b/packages/web/web-sdk/README.md index 8255c09e4..51e9eb111 100644 --- a/packages/web/web-sdk/README.md +++ b/packages/web/web-sdk/README.md @@ -100,6 +100,17 @@ The UMD build is available for HTML pages that do not use a bundler: ``` +When server rendering provides a content handoff, call `hydrateAndTrackCurrentPage()` on the same live instance. It applies preview state in memory and owns one replay/page decision. Matching accepted replay suppresses the ordinary page; otherwise the SDK attempts one ordinary page. Preview-backed rendering need not await delivery. Only a successful live Experience response can establish durable continuity when consent permits it: + +```js +await window.contentfulOptimization.hydrateAndTrackCurrentPage(handoff, { + routeKey, + buildPayload: () => ({ properties: { url: window.location.href } }), +}) +``` + +For a journal that does not end in an initial page, the Node request client accepts the complete ordered input through `previewExperience({ events })`. Experience inputs are preflighted once; Insights events are built on the server and retained for browser delivery. `createRequestHandoffFromPreview()` accepts that result without a route key when no page is present. The combined browser operation delivers that journal and makes the current-page decision. New handoffs preserve earlier delivery, while repeating the same handoff object shares its completion. + ### Usage with Web Components The optional Web Components entrypoint provides vanilla custom elements from the same package: @@ -138,7 +149,6 @@ const root = document.querySelector('ctfl-optimization-root') const entry = document.querySelector('ctfl-optimized-entry') root.defaults = { consent: true } -root.api = { preflight: false } root.contentful = { client: contentfulClient } root.trackEntryInteraction = { hovers: false } root.prefetchManagedEntries = [{ contentType: 'page', slug: 'home' }] @@ -239,13 +249,13 @@ the Insights API for event ingestion. Common `api` options: -| Option | Required? | Default | Description | -| ------------------- | --------- | ---------------------------------------------------------------- | ----------------------------------------------------- | -| `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | -| `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | -| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Experience API features to apply to each request | -| `preflight` | No | `false` | Aggregate a new profile state without storing it | -| `plainText` | No | `true` for single-profile mutations; batch mutations use `false` | Sends eligible Experience API mutations as plain text | +| Option | Required? | Default | Description | +| ------------------- | --------- | ---------------------------------------------------------------- | -------------------------------------------------------------- | +| `experienceBaseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Experience API features to apply to each request | +| `preflight` | No | `false` | Deprecated compatibility input; inert for the stateful Web SDK | +| `plainText` | No | `true` for single-profile mutations; batch mutations use `false` | Sends eligible Experience API mutations as plain text | Common `fetchOptions` are `fetchMethod`, `requestTimeout`, `retries`, `intervalTimeout`, `onFailedAttempt`, and `onRequestTimeout`. Default retries intentionally apply only to HTTP `503` diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index fa50bb739..0f053b8d7 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": 38200, + "index.cjs": 12600, + "index.mjs": 12800, "bridge-support.cjs": 700, - "bridge-support.mjs": 300, + "bridge-support.mjs": 200, "runtime.cjs": 1000, - "runtime.mjs": 500, - "handoff.cjs": 1700, - "handoff.mjs": 1600, - "analytics.cjs": 12900, - "analytics.mjs": 14100, - "contentful-optimization-web-components.umd.js": 42100, + "runtime.mjs": 400, + "handoff.cjs": 1600, + "handoff.mjs": 1200, + "analytics.cjs": 12700, + "analytics.mjs": 13000, + "contentful-optimization-web-components.umd.js": 43700, "presentation.cjs": 3600, - "presentation.mjs": 3500, - "tracking-attributes.cjs": 1000, - "tracking-attributes.mjs": 700, - "web-components.cjs": 17200, - "web-components.mjs": 18700 + "presentation.mjs": 3200, + "tracking-attributes.cjs": 900, + "tracking-attributes.mjs": 600, + "web-components.cjs": 17900, + "web-components.mjs": 19000 } } }, diff --git a/packages/web/web-sdk/src/ContentfulOptimization.test.ts b/packages/web/web-sdk/src/ContentfulOptimization.test.ts index 96fac3712..1cf2436d5 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.test.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.test.ts @@ -1,4 +1,10 @@ -import { batch, signals, type CoreConfig } from '@contentful/optimization-core' +import { + batch, + EventBuilder, + signals, + type CoreConfig, + type OptimizationReplayEnvelope, +} from '@contentful/optimization-core' import type { OptimizationData, Profile } from '@contentful/optimization-core/api-schemas' import { ANONYMOUS_ID_COOKIE, @@ -11,8 +17,14 @@ import { import ContentfulOptimization from './ContentfulOptimization' import { OPTIMIZATION_WEB_SDK_NAME } from './constants' import { EntryInteractionRuntime } from './entry-tracking/EntryInteractionRuntime' +import type { ContentOptimizationHandoff } from './handoff' import { getCookie, removeCookie, setCookie } from './lib/cookies' +import LocalStore from './storage/LocalStore' import { deferred } from './test/helpers' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const SPACE_ID = 'key_123' const ENVIRONMENT = 'main' @@ -22,6 +34,25 @@ const config: CoreConfig = { environment: ENVIRONMENT, } +function createReplay(routeKey: string): OptimizationReplayEnvelope { + return { + experience: [ + replayEventBuilder.buildIdentify({ userId: 'handoff-user' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey, + } +} + +function createContentHandoff(replay?: OptimizationReplayEnvelope): ContentOptimizationHandoff { + return { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + replay, + } +} + function compileManagedEntryDescriptorApis(web: ContentfulOptimization): void { const descriptor = { contentType: 'page', slug: 'home' } as const @@ -566,61 +597,402 @@ describe('ContentfulOptimization', () => { expect(upsertProfile).toHaveBeenCalledTimes(1) }) - it('can mark an SSR-emitted initial current page as accepted', async () => { + it('treats the legacy skip input as inert and still deduplicates the same route', async () => { const web = new ContentfulOptimization(config) const upsertProfile = rs .spyOn(web.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const legacyPayload = rs.fn(() => ({ properties: { legacy: true } })) + const deduplicatedPayload = rs.fn(() => ({})) await expect( web.trackCurrentPage({ + buildPayload: legacyPayload, initialPageEvent: 'skip', routeKey: '/', }), - ).resolves.toEqual({ accepted: true }) + ).resolves.toEqual({ accepted: true, data: EMPTY_OPTIMIZATION_DATA }) await expect( web.trackCurrentPage({ routeKey: '/', - buildPayload: () => ({}), + buildPayload: deduplicatedPayload, }), ).resolves.toEqual({ accepted: false }) - expect(upsertProfile).not.toHaveBeenCalled() + expect(upsertProfile).toHaveBeenCalledTimes(1) + expect(legacyPayload).toHaveBeenCalledTimes(1) + expect(deduplicatedPayload).not.toHaveBeenCalled() }) - it('can mark an SSR-emitted current page as accepted after another route', async () => { + it('shares one matching operation and suppresses ordinary concurrent and later pages', async () => { const web = new ContentfulOptimization(config) - const upsertProfile = rs + const response = Promise.withResolvers() + const upsert = rs.spyOn(web.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const handoff = createContentHandoff(createReplay('/replay')) + const payload = rs.fn(() => ({ properties: { fallback: true } })) + const first = web.hydrateAndTrackCurrentPage(handoff, { + routeKey: '/replay', + buildPayload: payload, + }) + const duplicate = web.hydrateAndTrackCurrentPage(handoff, { routeKey: '/replay' }) + const concurrentPage = web.trackCurrentPage({ routeKey: '/replay' }) + response.resolve(EMPTY_OPTIMIZATION_DATA) + await expect(concurrentPage).resolves.toEqual({ accepted: false }) + await duplicate + await expect(first).resolves.toMatchObject({ accepted: true }) + await expect(web.trackCurrentPage({ routeKey: '/replay' })).resolves.toEqual({ + accepted: false, + }) + expect(upsert).toHaveBeenCalledTimes(1) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual([ + 'identify', + 'page', + ]) + expect(payload).not.toHaveBeenCalled() + }) + + it('rejects unsafe replay before applying state or emitting', async () => { + const web = new ContentfulOptimization(config) + const upsert = rs.spyOn(web.api.experience, 'upsertProfile') + await expect( + web.hydrateAndTrackCurrentPage( + { cache: { scope: 'static' }, hydration: 'preserve-server', replay: createReplay('/a') }, + { routeKey: '/a' }, + ), + ).rejects.toThrow('must not be included in public or static caches') + expect(upsert).not.toHaveBeenCalled() + }) + + it('publishes state readiness without persisting preview before live completion', async () => { + const web = new ContentfulOptimization({ + ...config, + defaults: { consent: true, persistenceConsent: true }, + }) + const response = Promise.withResolvers() + rs.spyOn(web.api.experience, 'upsertProfile').mockReturnValue(response.promise) + const ready = Promise.withResolvers() + const operation = web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/a')), state: EMPTY_OPTIMIZATION_DATA }, + { + routeKey: '/a', + onHydrated: () => { + ready.resolve(undefined) + }, + }, + ) + await ready.promise + expect(web.states.profile.current).toEqual(EMPTY_OPTIMIZATION_DATA.profile) + expect(LocalStore.profile).toBeUndefined() + expect(getCookie(ANONYMOUS_ID_COOKIE)).toBeUndefined() + response.resolve(EMPTY_OPTIMIZATION_DATA) + await operation + expect(LocalStore.profile).toEqual(EMPTY_OPTIMIZATION_DATA.profile) + expect(LocalStore.anonymousId).toBe(EMPTY_OPTIMIZATION_DATA.profile.id) + }) + + it('continues replay after a recoverable hydration failure', async () => { + const web = new ContentfulOptimization(config) + const error = new Error('state apply failed') + let initial = true + web.interceptors.state.add((state) => { + if (initial) { + initial = false + throw error + } + return state + }) + const onError = rs.fn() + const upsert = rs .spyOn(web.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - const skippedPayload = rs.fn(() => ({})) - const dedupedPayload = rs.fn(() => ({})) - await expect( - web.trackCurrentPage({ - routeKey: '/', - buildPayload: () => ({}), + web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/a')), state: { profile: DEFAULT_PROFILE } }, + { routeKey: '/a', onHydrated: onError }, + ), + ).resolves.toMatchObject({ accepted: true }) + expect(onError).toHaveBeenCalledWith(error) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual([ + 'identify', + 'page', + ]) + }) + + it('does not cancel an older event journal when a newer handoff arrives', async () => { + const web = new ContentfulOptimization(config) + const hydration = deferred() + let firstState = true + web.interceptors.state.add(async (state) => { + if (firstState) { + firstState = false + await hydration.promise + } + return state + }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const first = web.hydrateAndTrackCurrentPage( + { + ...createContentHandoff({ + experience: [ + replayEventBuilder.buildIdentify({ userId: 'first' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: '/a', + }), + state: { profile: DEFAULT_PROFILE }, + }, + { routeKey: '/a' }, + ) + await web.hydrateAndTrackCurrentPage( + createContentHandoff({ + experience: [ + replayEventBuilder.buildIdentify({ userId: 'second' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: '/a', }), - ).resolves.toEqual({ accepted: true, data: EMPTY_OPTIMIZATION_DATA }) - await expect( - web.trackCurrentPage({ - initialPageEvent: 'skip', - routeKey: '/page-two', - buildPayload: skippedPayload, + { routeKey: '/a' }, + ) + hydration.resolve() + await first + expect(upsert).toHaveBeenCalledTimes(2) + expect( + upsert.mock.calls.map(([payload]) => String(Reflect.get(payload.events[0] ?? {}, 'userId'))), + ).toEqual(['second', 'first']) + }) + + it('keeps an admitted journal when routing changes during hydration', async () => { + const web = new ContentfulOptimization(config) + const hydration = deferred() + let initial = true + web.interceptors.state.add(async (state) => { + if (initial) { + initial = false + await hydration.promise + } + return state + }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + let routeKey = '/server' + const before = rs.fn(async () => { + await Promise.resolve() + }) + const operation = web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/server')), state: { profile: DEFAULT_PROFILE } }, + { + routeKey, + getCurrentPage: () => ({ + routeKey, + buildPayload: () => ({ properties: { path: routeKey } }), + }), + beforeInitialPage: before, + }, + ) + routeKey = '/browser' + hydration.resolve() + await operation + expect(before).not.toHaveBeenCalled() + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual([ + 'identify', + 'page', + ]) + }) + + it('uses current router inputs for fallback after a mismatch at admission', async () => { + const web = new ContentfulOptimization(config) + const hydration = deferred() + let initial = true + web.interceptors.state.add(async (state) => { + if (initial) { + initial = false + await hydration.promise + } + return state + }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + let routeKey = '/browser-a' + const operation = web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/server')), state: { profile: DEFAULT_PROFILE } }, + { + routeKey, + getCurrentPage: () => ({ + routeKey, + buildPayload: () => ({ properties: { path: routeKey } }), + }), + }, + ) + routeKey = '/browser-b' + hydration.resolve() + await expect(operation).resolves.toMatchObject({ accepted: true }) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual(['page']) + expect(Reflect.get(upsert.mock.calls[0]?.[0].events[0] ?? {}, 'properties')).toMatchObject({ + path: '/browser-b', + }) + }) + + it('does not let an older accepted replay replace the newer route deduplication', async () => { + const web = new ContentfulOptimization(config) + const older = deferred() + let initial = true + web.interceptors.state.add(async (state) => { + if (initial) { + initial = false + await older.promise + } + return state + }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const first = web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/older')), state: { profile: DEFAULT_PROFILE } }, + { routeKey: '/older' }, + ) + await web.hydrateAndTrackCurrentPage(createContentHandoff(createReplay('/newer')), { + routeKey: '/newer', + }) + older.resolve() + await first + await web.trackCurrentPage({ routeKey: '/newer' }) + expect(upsert).toHaveBeenCalledTimes(2) + }) + + it('makes an ordinary page attempt for malformed private replay instructions', async () => { + const web = new ContentfulOptimization(config) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const handoff: unknown = { + cache: { scope: 'private-request' }, + hydration: 'preserve-server', + replay: { routeKey: '/a', experience: null, insights: [] }, + } + const operation: unknown = Reflect.apply(web.hydrateAndTrackCurrentPage, web, [ + handoff, + { routeKey: '/a' }, + ]) + await expect(operation).resolves.toMatchObject({ accepted: true }) + expect(upsert.mock.calls[0]?.[0].events.map((event) => event.type)).toEqual(['page']) + }) + + it('keeps an Analytics-only handoff identity for the ordinary page and later live continuity', async () => { + const web = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + await web.hydrateAndTrackCurrentPage( + createContentHandoff({ + profile: { id: 'request-profile' }, + experience: [], + insights: [replayEventBuilder.buildClick({ componentId: 'entry' })], }), - ).resolves.toEqual({ accepted: true }) + { routeKey: '/a' }, + ) + expect(upsert.mock.calls[0]?.[0].profileId).toBe('request-profile') + await web.trackCurrentPage({ routeKey: '/later' }) + expect(upsert.mock.calls[1]?.[0].profileId).toBe(EMPTY_OPTIMIZATION_DATA.profile.id) + }) + + it('falls back once after replay failure', async () => { + const web = new ContentfulOptimization(config) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(EMPTY_OPTIMIZATION_DATA) await expect( - web.trackCurrentPage({ - routeKey: '/page-two', - buildPayload: dedupedPayload, - }), - ).resolves.toEqual({ accepted: false }) + web.hydrateAndTrackCurrentPage(createContentHandoff(createReplay('/a')), { routeKey: '/a' }), + ).resolves.toMatchObject({ accepted: true }) + expect(upsert).toHaveBeenCalledTimes(2) + expect(upsert.mock.calls[1]?.[0].events.map((event) => event.type)).toEqual(['page']) + }) - expect(upsertProfile).toHaveBeenCalledTimes(1) - expect(skippedPayload).not.toHaveBeenCalled() - expect(dedupedPayload).not.toHaveBeenCalled() + it('preserves page acceptance after a later malformed Analytics command', async () => { + const web = new ContentfulOptimization({ ...config, defaults: { consent: true } }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const handoff = createContentHandoff({ + routeKey: '/a', + experience: [replayEventBuilder.buildPageView({})], + insights: [replayEventBuilder.buildClick({ componentId: 'entry' })], + }) + web.interceptors.event.add((event) => { + if (event.type === 'component_click') throw new Error('analytics failed') + return event + }) + await expect( + web.hydrateAndTrackCurrentPage(handoff, { routeKey: '/a' }), + ).resolves.toMatchObject({ accepted: true }) + expect(upsert).toHaveBeenCalledTimes(1) }) + it('accepts offline replay once without persisting preview continuity', async () => { + const web = new ContentfulOptimization({ + ...config, + defaults: { consent: true, persistenceConsent: true }, + }) + const upsert = rs + .spyOn(web.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + signals.online.value = false + const handoff = { ...createContentHandoff(createReplay('/a')), state: EMPTY_OPTIMIZATION_DATA } + await expect(web.hydrateAndTrackCurrentPage(handoff, { routeKey: '/a' })).resolves.toEqual({ + accepted: true, + }) + await expect(web.trackCurrentPage({ routeKey: '/a' })).resolves.toEqual({ accepted: false }) + expect(upsert).not.toHaveBeenCalled() + expect(LocalStore.profile).toBeUndefined() + signals.online.value = true + await web.flush() + expect(upsert).toHaveBeenCalledTimes(1) + }) + + it.each(['reset', 'destroy'] as const)( + 'does not start canceled initial delivery after %s', + async (lifecycle) => { + const web = new ContentfulOptimization(config) + const hydration = deferred() + web.interceptors.state.add(async (state) => { + await hydration.promise + return state + }) + const upsert = rs.spyOn(web.api.experience, 'upsertProfile') + const operation = web.hydrateAndTrackCurrentPage( + { ...createContentHandoff(createReplay('/a')), state: { profile: DEFAULT_PROFILE } }, + { routeKey: '/a' }, + ) + web[lifecycle]() + hydration.resolve() + await expect(operation).resolves.toEqual({ accepted: false }) + expect(upsert).not.toHaveBeenCalled() + }, + ) + + it.each(['reset', 'destroy'] as const)( + 'does not start replay when state readiness tears down the runtime with %s', + async (lifecycle) => { + const web = new ContentfulOptimization(config) + const upsert = rs.spyOn(web.api.experience, 'upsertProfile') + await expect( + web.hydrateAndTrackCurrentPage(createContentHandoff(createReplay('/a')), { + routeKey: '/a', + onHydrated: () => { + web[lifecycle]() + }, + }), + ).resolves.toEqual({ accepted: false }) + expect(upsert).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..8e701a406 100644 --- a/packages/web/web-sdk/src/ContentfulOptimization.ts +++ b/packages/web/web-sdk/src/ContentfulOptimization.ts @@ -12,6 +12,7 @@ import { AcceptedCurrentStateTracker, + assertOptimizationCacheSafety, CoreStateful, effect, resolveStatefulDefaults, @@ -27,6 +28,7 @@ import { type CoreBridgeHost, } from '@contentful/optimization-core/bridge-support' import { ANONYMOUS_ID_COOKIE_LEGACY } from '@contentful/optimization-core/constants' +import { createScopedLogger } from '@contentful/optimization-core/logger' import { getPageProperties, getUserAgent } from './builders/EventBuilder' import { ANONYMOUS_ID_COOKIE, @@ -41,6 +43,11 @@ import { createOnlineChangeListener, createVisibilityChangeListener, } from './handlers' +import { + hydrateContentOptimizationHandoffState, + invalidateOptimizationHandoffHydration, + type BrowserOptimizationHandoff, +} from './handoff' import { getCookie, removeCookie, setCookie, type CookieAttributes } from './lib/cookies' import { clearProfilelessHandoffDurableContinuity, @@ -65,6 +72,32 @@ declare global { * * @internal */ +const logger = createScopedLogger('Web:Handoff') + +function readInitialCookieValues(canLoadPersistedContinuity: boolean): { + cookieValue?: string + legacyCookieValue?: string +} { + if (!canLoadPersistedContinuity) return {} + + const legacyCookieValue = getCookie(ANONYMOUS_ID_COOKIE_LEGACY) + + return { + cookieValue: legacyCookieValue ?? getCookie(ANONYMOUS_ID_COOKIE), + legacyCookieValue, + } +} + +function canPersistDurableContinuity(persistenceConsent: boolean | undefined): boolean { + const hasProfile = signals.profile.value !== undefined + + if (hasProfile && !isDurableContinuityPersistenceSuppressed()) { + clearProfilelessHandoffDurableContinuity() + } + + return persistenceConsent === true && !shouldSkipDurableContinuityPersistence(hasProfile) +} + const EXPIRATION_DAYS_DEFAULT = 365 /** @@ -133,37 +166,29 @@ 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. - */ + /** @deprecated This input is inert. Current-page tracking always emits when admitted. */ readonly initialPageEvent?: InitialCurrentPageEvent - /** - * Builds the page payload only when a page event will be emitted. - */ - readonly buildPayload: (metadata: CurrentPageEmissionMetadata) => PageViewBuilderArgs | undefined + /** Builds the page payload. Omit it to emit the legacy empty payload. */ + readonly buildPayload?: (metadata: CurrentPageEmissionMetadata) => PageViewBuilderArgs | undefined + /** Skip queued work when its owning route effect has been disposed. */ + readonly isCurrent?: () => boolean } -/** - * Skip-only options for {@link ContentfulOptimization.trackCurrentPage}. - * - * @public - */ -export interface TrackCurrentPageSkipOptions { - /** - * Stable route identity used for current-page deduplication. - */ - 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. - */ - readonly buildPayload?: TrackCurrentPageOptions['buildPayload'] +/** Initial handoff state and event delivery owned by one operation. @public */ +export interface HydrateAndTrackCurrentPageOptions extends TrackCurrentPageOptions { + /** Read router inputs after asynchronous hydration or prerequisite work. */ + readonly getCurrentPage?: () => TrackCurrentPageOptions + /** Runtime lifetime guard; newer handoffs do not cancel this operation. */ + readonly isCurrent?: () => boolean + /** Called after state hydration, before any replay events are emitted. */ + readonly onHydrated?: (error?: unknown) => void + /** Browser prerequisite work when no matching page replay supplies it. */ + readonly beforeInitialPage?: () => Promise } +/** @deprecated Use {@link TrackCurrentPageOptions}; skip-only tracking is inert. */ +export type TrackCurrentPageSkipOptions = TrackCurrentPageOptions + function resolveDefaultState( defaults: CoreStatefulConfig['defaults'] | undefined, ): NonNullable { @@ -176,20 +201,6 @@ function resolveDefaultState( }).defaults } -function readInitialCookieValues(canLoadPersistedContinuity: boolean): { - cookieValue?: string - legacyCookieValue?: string -} { - if (!canLoadPersistedContinuity) return {} - - const legacyCookieValue = getCookie(ANONYMOUS_ID_COOKIE_LEGACY) - - return { - cookieValue: legacyCookieValue ?? getCookie(ANONYMOUS_ID_COOKIE), - legacyCookieValue, - } -} - /** * Merge user-supplied Web configuration with sensible defaults for the * stateful core and browser environment. @@ -245,16 +256,6 @@ function mergeConfig({ return mergedConfig } -function canPersistDurableContinuity(persistenceConsent: boolean | undefined): boolean { - const hasProfile = signals.profile.value !== undefined - - if (hasProfile && !isDurableContinuityPersistenceSuppressed()) { - clearProfilelessHandoffDurableContinuity() - } - - return persistenceConsent === true && !shouldSkipDurableContinuityPersistence(hasProfile) -} - /** * Stateful Web SDK built on top of {@link CoreStateful}. * @@ -274,6 +275,12 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { declare readonly [CORE_BRIDGE_CAPABILITIES_SYMBOL]: CoreBridgeCapabilities private readonly currentPageTracker = new AcceptedCurrentStateTracker() + private readonly handoffOperations = new WeakMap< + BrowserOptimizationHandoff, + Promise + >() + private handoffLifetime = 0 + private initialPage: Promise | undefined = undefined /** * Tracked entry interaction runtime state and trackers. @@ -403,6 +410,7 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { clearProfilelessHandoffDurableContinuity() } + if (isDurableContinuityPersistenceSuppressed()) return if (persistenceConsent !== true) return LocalStore.profile = value @@ -490,6 +498,9 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { */ reset(): void { this.currentPageTracker.reset() + this.handoffLifetime += 1 + this.initialPage = undefined + invalidateOptimizationHandoffHydration() this.entryInteractionRuntime.reset() removeCookie(ANONYMOUS_ID_COOKIE, this.cookieAttributes) LocalStore.reset() @@ -498,36 +509,157 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { } /** - * Track the current browser page with route-key deduplication. - * - * @remarks - * This is intended for router integrations. Manual `page()` calls remain - * direct emits and are not deduplicated. - * + * Apply provisional state and make the initial replay/page decision once. + * State readiness is reported before delivery, so presentation need not await the result. + * Repeated calls with the same handoff share its completion; distinct handoffs keep their events. * @public */ - async trackCurrentPage( - options: TrackCurrentPageOptions | TrackCurrentPageSkipOptions, + async hydrateAndTrackCurrentPage( + handoff: BrowserOptimizationHandoff | undefined, + options: HydrateAndTrackCurrentPageOptions, ): Promise { - const { routeKey } = options + const existing = handoff === undefined ? undefined : this.handoffOperations.get(handoff) + if (existing !== undefined) return await existing + if (handoff !== undefined) assertOptimizationCacheSafety(handoff) + const { handoffLifetime: lifetime } = this + const operation = this.currentPageTracker + .emitIfNeeded({ + key: options.routeKey, + isAllowed: true, + deduplicate: false, + emit: async () => await this.emitInitialPage(handoff, options, lifetime), + }) + .then( + (result): EventEmissionResult => + result.accepted + ? result.data === undefined + ? { accepted: true } + : { accepted: true, data: result.data } + : { accepted: false }, + ) + this.initialPage = operation + if (handoff !== undefined) this.handoffOperations.set(handoff, operation) + return await operation + } - if (options.initialPageEvent === 'skip') { - this.currentPageTracker.markAccepted(routeKey) - return { accepted: true } + private isCurrentHandoff(options: HydrateAndTrackCurrentPageOptions, lifetime: number): boolean { + return lifetime === this.handoffLifetime && options.isCurrent?.() !== false + } + + private async hydrateInitialState( + handoff: BrowserOptimizationHandoff | undefined, + options: HydrateAndTrackCurrentPageOptions, + lifetime: number, + ): Promise { + let error: unknown = undefined + try { + if (handoff !== undefined) + await hydrateContentOptimizationHandoffState(this, handoff.state, { + isCurrent: () => this.isCurrentHandoff(options, lifetime), + suppressDurableContinuityPersistence: true, + }) + } catch (hydrationError: unknown) { + error = hydrationError + logger.warn('Handoff state could not be applied; continuing browser delivery.', error) + } + if (!this.isCurrentHandoff(options, lifetime)) return + options.onHydrated?.(error) + } + + private async emitInitialPage( + handoff: BrowserOptimizationHandoff | undefined, + options: HydrateAndTrackCurrentPageOptions, + lifetime: number, + ): Promise { + await this.hydrateInitialState(handoff, options, lifetime) + if (!this.isCurrentHandoff(options, lifetime)) return { accepted: false } + const replayResult = await this.tryInitialReplay(handoff, options.routeKey) + if (replayResult.accepted) return this.promoteCommittedCurrentPage(replayResult) + if (!this.isCurrentHandoff(options, lifetime)) return { accepted: false } + await options.beforeInitialPage?.() + if (!this.isCurrentHandoff(options, lifetime)) return { accepted: false } + const page = options.getCurrentPage?.() ?? options + if (page.routeKey !== options.routeKey) return await this.emitCurrentPage(page) + return await this.emitPage(page) + } + + private async tryInitialReplay( + handoff: BrowserOptimizationHandoff | undefined, + routeKey: string, + ): Promise { + try { + const replay = handoff?.replay + if ( + replay !== undefined && + (!replay.experience.some((event) => event.type === 'page') || replay.routeKey === routeKey) + ) { + return await this.replayOptimizationHandoff({ + ...replay, + profile: replay.profile ?? handoff?.state?.profile, + }) + } + } catch (error: unknown) { + logger.warn('Private replay failed; using ordinary page tracking.', error) } + return { accepted: false } + } - const { buildPayload } = options - const isInitialEmission = !this.currentPageTracker.hasAccepted() + /** Ordinary routing uses the existing accepted/in-flight route tracker. @public */ + async trackCurrentPage(options: TrackCurrentPageOptions): Promise { + const { handoffLifetime: lifetime } = this + await this.initialPage?.catch(() => undefined) + if (lifetime !== this.handoffLifetime || options.isCurrent?.() === false) + return { accepted: false } + return await this.emitCurrentPage(options) + } + + private async emitCurrentPage(options: TrackCurrentPageOptions): Promise { const result = await this.currentPageTracker.emitIfNeeded({ - key: routeKey, + key: options.routeKey, isAllowed: this.hasConsent('page'), - emit: async () => await this.page(buildPayload({ isInitialEmission }) ?? {}), + emit: async () => await this.emitPage(options), }) + return result.accepted + ? result.data === undefined + ? { accepted: true } + : { accepted: true, data: result.data } + : { accepted: false } + } + + private async emitPage(options: TrackCurrentPageOptions): Promise { + if (!this.hasConsent('page')) return { accepted: false } + return this.promoteCommittedCurrentPage( + await this.page( + options.buildPayload?.({ isInitialEmission: !this.currentPageTracker.hasAccepted() }) ?? {}, + ), + ) + } + + private promoteCommittedCurrentPage(result: EventEmissionResult): EventEmissionResult { + if (result.accepted && result.data !== undefined) this.persistCurrentDurableContinuity() + + return result + } + + private persistCurrentDurableContinuity(): void { + const { + changes: { value: changes }, + persistenceConsent: { value: persistenceConsent }, + profile: { value: profile }, + selectedOptimizations: { value: selectedOptimizations }, + } = signals + + if (persistenceConsent !== true) return + + if (profile !== undefined) clearProfilelessHandoffDurableContinuity() + + LocalStore.profile = profile + this.setAnonymousId(profile?.id ?? LocalStore.anonymousId) - if (!result.accepted) return { accepted: false } - if (result.data === undefined) return { accepted: true } + if (!canPersistDurableContinuity(persistenceConsent)) return - return { accepted: true, data: result.data } + LocalStore.changes = changes + LocalStore.selectedOptimizations = selectedOptimizations } /** @@ -538,6 +670,9 @@ class ContentfulOptimization extends CoreStateful implements CoreBridgeHost { * clear persisted user state. */ destroy(): void { + this.handoffLifetime += 1 + this.initialPage = undefined + invalidateOptimizationHandoffHydration() 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..09f378d14 100644 --- a/packages/web/web-sdk/src/analytics.test.ts +++ b/packages/web/web-sdk/src/analytics.test.ts @@ -1,4 +1,4 @@ -import { batch, InterceptorManager, signals } from '@contentful/optimization-core' +import { batch, EventBuilder, InterceptorManager, signals } from '@contentful/optimization-core' import type { ChangeArray, Profile, @@ -12,6 +12,10 @@ import { } from './analytics' import ContentfulOptimization from './ContentfulOptimization' import LocalStore from './storage/LocalStore' +const replayEventBuilder = new EventBuilder({ + channel: 'server', + library: { name: 'test-server', version: '1.0.0' }, +}) const config = { spaceId: 'key_123', @@ -98,7 +102,6 @@ function createAnalyticsHandoff( return { cache: { scope: 'private-request' }, hydration: 'analytics-only', - initialPageEvent: 'emit', state: { profile, selectedOptimizations, @@ -250,7 +253,50 @@ describe('Optimization analytics handoff runtime', () => { expect('fetchOptimizedEntry' in runtime).toBe(false) }) - it('does not track an older analytics route after a newer handoff starts', async () => { + it('stages and consumes an analytics handoff replay through ordinary page tracking', async () => { + const { fetchMethod, requests } = createFetchMethod() + runtime = initializeOptimizationAnalyticsRuntime({ + ...config, + defaults: { consent: true, persistenceConsent: true }, + fetchOptions: { fetchMethod }, + }) + + await hydrateOptimizationAnalyticsHandoff( + runtime, + createAnalyticsHandoff({ + replay: { + experience: [ + replayEventBuilder.buildIdentify({ userId: 'handoff-user' }), + replayEventBuilder.buildPageView({}), + ], + insights: [], + routeKey: '/segment-a', + }, + }), + { + buildPagePayload: () => ({ properties: { ordinary: true } }), + routeKey: '/segment-a', + }, + ) + + const pageRequest = requests.find((request) => request.url.includes('/profiles')) + expect(pageRequest?.body).toEqual( + expect.objectContaining({ + events: [ + expect.objectContaining({ type: 'identify' }), + expect.objectContaining({ type: 'page' }), + ], + }), + ) + const replayedPage = Reflect.get(pageRequest?.body ?? {}, 'events') + if (!Array.isArray(replayedPage)) throw new Error('Expected replayed analytics events.') + const pageEvent = replayedPage.find((event) => Reflect.get(event, 'type') === 'page') + expect(Reflect.get(pageEvent ?? {}, 'properties')).not.toEqual( + expect.objectContaining({ ordinary: true }), + ) + }) + + it('keeps latest state while each replay-less initialization makes its page attempt', async () => { const firstProfile = createProfile('first-profile') const secondProfile = createProfile('second-profile') const firstHydration = createDeferred() @@ -258,7 +304,7 @@ describe('Optimization analytics handoff runtime', () => { const firstPayload = rs.fn(() => ({})) const secondPayload = rs.fn(() => ({})) const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) const runInterceptors = InterceptorManager.prototype.run rs.spyOn(InterceptorManager.prototype, 'run').mockImplementation(async function run( @@ -303,16 +349,39 @@ describe('Optimization analytics handoff runtime', () => { await second expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(trackCurrentPage).toHaveBeenCalledWith({ - buildPayload: secondPayload, - initialPageEvent: 'emit', - routeKey: '/segment-b', - }) + expect(trackCurrentPage).toHaveBeenCalledWith({}) firstHydration.resolve() await first + expect(trackCurrentPage).toHaveBeenCalledTimes(2) + }) + + it('tracks the ordinary page when private-request analytics hydration fails', async () => { + const baselineSelectedOptimizations: SelectedOptimizationArray = [] + const hydrationError = new Error('handoff failed') + const warn = rs.spyOn(console, 'warn').mockImplementation(() => undefined) + const trackCurrentPage = rs + .spyOn(ContentfulOptimization.prototype, 'page') + .mockResolvedValue({ accepted: true }) + rs.spyOn(InterceptorManager.prototype, 'run').mockRejectedValue(hydrationError) + runtime = initializeOptimizationAnalyticsRuntime({ ...config, logLevel: 'warn' }) + signals.selectedOptimizations.value = baselineSelectedOptimizations + + await expect( + hydrateOptimizationAnalyticsHandoff(runtime, createAnalyticsHandoff(), { + buildPagePayload: () => ({ properties: { ordinary: true } }), + routeKey: '/segment-a', + }), + ).resolves.toBeUndefined() + + expect(signals.selectedOptimizations.value).toBe(baselineSelectedOptimizations) expect(trackCurrentPage).toHaveBeenCalledTimes(1) + expect(trackCurrentPage).toHaveBeenCalledWith({ properties: { ordinary: true } }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Handoff state could not be applied'), + hydrationError, + ) }) it('hydrates static profileless analytics state without overwriting durable continuity', async () => { @@ -327,7 +396,7 @@ describe('Optimization analytics handoff runtime', () => { ] const durableChanges: ChangeArray = [{ ...change, value: false }] const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) runtime = initializeOptimizationAnalyticsRuntime({ ...config, @@ -355,10 +424,10 @@ describe('Optimization analytics handoff runtime', () => { expect(LocalStore.selectedOptimizations).toEqual(durableSelectedOptimizations) }) - it('persists private-request analytics state to durable continuity', async () => { + it('keeps private-request analytics state in memory', async () => { const durableProfile = createProfile('durable-profile') const trackCurrentPage = rs - .spyOn(ContentfulOptimization.prototype, 'trackCurrentPage') + .spyOn(ContentfulOptimization.prototype, 'page') .mockResolvedValue({ accepted: true }) runtime = initializeOptimizationAnalyticsRuntime({ ...config, @@ -378,35 +447,9 @@ describe('Optimization analytics handoff runtime', () => { ) expect(trackCurrentPage).toHaveBeenCalledTimes(1) - expect(LocalStore.changes).toEqual(changes) + expect(LocalStore.changes).toBeUndefined() expect(LocalStore.profile).toEqual(durableProfile) - 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'), - ) + expect(LocalStore.selectedOptimizations).toBeUndefined() }) it('rejects content handoffs', async () => { @@ -418,7 +461,6 @@ describe('Optimization analytics handoff runtime', () => { { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'emit', }, { routeKey: '/', @@ -428,43 +470,26 @@ describe('Optimization analytics handoff runtime', () => { ).rejects.toThrow('analytics-only optimization handoffs') }) - it('rejects invalid initialPageEvent values', async () => { + it('fails closed for unsafe public and static analytics handoffs', async () => { + const trackCurrentPage = rs.spyOn(ContentfulOptimization.prototype, 'page') runtime = initializeOptimizationAnalyticsRuntime(config) - await expect( - Reflect.apply(hydrateOptimizationAnalyticsHandoff, undefined, [ - runtime, - { - ...createAnalyticsHandoff(), - initialPageEvent: 'invalid', - }, - { + for (const cache of [ + { scope: 'public-permutation', key: 'segment-a' }, + { scope: 'static' }, + ] as const) { + await expect( + hydrateOptimizationAnalyticsHandoff(runtime, createAnalyticsHandoff({ cache }), { routeKey: '/', buildPagePayload: () => ({}), - }, - ]), - ).rejects.toThrow('initialPageEvent') - }) - - it('rejects public profile state before hydrating browser signals', async () => { - runtime = initializeOptimizationAnalyticsRuntime(config) - - await expect( - hydrateOptimizationAnalyticsHandoff( - runtime, - createAnalyticsHandoff({ - cache: { scope: 'public-permutation', key: 'segment-a' }, }), - { - routeKey: '/', - buildPagePayload: () => ({}), - }, - ), - ).rejects.toThrow( - 'Profile state should not be included in public or static optimization caches.', - ) + ).rejects.toThrow( + 'Profile state should not be included in public or static optimization caches.', + ) + } expect(signals.profile.value).toBeUndefined() expect(signals.selectedOptimizations.value).toBeUndefined() + expect(trackCurrentPage).not.toHaveBeenCalled() }) }) diff --git a/packages/web/web-sdk/src/analytics.ts b/packages/web/web-sdk/src/analytics.ts index 58687a2cf..f3d708b5b 100644 --- a/packages/web/web-sdk/src/analytics.ts +++ b/packages/web/web-sdk/src/analytics.ts @@ -5,22 +5,14 @@ */ 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 type { AnalyticsOptimizationHandoff } from './handoff' const runtimeSdks = new WeakMap() -let latestAnalyticsHandoffHydration = 0 /** * Options used when hydrating an analytics-only handoff. @@ -30,8 +22,8 @@ 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`. */ - readonly buildPagePayload: TrackCurrentPageOptions['buildPayload'] + /** Builds the browser page payload. */ + readonly buildPagePayload?: TrackCurrentPageOptions['buildPayload'] /** Cancels async hydration before state apply or page tracking. */ readonly isCurrent?: () => boolean } @@ -62,22 +54,6 @@ function getRuntimeSdk(runtime: OptimizationAnalyticsRuntime): ContentfulOptimiz 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 +64,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. * @@ -142,28 +112,12 @@ 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 (!isCurrent()) return - - warnSkippedInitialPageWithoutProfileContinuity(sdk, handoff) - - if (!isCurrent()) return - - await runtime.trackCurrentPage({ + await sdk.hydrateAndTrackCurrentPage(handoff, { buildPayload: options.buildPagePayload, - initialPageEvent: handoff.initialPageEvent, routeKey: options.routeKey, + isCurrent: options.isCurrent, }) } diff --git a/packages/web/web-sdk/src/handoff.test.ts b/packages/web/web-sdk/src/handoff.test.ts index e642312bf..bdc6cf62d 100644 --- a/packages/web/web-sdk/src/handoff.test.ts +++ b/packages/web/web-sdk/src/handoff.test.ts @@ -73,7 +73,6 @@ function createContentHandoff( return { cache: { scope: 'static' }, hydration: 'preserve-server', - initialPageEvent: 'skip', state, ...overrides, } @@ -224,6 +223,33 @@ describe('hydrateOptimizationHandoff', () => { expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) }) + it('hydrates the live SDK state without a retained replay lifecycle', async () => { + const sdk = new ContentfulOptimization(config) + + await hydrateOptimizationHandoff(sdk, createContentHandoff({ changes, selectedOptimizations })) + + expect(sdk.states.selectedOptimizations.current).toEqual(selectedOptimizations) + expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) + }) + + it('hydrates private preview state without validating its optional replay', async () => { + const sdk = new ContentfulOptimization(config) + + await hydrateOptimizationHandoff( + sdk, + createContentHandoff( + { changes, selectedOptimizations }, + { + cache: { scope: 'private-request' }, + replay: { experience: [], insights: [], routeKey: '' }, + }, + ), + ) + + expect(sdk.states.selectedOptimizations.current).toEqual(selectedOptimizations) + expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) + }) + it('hydrates public handoff state through the Web handoff helper', async () => { const sdk = new ContentfulOptimization(config) @@ -273,7 +299,7 @@ describe('hydrateOptimizationHandoff', () => { }) }) - it('applies a full server profile when the handoff includes one', async () => { + it('applies a full server profile in memory when the handoff includes one', async () => { const existingProfile = createProfile('existing-profile') const serverProfile = createProfile('server-profile') const interceptedProfile = createProfile('intercepted-profile') @@ -306,9 +332,9 @@ describe('hydrateOptimizationHandoff', () => { expect(incomingProfiles).toEqual([serverProfile]) expect(sdk.states.profile.current).toEqual(interceptedProfile) expect(sdk.states.selectedOptimizations.current).toEqual(selectedOptimizations) - expect(LocalStore.changes).toEqual(changes) - expect(LocalStore.profile).toEqual(interceptedProfile) - expect(LocalStore.selectedOptimizations).toEqual(selectedOptimizations) + expect(LocalStore.changes).toBeUndefined() + expect(LocalStore.profile).toEqual(existingProfile) + expect(LocalStore.selectedOptimizations).toBeUndefined() }) it('keeps input handoff fields when an interceptor omits them', async () => { @@ -336,9 +362,6 @@ describe('hydrateOptimizationHandoff', () => { expect(sdk.states.profile.current).toEqual(serverProfile) expect(sdk.states.selectedOptimizations.current).toEqual(selectedOptimizations) - expect(LocalStore.changes).toEqual(changes) - expect(LocalStore.profile).toEqual(serverProfile) - expect(LocalStore.selectedOptimizations).toEqual(selectedOptimizations) }) it('applies present undefined handoff fields intentionally', async () => { @@ -509,6 +532,32 @@ describe('hydrateOptimizationHandoff', () => { expect(sdk.states.profile.current).toEqual(secondProfile) }) + it.each(['reset', 'destroy'] as const)( + 'cancels an in-flight handoff when SDK %s runs', + async (lifecycle) => { + const hydration = createDeferred() + const delayedProfile = createProfile('delayed-profile') + const sdk = new ContentfulOptimization(config) + sdk.interceptors.state.add(async (incoming) => { + await hydration.promise + return incoming + }) + + const handoff = hydrateOptimizationHandoff( + sdk, + createContentHandoff( + { changes, profile: delayedProfile, selectedOptimizations }, + { cache: { scope: 'private-request' } }, + ), + ) + sdk[lifecycle]() + hydration.resolve() + await handoff + + expect(sdk.states.profile.current).toBeUndefined() + }, + ) + it('rejects analytics-only handoffs', async () => { const sdk = new ContentfulOptimization(config) @@ -518,7 +567,6 @@ describe('hydrateOptimizationHandoff', () => { { 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..49d3e325d 100644 --- a/packages/web/web-sdk/src/handoff.ts +++ b/packages/web/web-sdk/src/handoff.ts @@ -10,6 +10,7 @@ import { hasOptimizationSelectionStateField, mergeOptimizationSelectionState, signals, + type ContentOptimizationHydrationMode, type OptimizationHandoff, type OptimizationSelectionState, } from '@contentful/optimization-core' @@ -23,14 +24,14 @@ import { * * @public */ -export type ContentOptimizationHydrationMode = 'preserve-server' | 'client-only-hidden-until-ready' +export type { ContentOptimizationHydrationMode } from '@contentful/optimization-core' /** * Browser hydration policy for content or analytics-only handoffs. * * @public */ -export type OptimizationHydrationMode = ContentOptimizationHydrationMode | 'analytics-only' +export type { OptimizationHydrationMode } from '@contentful/optimization-core' /** * Content-capable browser handoff. @@ -40,8 +41,8 @@ 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' + /** @deprecated This legacy input is inert. */ + readonly initialPageEvent?: 'emit' | 'skip' } /** @@ -52,8 +53,8 @@ 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' + /** @deprecated This legacy input is inert. */ + readonly initialPageEvent?: 'emit' | 'skip' } /** @@ -111,12 +112,9 @@ const CONTENT_HYDRATION_MODES: readonly ContentOptimizationHydrationMode[] = [ 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".') +/** @internal */ +export function invalidateOptimizationHandoffHydration(): void { + latestHandoffStateHydration += 1 } function assertContentHandoff( @@ -140,16 +138,6 @@ 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, @@ -184,8 +172,8 @@ function applyHydratedSignals({ updateSignals() } -function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): void { - if (!shouldContinueHydration(options)) return +function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOptions): boolean { + if (!shouldContinueHydration(options)) return false applyHydratedSignals({ hasChanges: true, @@ -194,19 +182,19 @@ function applySuccessfulEmptyHandoffHydration(options: HandoffStateHydrationOpti options, state: CONTENT_STATE_RESET, }) + return true } -async function hydrateOptimizationHandoffStateInternal( +export async function hydrateContentOptimizationHandoffState( sdk: OptimizationHandoffHydrationTarget, state: BrowserOptimizationHandoff['state'], options: HandoffStateHydrationOptions = {}, -): Promise { +): Promise { latestHandoffStateHydration += 1 const hydration = latestHandoffStateHydration if (!state) { - applySuccessfulEmptyHandoffHydration(options) - return + return applySuccessfulEmptyHandoffHydration(options) } const hasChanges = hasOptimizationSelectionStateField(state, 'changes') @@ -216,8 +204,7 @@ async function hydrateOptimizationHandoffStateInternal( 'selectedOptimizations', ) if (!hasChanges && !hasProfile && !hasSelectedOptimizations) { - applySuccessfulEmptyHandoffHydration(options) - return + return applySuccessfulEmptyHandoffHydration(options) } const inputState = mergeOptimizationSelectionState(CONTENT_STATE_RESET, state) @@ -234,8 +221,8 @@ async function hydrateOptimizationHandoffStateInternal( return undefined }) - if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return - if (!shouldContinueHydration(options)) return + if (hydratedState === undefined || hydration !== latestHandoffStateHydration) return false + if (!shouldContinueHydration(options)) return false const mergedState = mergeOptimizationSelectionState(inputState, hydratedState) @@ -249,6 +236,7 @@ async function hydrateOptimizationHandoffStateInternal( options, state: mergedState, }) + return true } /** @@ -264,7 +252,7 @@ export async function hydrateOptimizationHandoffState( state: BrowserOptimizationHandoff['state'], options: HandoffStateHydrationOptions = {}, ): Promise { - await hydrateOptimizationHandoffStateInternal(sdk, state, options) + await hydrateContentOptimizationHandoffState(sdk, state, options) } /** @@ -280,9 +268,9 @@ export async function hydrateOptimizationHandoff( handoff: ContentOptimizationHandoff, ): Promise { assertContentHandoff(handoff) - assertInitialPageEvent(handoff.initialPageEvent) assertOptimizationCacheSafety(handoff) - await hydrateOptimizationHandoffStateInternal(sdk, handoff.state, { - suppressDurableContinuityPersistence: shouldPreserveDurableContinuity(handoff), + + await hydrateContentOptimizationHandoffState(sdk, handoff.state, { + suppressDurableContinuityPersistence: true, }) } diff --git a/packages/web/web-sdk/src/index.ts b/packages/web/web-sdk/src/index.ts index fea111abc..14d35d713 100644 --- a/packages/web/web-sdk/src/index.ts +++ b/packages/web/web-sdk/src/index.ts @@ -43,6 +43,12 @@ export type { EntryViewInteractionStartOptions, } from './entry-tracking' export * from './handlers/beaconHandler' +export type { + AnalyticsOptimizationHandoff, + BrowserOptimizationHandoff, + ContentOptimizationHandoff, + ContentOptimizationHydrationMode, +} from './handoff' export * from './storage/LocalStore' export default ContentfulOptimization diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts index 28819d1a8..9687ab02b 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' @@ -95,6 +95,7 @@ export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOp fetchOptimizedEntry: rejectSnapshotManagedEntryFetch, prefetchManagedEntries: rejectSnapshotManagedEntryFetch, tracking: NOOP_TRACKING, + hydrateAndTrackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), }) } diff --git a/scripts/run-implementation-script.ts b/scripts/run-implementation-script.ts index bb32be1c0..b2da31110 100644 --- a/scripts/run-implementation-script.ts +++ b/scripts/run-implementation-script.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { copyFileSync, existsSync, readdirSync, readFileSync } from 'node:fs' +import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs' import path from 'node:path' import process from 'node:process' import { isRecord } from './typeGuards' @@ -211,6 +211,13 @@ function ensureImplementationEnvFile(implementation: string): void { process.stdout.write(`\n> created ${envPath} from .env.example\n`) } +function resetImplementationInstallState(implementation: string): void { + rmSync(path.join(IMPLEMENTATIONS_DIRECTORY, implementation, 'node_modules'), { + force: true, + recursive: true, + }) +} + function runScript( implementation: string, scriptName: string, @@ -243,14 +250,9 @@ function runImplementationInstallAction( return localPackageTarballsExitCode } - const installArgs = [ - 'install', - '--force', - '--no-lockfile', - '--no-optimistic-repeat-install', - '--update-checksums', - ...actionArgs, - ] + resetImplementationInstallState(implementation) + + const installArgs = ['install', '--update-checksums', ...actionArgs] if (!hasExplicitFrozenLockfileFlag(actionArgs)) { installArgs.push('--no-frozen-lockfile')