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

Browser-only route

- Next view -
-
- ) -} +**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. -export default function BrowserOnlyPage() { - return ( - Loading page…

}> - -
- ) -} +```diff + // app/(request)/layout.tsx +-import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '@/lib/optimization' ++import { RequestOptimizationRoot } from '@/lib/optimization' + + +- + {children} + ``` -A direct root with `beforeInitialPage` requires `routeKey` and lazy `buildPagePayload`; it rejects -`initialPagePayload`, which captures page data too early. The callback stays in the client module: -the server binding rejects a client config containing `beforeInitialPage`, and neither its provider -nor analytics root receives it. +The 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 binding config separates policy from mechanism: - `consent.server` is the app-owned server policy for the current request; configure it explicitly because App Router request initialization resolves omitted request consent to `false`. -- `consent.clientDefaults` supplies explicit browser defaults. The nested request family replaces - them with the current request's consent, but a top-level static or public root uses them as given. - Supply the app's current browser consent when mounting a public root. +- `consent.clientDefaults` seeds the browser SDK before a persisted or explicit browser decision is + available. - `contentful.client` is your delivery client. The SDK may call it for managed entry IDs or content-type/slug lookups, but it does not own your Contentful Delivery API (CDA) credentials or query policy. @@ -573,8 +567,9 @@ 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. This diff belongs to the paired request-family path; keep -`RequestNextAppAutoPageTracker` mounted. The direct browser-only callback route is separate. +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. **Adapt this to your use case:** @@ -662,12 +657,12 @@ forwarding-only path. It sanitizes SDK-owned forwarded context and supplies the the request family performs request evaluation. Keep the matcher narrow to the routes that use Optimization request context. -The forwarding handler does not evaluate events. The request family previews the initial batch -when Server Components render. If the preview fails, the server renders baseline content and keeps -valid prepared events for browser commitment. Invalid event or route inputs produce no replay. -The browser can persist a profile ID returned by preview when persistence consent permits it. +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 SDK-owned anonymous ID cookie is `ctfl-opt-aid`. It stores an Experience API-issued identifier that connects browser +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 @@ -680,8 +675,7 @@ record. Server Components render personalized first paint through `optimization.request.OptimizedEntry`. If no experience applies, the API has no variant, or a linked variant cannot be resolved, the render -receives the baseline entry. A recoverable preview failure also renders baseline while the valid -prepared replay remains available for the browser. When policy denies the selection-producing Experience API request, no +receives the baseline entry. When policy denies the selection-producing Experience API request, no **selected optimizations** enter the request state. Selected optimizations are the experience-and-variant choices returned by an accepted page, identify, or custom Experience event. Resolution without a selection also returns the baseline. @@ -755,16 +749,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, prepared replay, and browser startup mode. The request -`NextAppAutoPageTracker` passes the handoff and current route to the Web SDK. For a matching replay, -the Web SDK admits the complete batch to its existing queue and does not submit an ordinary page -for the same visit. Queue admission is reported before HTTP delivery; a later response updates live -browser state. Without matching replay, the tracker uses ordinary page tracking. The request family -builds the route key and page payload, so the app does not pass either one. Keep the tracker inside the Next.js-required +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 request-family path, the prepared page event's `page.url` comes from the SDK-forwarded +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 complete campaign source and maps it into `context.campaign`: `utm_campaign` becomes `name`, @@ -780,52 +773,78 @@ fallback for the private slot so the public navigation and page context remain a loads. Keep Next.js `Link` prefetch enabled; the narrow handler matcher limits request-context work to participating routes. -For request-family pages, server `request.initialEvents` is the place to supply identity and -custom Experience events that must precede the page. The replay contains those events and the -page in order. A root that receives this prepared replay uses the handoff path; its -`beforeInitialPage` callback does not run. - -In the direct browser-only route above, the callback runs because there is no prepared replay. The -**initial page decision** is its first direct browser page -attempt after callback work. The callback runs after the root's live owned runtime exists and once -per retained mount; a real remount starts another lifetime. Return a Promise or **thenable** (an -object with `.then()`) for all work the root must await. It waits for that result or the watchdog, -then reads the latest route and lazy payload for one direct page attempt. After that attempt settles, -its built-in page emitter makes a non-emitting initial `skip` mark for the attempted route, then -emits for later routes. `{ accepted: false }` means the page was not admitted locally; it still -advances without an immediate same-route retry. - -The watchdog waits 3,000 ms by default; `maxWaitMs` must be a positive finite number. A callback -throw, rejected returned work, or watchdog expiry reaches `onError`, then the root continues to the -page while still mounted on the same live runtime. The watchdog does not cancel work already -started. A route change during an in-flight direct attempt neither cancels it nor starts another; -a route observed only during that interval is not emitted, and a change after readiness emits -normally. Work the callback starts without returning can finish after the page. +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. > [!NOTE] > -> If callback and page work remain pending when an entry reaches its five-second fallback deadline, -> baseline content can become visible. With live updates off, that visible content stays frozen. -> Enable [Browser takeover and live updates](#browser-takeover-and-live-updates) if late replacement -> is intended. +> 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. 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. A matching prepared replay is committed by -the browser. Without a matching replay, the root tracks an ordinary page when supplied with a page -payload; otherwise, mount the router tracker to provide the fallback page event. See +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. 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 no replay. Use that path for baseline managed-entry warming, not request-personalized state. +and `initialPageEvent: 'emit'`. 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. -Mount the diagnostic before the request tracker so it observes the initial browser admission. +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. **Adapt this to your use case:** @@ -867,6 +886,7 @@ export function OptimizationEventDiagnostics() { + + {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} {children} @@ -880,25 +900,14 @@ that either API received an event. Use three separate checks: the raw server HTML. - **Browser admission:** Trigger a tracked action and inspect the browser console for `Contentful Optimization event accepted` or `Contentful Optimization event blocked`. -- **Prepared commitment:** Temporarily add the app-owned `server_render` track event to - `request.initialEvents` as shown above and grant event consent. Clear the browser Network panel - and reload. Inspect the Experience request after hydration: it must contain the prepared - `server_render` track and `page` in one batch, with no second ordinary first page. A page-only - request can be ordinary fallback and points to missing or mismatched replay. Follow a normal - Next.js `Link` to another participating route and confirm one later page admission. Remove the - diagnostic track event afterward. Local acceptance can precede HTTP delivery. - -The server's preview selects the raw HTML but does not commit events. Browser Network activity -shows commitment attempts, while the diagnostic shows local admission or blocking. Neither one -alone proves that a retry after a lost response was applied exactly once by the API. - -For the optional browser-only `beforeInitialPage` route, mount the same development observer as a -child of `ClientOptimizationRoot` before the page body. Omit `RequestNextAppAutoPageTracker`. Set -the example identity with `localStorage.setItem('app-user-id', 'guide-user')`, then load -`/browser-only`. The observer must show the `identify` and `client_ready` results, accepted or -blocked, before at most one initial `page` result. Click **Next view** after readiness and check -for one later page result. A page result before the callback's results or two initial pages points to another -page owner. Remove the observer after this development check. +- **Duplicate page prevention:** Clear the browser console and reload the route. Hydration must not + log a browser `page` event when the server accepted the first page event. Follow a normal Next.js + `Link` to another participating route and confirm one browser `page` event appears for that + navigation. + +To verify API delivery rather than local admission, inspect your server's outbound Experience API +telemetry for the initial request and your browser Network panel for browser-owned events. The +browser observer cannot see the completed server call. ### Browser takeover and live updates @@ -1019,13 +1028,6 @@ Replace the quick-start consent shortcut with your app policy: `setConsent(true)` or `setConsent(false)` sets both event consent and persistence consent to the same value. Use the object form, `setConsent({ events, persistence })`, when those two decisions differ. -> [!WARNING] -> The fixed `false` browser defaults below apply to the nested request path, whose handoff supplies -> the current request consent. A top-level static or public root uses those defaults as given rather -> than reading the current browser decision. Initialize public browser roots from the current -> app-owned consent decision as shown in -> [Render personalized Next.js routes with static, ISR, and edge handoffs](./rendering-personalized-nextjs-routes-with-static-isr-and-edge-handoffs.md). - **Adapt this to your use case:** ```tsx @@ -1369,8 +1371,8 @@ export function OptimizationPreviewPanel() { Mount the panel inside the same request root as the content it previews. -The mounting diff below shows the paired request-family path. The direct browser-only callback -route owns its own root and does not mount `RequestNextAppAutoPageTracker`. +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:** @@ -1380,6 +1382,7 @@ route owns its own root and does not mount `RequestNextAppAutoPageTracker`. + + {/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */} {children} @@ -1457,8 +1460,9 @@ export default function Page() { } ``` -This private-slot example uses the paired request-family root and tracker. The direct browser-only -callback route is separate and has no request tracker. +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. **Follow this pattern:** @@ -1522,9 +1526,11 @@ 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 request handler forwards sanitized request context. It does not evaluate the initial event -batch or send committed page data to Server Components. Keep the no-argument handler for ordinary -request-family routes; the rendering request resource performs the preview once. +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 resolver calls keep selections as the optional second positional argument: `resolveOptimizedEntry(entry, selectedOptimizations)`. Managed fetch calls accept an ID or a @@ -1583,15 +1589,15 @@ entry-cache hit. **Integration category:** Advanced or production-only When no Optimization event may emit before explicit consent, configure a strict event policy and -return `false` from `consent.server` until your app-owned consent record is accepted. The request -tracker passes the handoff and route to the Web SDK, which applies current browser consent before -admitting a prepared replay. A browser-only root without replay owns an ordinary first page. Use -blocked-event diagnostics to verify denied events are dropped at the SDK boundary. +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. `allowedEventTypes` is the binding's pre-consent event allow-list. An empty list makes every event -require accepted event consent. If you also use the separate browser-only client binding above, -set `allowedEventTypes: []` there as well; the server binding's policy does not configure that -client root. +require accepted event consent. **Adapt this to your use case:** @@ -1607,13 +1613,14 @@ client root. }) ``` -Keep `OptimizationEventDiagnostics` before `RequestNextAppAutoPageTracker` inside the root. Clear the `app-consent` cookie, reload, and confirm the +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. When replay is admitted for the first route, the Web SDK does not also enqueue an ordinary -page. If consent initially blocks admission, calling the current-page path again after consent is -granted can admit the still-uncommitted replay. +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. 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 @@ -1626,17 +1633,11 @@ 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 server preview produces the intended raw HTML, then confirm the browser's prepared batch - reaches the intended Experience API destination. The browser diagnostic proves local admission, - not HTTP delivery. -- Temporarily include the app-owned `server_render` track event in `request.initialEvents` as shown - above, grant event consent, and reload. In the browser Network panel, confirm the prepared - `track` and `page` appear in one Experience batch without an extra ordinary first page. A - page-only request points to missing or mismatched replay. Remove the diagnostic event afterward. -- In the normal request path, confirm the request tracker admits one prepared first-route batch, - does not enqueue an additional ordinary first page, and tracks later routes. In the direct - browser-only `beforeInitialPage` route, omit the tracker and confirm callback results precede the - first direct page attempt. +- Confirm locally accepted server and browser events arrive at the intended Experience or Insights + API destination; the browser diagnostic alone is not delivery evidence. +- Confirm first-page ownership matches one mode. In normal tracker mode, the request tracker skips a + server-owned first event and emits later routes. In `beforeInitialPage` mode, no request tracker + is mounted and the root's direct attempt plus built-in emitter do not duplicate the initial route. - Confirm 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. @@ -1664,7 +1665,7 @@ pnpm test:e2e:nextjs-sdk_app_router | A heterogeneous render cannot read content-type-specific fields | The skeleton union omits a possible content type, or the entry was not narrowed before rendering | Include every baseline and variant skeleton in `S`, then narrow with `isEntryOfContentType` | | Variant appears in the browser but not View Source | The route is browser-owned rather than request-family or public-permutation rendered | Use `optimization.request` for private request rendering, or use a top-level public permutation handoff before rendering | | Request components report a missing forwarded request URL | The handler filename or export name does not match the Next.js version, or the handler is absent | Configure the SDK request handler; use `proxy.ts` with `proxy` on Next.js 16, or `middleware.ts` with `middleware` on Next.js 13 to 15 | -| Duplicate first page events | Multiple page owners are mounted, or app code also calls `page()` for the prepared route | Keep one request tracker in the paired request path; on the direct browser-only `beforeInitialPage` route, omit trackers. Remove duplicate manual first-page calls. | +| 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 | | 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 53e46482d..121f0ca06 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-pages-router-app.md @@ -112,9 +112,9 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- } ``` -4. Mount the bound root once in `_app.tsx` and pass the same handoff to the route tracker. The - tracker gives the Web SDK the current route and prepared events together. A matching replay is - admitted as one batch; without replay, the tracker emits an ordinary page event. +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. **Adapt this to your use case:** @@ -136,7 +136,7 @@ replace it in [Consent, identity, profile, and reset](#consent-identity-profile- + handoff={handoff} + routeKey={routeKey} + > - + + + + + ) @@ -408,14 +408,8 @@ the slug. **Integration category:** Required for first integration `createRequestHandoff(context, options)` reads the Pages Router request, evaluates -`consent.server`, prepares the initial page event, and previews it through the Experience API. The -preview supplies selections for server rendering without committing the event. The helper returns a -serializable browser `handoff` containing the prepared replay. The browser admits the matching -replay to the existing Experience queue; its `accepted` result means queue admission, not completed -HTTP delivery. A recoverable Experience preview failure leaves baseline selections and retains -valid prepared events for browser commitment. If the helper, entry prefetch, or rendering throws for -another reason, your `getServerSideProps` error handling decides whether to return baseline or a -500 response. A page blocked by the effective event gate or invalid input produces no replay. +`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`. Configure `consent.server` explicitly. If it is omitted, Pages Router request consent resolves to `false`. @@ -424,11 +418,8 @@ 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`. It stores an Experience API-issued ID, not -an ID invented by the SDK. When persistence consent permits it and an existing or preview-issued ID -is known, the helper appends a browser-readable cookie to the response. An ID can be absent after a -failed first preview; the browser can establish one when it commits the replay. Your app owns any -consent cookie or account record that `consent.server` reads. Pages Router server work happens in `getServerSideProps`; there +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 bound root and page events @@ -436,15 +427,14 @@ is no middleware or proxy requirement for the Pages Router path. **Integration category:** Required for first integration The bound `OptimizationProvider` handles the content SDK context, handoff, hydration mode, and -managed-entry prefetch for a subtree. Use the bound `OptimizationRoot` in `_app.tsx` for content and -the `NextPagesAutoPageTracker` for route events. Pass the same `handoff` to both. The tracker sends -the handoff with the current route to the Web SDK, which admits a matching prepared replay instead -of an ordinary first page. A missing or mismatched replay uses ordinary page tracking. Keep -`routeKey` and a lazy `buildPagePayload` on the root when its binding requires them; those props do -not belong on `OptimizationProvider`. +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 prepared first page event. The root's `buildPagePayload` shapes a browser event that the root +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 @@ -460,40 +450,34 @@ 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. -For a paired server route, put identity and custom Experience inputs that must precede the page -in the server helper's `initialEvents` option. The SDK prepares them in order, followed by the page, -and previews the batch for the server render. The browser commits the prepared replay through the -handoff. Use only identity and event values your app's consent policy permits on the server. - -Keep the prepared batch to 100 events total, including exactly one final page and at most 50 -identifies. The SDK validates it before the preview request; an invalid batch has no replay. In the -browser, the whole batch needs room in the configured Experience queue before it is admitted. - -**Adapt this to your use case:** if your request has an app-owned event to evaluate before the page, -add it to the existing `getContentfulOptimization()` helper. `server_render` is an app-owned event -name; replace it with one your application actually uses. - -```diff - // lib/optimization-server.ts - handoff: await createRequestHandoff(context, { - cache: { scope: 'private-request' }, - hydration: 'preserve-server', -+ initialEvents: [{ type: 'track', event: 'server_render' }], - pagePayload: { properties: { path: routeKey } }, - }), -``` - -The browser binder also accepts `beforeInitialPage` for an owned content root. Use this on a -browser-only route with no `getServerSideProps` handoff, such as `/browser-only` below. The -**initial page decision** is the root's first direct browser page attempt after callback work. A -root receiving a prepared replay follows the normal handoff queue path and does not run the -callback. Keep `initialEvents` above for work that must participate in server evaluation. - -**Adapt this to your use case:** add the callback to the existing browser binding. `app-user-id` -and `client_ready` are app-owned; replace them with your identity store and custom event name. The -`async` callback returns one Promise that includes both awaited operations. The SDK's -`identify`, `screen`, and `track` delegates are safe to destructure; `screen` records a screen view. -The development observer logs local accepted or blocked results and cleans up both subscriptions. +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. ```diff // lib/optimization.ts @@ -504,11 +488,14 @@ The development observer logs local accepted or blocked results and cleans up bo +} 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') ++ const userId = window.localStorage.getItem(APP_USER_ID_KEY) + if (userId !== null) await identify({ userId }) -+ await track({ event: 'client_ready' }) ++ await track({ event: CLIENT_READY_EVENT }) + }, + onError: (error) => console.warn('Before-initial-page work failed.', error), +} satisfies BeforeInitialPageOptions @@ -519,28 +506,17 @@ The development observer logs local accepted or blocked results and cleans up bo environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master', locale: 'en-US', + beforeInitialPage, -+ onStatesReady: (states) => { -+ if (process.env.NODE_ENV !== 'development') return -+ const accepted = states.eventStream.subscribe((event) => { -+ if (event) console.debug('Contentful Optimization event accepted', event) -+ }) -+ const blocked = states.blockedEventStream.subscribe((event) => { -+ if (event) console.debug('Contentful Optimization event blocked', event) -+ }) -+ return () => { -+ accepted.unsubscribe() -+ blocked.unsubscribe() -+ } -+ }, + // your existing browser config }) ``` -The bound root requires `routeKey` and lazy `buildPagePayload`; the quick-start `_app.tsx` already -supplies both. It rejects eager `initialPagePayload`. Remove the separate tracker from `_app.tsx` in -the same edit, because this root owns the first and later browser pages. Routes with a prepared -handoff still commit replay through the root's normal handoff path and bypass the callback. +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 same root and page component in `_app.tsx`. +**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 @@ -552,57 +528,84 @@ handoff still commit replay through the root's normal handoff path and bypass th handoff={handoff} routeKey={routeKey} > -- +- ``` -**Adapt this to your use case:** add a browser-only page outside your `getServerSideProps` route. -It receives no request handoff through page props, so the callback runs on first load. +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. -```tsx -// pages/browser-only.tsx -import Link from 'next/link' +> [!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. -export default function BrowserOnlyPage() { - return ( -
-

Browser-only route

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

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

-

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

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

Personalized Edge request

-
-

{entry.text}

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

Public Edge selection: {segment.label}

-
-

{entry.text}

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

Next.js Edge runtime reference implementation

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