Skip to content

feat(spike): wire optional optimization SDK for personalization + analytics [NT-3613]#26

Draft
Johannes Maximilian Toball (jmtoball) wants to merge 9 commits into
feat/idiomatic-adapters-and-bare-componentsfrom
docs/NT-3613-optimization-experiences-spike-design
Draft

feat(spike): wire optional optimization SDK for personalization + analytics [NT-3613]#26
Johannes Maximilian Toball (jmtoball) wants to merge 9 commits into
feat/idiomatic-adapters-and-bare-componentsfrom
docs/NT-3613-optimization-experiences-spike-design

Conversation

@jmtoball

@jmtoball Johannes Maximilian Toball (jmtoball) commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

"Minimal" spike with additive plumbing to consume the Optimization SDK's new personalization + analytics surface. Compiled against feat/idiomatic-adapters-and-bare-components, not main. Not intended to merge as-is — the goal is to demonstrate what a real consumer diff looks like.

Companion PR: contentful/optimization#362.

Motivation

Can we surface Personalization + Analytics through @contentful/experiences-react with a small, additive change on both sides? This PR is the Experiences half of that answer — a few dozen lines in packages/core, a new packages/adapter-react/src/optimization/ folder, and the existing examples/nextjs adapted to show the call-site diff a consumer would apply.

What changes

Package Change Notes
packages/core sourceMap?: DeliveryViewSourceMap on PortableRenderPlan + option on resolveExperience Opaque passthrough. Core stays vendor-agnostic — no import from optimization SDK.
packages/adapter-react Optional peer @contentful/optimization-react-web + optimization/load-adapter.ts (top-level await import in try/catch) Peer absent → whole surface no-ops.
packages/adapter-react OptimizationProvider + useOptimization context, InstrumentedNode wrapper wired into NodeRenderer, optimization?: OptimizationOption prop on both renderers InstrumentedNode only wraps personalized nodes (display: contents div); non-personalized nodes are byte-untouched.
packages/adapter-react Client renderer: useEffect calls adapter.attachInteractionRuntime({ views, clicks, hovers }) post-hydration Server renderer publishes the provider but skips the effect.
examples/nextjs Clobbered — new client boundary + getExperienceWithOverrides + sourceMap threading The call-site diff a consumer would apply.

Guarantees

  • Omitting the optimization prop → byte-for-byte identical output. Verified with exact-string SSR assertions in the test suite.
  • Peer absent → adapterFactory === null → surface degrades to no-op.
  • Server + client SSR output stays hydration-compatible (both publish the provider; only client attaches trackers).

Bundle size

Metric Baseline Spike Δ
Shared First Load JS 102 kB 103 kB +1 kB
/[slug] route size 2.23 kB 3.74 kB +1.51 kB
/[slug] First Load JS 105 kB 143 kB +38 kB
/advanced/[slug] route size 2.23 kB 2.85 kB +0.62 kB
/advanced/[slug] First Load JS 105 kB 142 kB +37 kB

Follow-up: broader framework/platform support

packages/adapter-react/src/optimization/load-adapter.ts hard-codes the peer specifier @contentful/optimization-react-web/experiences-adapter. Extending to experiences-svelte (already shipped) and future adapters (experiences-vue, experiences-angular, experiences-swiftui, experiences-compose) would want either (a) a small per-adapter loader mirroring this file against @contentful/optimization-<framework>-<platform>/experiences-adapter, or (b) lifting the loader + provider shape into a framework-neutral core package that each adapter re-exports. The optimization SDK already ships optimization-web, optimization-node, optimization-nextjs, optimization-react-native — the underlying NodeInteractionRuntime is DOM-driven and would need a different runtime story on native platforms.

Follow-up: delivery-client ergonomics

Adjacent seam we didn't touch. examples/nextjs/lib/delivery-client.ts shows two rough edges a small change in @contentful/experience-delivery could smooth:

  1. The GetExperienceWithOverridesViewResponse shape isn't structurally ExperiencePayload — the example strips extensions and casts (rest as unknown as ExperiencePayload). A typed unwrap helper (or exposing a payload accessor on the response) would remove the cast.
  2. The consumer has to opt into the sourceMap by passing extensions: { sourceMap: {} } in the request body, then re-plumb it from the response into resolveExperience({ sourceMap }). A single { withSourceMap: true } request flag + a top-level sourceMap accessor on the response would collapse this to one line.

Neither blocks the spike; both would shrink the customer's call-site.

* Registrations accept a bare component (`button: Button`) OR the config
  shape when defaults / resolveData are needed. `defineComponent<Props>`
  stays as an opt-in generic-narrowing helper.
* Runtime context (`experience`, `contentful`) moves off injected props
  onto context hooks — `useExperience`, `useContentfulComponent`,
  `useContentfulTemplate` (React); `getExperience`, `getContentfulComponent`,
  `getContentfulTemplate` (Svelte). Customer components stay plain and
  only receive props they declare.
* Svelte: `children` is a first-class Snippet prop; the `slot('name')`
  dispatcher is gone. Additional named slots reachable via
  `getContentfulComponent().slots` + the exported `<NodesRenderer />`.
* ClientExperienceRenderer no longer throws on the server — SSR path
  matches Server variant byte-for-byte, matchMedia takes over after
  hydration.

BREAKING CHANGE: components no longer receive `experience` / `contentful`
props; use the corresponding hook / getter. Svelte adapter no longer
passes a `slot: Snippet<[string]>` dispatcher; use named `children`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rework docstrings and inline comments across the adapter packages and
examples to describe the current API instead of contrasting against the
previous shape. Drops "no longer injects", "escape hatch", "used to be",
etc.; keeps the reasoning behind non-obvious behavior.

Also removes a stale reference to a non-existent `DebugPanel` from the
advanced example's docstring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Purely whitespace: `prettier --check` on CI flagged the seven files the
previous commit reformatted. Applied `prettier --write` and re-verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Additive plumbing for the optimization spike (NT-3613). Adds an optional
`sourceMap` on the resolved plan so adapters — starting with the
optimization SDK's `experiences-adapter` — can consume it without core
having to know anything about personalization.

- `PortableRenderPlan` gains `sourceMap?: DeliveryViewSourceMap`.
- `ResolveExperienceOptions` gains `sourceMap?: DeliveryViewSourceMap`;
  `resolveExperience` copies it onto the returned plan verbatim (no
  clone — referential identity is preserved).
- `DeliveryViewSourceMap` is declared locally in `packages/core/src/types.ts`,
  structurally compatible with the assemblies wire authority
  (`@contentful/view-delivery-contract`) and with the Fern-generated
  `GetExperienceWithOverridesViewResponseExtensionsSourceMap` shape in
  `@contentful/experience-delivery`. Interning-table fields typed as
  `unknown[]` since the assemblies contract marks them "reserved for
  future use" — same "structurally compatible, no normalization"
  approach `ExperiencePayload` already takes.
- Core stays vendor-agnostic: no validation, no import from the
  optimization SDK. Adapters own validation at consumption time.

Verified: `npx nx typecheck core` clean; `npx nx test core` → 21/21
pass (+2 new); ESLint + Prettier clean on the three edited files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[NT-3613]

Milestone 8 of the optimization spike (NT-3613). Adds the single top-level
import site that the React adapter uses to hook into the optimization SDK's
`experiences-adapter` subpath, plus the optional peer wiring so the dep
degrades to a no-op when consumers don't install the optimization package.

- `package.json`: adds `peerDependencies["@contentful/optimization-react-web"]:
  "*"` and marks it optional in `peerDependenciesMeta`. Peer name is the
  React package (not the browser SDK), matching the current package layout.
- `src/optimization/load-adapter.ts` (new): top-level
  `await import('@contentful/optimization-react-web/experiences-adapter')` in
  try/catch; `adapterFactory` resolves to the peer's `getExperiencesAdapter`
  or `null` when the peer is absent. Evaluated once at module load so both
  `ClientExperienceRenderer` and `ServerExperienceRenderer` observe the same
  value synchronously — SSR one-pass.
- `src/optimization/optimization-peer.d.ts` (new): local ambient module
  declaration for the peer subpath so `packages/adapter-react` typechecks in
  a workspace that doesn't install the peer. Mirrors only the surface this
  package consumes; real declarations take precedence when the peer is
  installed.
- `src/optimization/load-adapter.test.ts` (new): smoke test asserting
  `adapterFactory === null` in the workspace (peer not installed). Broader
  integration lands in Milestone 12.
- `tsup.config.ts`: excludes `src/**/*.d.ts` from the entry glob so tsup
  doesn't emit a JS stub alongside the ambient declaration.

Verified: `npx nx typecheck adapter-react` clean; `npx nx build adapter-react
--skip-nx-cache` clean and `dist/optimization/load-adapter.js` preserves the
top-level `await import(...)` verbatim; `npx nx test adapter-react
--skip-nx-cache` → 19/19 pass (+1 new); ESLint + Prettier clean on touched
files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…3613]

Milestone 9 of the optimization spike (NT-3613). Adds the React contexts that
the renderer (Milestone 11) and `InstrumentedNode` (Milestone 10) will publish
into, plus the customer-facing `useOptimization()` hook for reading the picked
node metadata inside registered components.

- `src/optimization/context.tsx` (new): two React contexts, colocated so the
  next two milestones import from one place.
  - `OptimizationProvider` (runtime, root-level) exposes `{ adapter, sourceMap
    }` to the whole tree; consumed by the renderer.
  - `OptimizationNodeProvider` (per-node) exposes `ResolvedNodeMetadata`;
    consumed by `InstrumentedNode`.
  - `useOptimizationRuntime()` — internal lookup for the renderer + node
    wrapper.
  - `useOptimization(): { resolved }` — public hook for consumer components.
- `src/index.ts`: re-exports `useOptimization` alongside the existing runtime
  context hooks. Providers stay internal so consumer code can't short-circuit
  the renderer's lifecycle contract (spec §5.2f).
- `src/optimization/context.test.tsx` (new, 4 tests): SSR-only via
  `renderToStaticMarkup` (existing node vitest environment). Reference-identity
  assertions on both providers and both hooks.

Deviation from spec §5.2d — logged in the plan. Spec named a single
`OptimizationContext`, but `useOptimization()`'s `{ resolved }` return in
§5.2e is per-node state that doesn't live at the tree root. Split into two
contexts so each is published at the correct boundary.

Verified: `npx nx typecheck adapter-react` clean; `npx nx build adapter-react
--skip-nx-cache` clean; `npx nx test adapter-react --skip-nx-cache` → 23/23
pass (+4 new); ESLint + Prettier clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone 10 of the optimization spike (NT-3613). Adds the per-node
instrumentation wrapper and wires it into `NodeRenderer`. Non-personalized
nodes stay byte-for-byte unchanged; personalized nodes get a
`display: contents` wrapper carrying `data-ctfl-*` attrs stamped by the
adapter's ref-callback, and publish their `ResolvedNodeMetadata` to
`useOptimization()` for descendants.

- `src/optimization/instrumented-node.tsx` (new): calls
  `adapter.useNodeBinding(nodeId, sourceMap)`. `resolved === null` renders
  children unchanged (no wrapper, no context). Otherwise wraps in
  `<OptimizationNodeProvider value={resolved}><div ref={ref}
  data-ctfl-node-id={nodeId} style={{ display: 'contents' }}>...</div>
  </OptimizationNodeProvider>`.
- `src/nodes-renderer.tsx`: `NodeRenderer` now reads
  `useOptimizationRuntime()` and, when the runtime + `sourceMap` + `nodeId`
  are all present, wraps the created element in `<InstrumentedNode>`. Wrap
  sits inside the existing `<ContentfulComponentProvider>` (unchanged
  provider structure).
- `src/optimization/instrumented-node.test.tsx` (new, 4 tests): direct
  component tests via `renderToStaticMarkup`.
- `src/nodes-renderer.optimization.test.tsx` (new, 3 tests): integration
  tests driving `ServerExperienceRenderer` with a fake adapter — verifies
  selective wrapping and both regression paths (no provider, no sourceMap).

Verified: `npx nx typecheck adapter-react` clean; `npx nx build adapter-react
--skip-nx-cache` clean; `npx nx test adapter-react --skip-nx-cache` → 30/30
pass (+7 new); ESLint + Prettier clean on `src/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone 11 of the optimization spike (NT-3613). Adds the `optimization?:
OptimizationOption` prop to both renderers, publishes the OptimizationProvider
to the tree, and attaches view/click/hover tracking on the client after
hydration. Omitting the prop (or `enabled: false`) is byte-for-byte the same
output as before.

- `src/client-renderer.tsx`: new `optimization` prop. Two colocated hooks —
  `useOptimizationRuntimeMemo` returns `{ adapter, sourceMap } | null` (null
  when disabled, no client, or peer absent); `useOptimizationInteractionEffect`
  calls `attachInteractionRuntime({ views, clicks, hovers })` on mount and
  returns its cleanup. Both run before the existing early-return guard so
  hook order stays stable. New public type `OptimizationOption` — `client` is
  `Parameters<typeof getExperiencesAdapter>[0]` so consumers with the peer
  installed see the real `ContentfulOptimization` type.
- `src/server-renderer.tsx`: same `optimization` prop, plain (non-hook)
  `buildOptimizationRuntime` helper. Server publishes the provider (so
  `InstrumentedNode` stamps `data-ctfl-node-id` into first-paint HTML for
  hydration parity) but never attaches the interaction runtime — SSR has no
  post-render lifecycle. Spec §5.2f split logged as a Decisions-log entry.
- `src/index.ts`: re-exports `OptimizationOption` alongside the renderer prop
  types.
- `src/client-renderer.optimization.test.tsx` (new, 4 tests) + `src/
  server-renderer.optimization.test.tsx` (new, 3 tests): SSR-only tests
  covering prop-absent, `enabled: false`, `enabled: true` + peer absent
  (no-op), and SSR-with-enabled doesn't throw.

Verified: `npx nx typecheck adapter-react` clean; `npx nx build
adapter-react --skip-nx-cache` clean; `npx nx test adapter-react
--skip-nx-cache` → 37/37 pass (+7 new); ESLint + Prettier clean on `src/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone 13 of the optimization spike (NT-3613). Clobbers `examples/nextjs`
in place (per direction) to showcase exactly which call-sites move to enable
personalization in a real consumer app.

- `lib/delivery-client.ts`: switched from `view.getExperience` to
  `view.getExperienceWithOverrides` with `extensions.sourceMap: {}` so the
  response carries the per-node provenance table. Returns the payload plus
  `sourceMap: DeliveryViewSourceMap | undefined`.
- `lib/optimization-client.ts` (new, `'use client'`): module-scope singleton
  factory constructing the browser-only `ContentfulOptimization`. Consent
  defaulted to `true` for the smoke.
- `components/ClientExperience.tsx` (new, `'use client'`): client boundary
  that mounts `<ClientExperienceRenderer optimization={{ enabled: true,
  client }} />`. Owns the SDK instance's lifetime.
- `app/[slug]/page.tsx`: server component threads sourceMap through
  `resolveExperience(payload, config, { sourceMap })` and hands the plan to
  `<ClientExperience>`. Server-side rendering with client-side
  instrumentation, same route.
- `package.json`: five `file:` deps into the sibling `contentful/optimization`
  repo — the peer chain plus its transitive `0.0.0` deps (npm doesn't
  auto-resolve them across file: tarballs).
- `.env.example`: added `NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID` +
  `NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT` (client-inlined because the SDK is
  browser-only).
- `README.md`: new "Personalization (NT-3613 spike smoke)" section
  documenting the wiring, the one-time peer-install steps, and manual
  browser checks.

Also:
- `packages/adapter-react/src/index.ts`: re-exports `DeliveryViewSourceMap`
  so consumers can type delivery-client returns without reaching into
  `experiences-core` directly.
- Three tests in `packages/adapter-react/src/**/*.test.tsx` had their
  descriptions + assertions updated to reflect the peer being installed in
  this workspace (the smoke needs it). Underlying code paths tested are
  unchanged.

Verified: `pnpm build:pkgs` in the sibling optimization repo produced the
tarballs; `npm install` resolves the chain; `cd examples/nextjs && npx tsc
--noEmit` clean (except pre-existing `server-only` baseline error);
`SPACE_ID=x CDA_TOKEN=y npm run build` in the example succeeds end-to-end;
`npx nx test adapter-react --skip-nx-cache` still 37/37. Manual browser
smoke deferred to the consumer with real credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant