feat(spike): wire optional optimization SDK for personalization + analytics [NT-3613]#26
Draft
Johannes Maximilian Toball (jmtoball) wants to merge 9 commits into
Conversation
* 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>
Johannes Maximilian Toball (jmtoball)
force-pushed
the
feat/idiomatic-adapters-and-bare-components
branch
from
July 8, 2026 14:31
64c22d6 to
aaf8de1
Compare
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
"Minimal" spike with additive plumbing to consume the Optimization SDK's new personalization + analytics surface. Compiled against
feat/idiomatic-adapters-and-bare-components, notmain. 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-reactwith a small, additive change on both sides? This PR is the Experiences half of that answer — a few dozen lines inpackages/core, a newpackages/adapter-react/src/optimization/folder, and the existingexamples/nextjsadapted to show the call-site diff a consumer would apply.What changes
packages/coresourceMap?: DeliveryViewSourceMaponPortableRenderPlan+ option onresolveExperiencepackages/adapter-react@contentful/optimization-react-web+optimization/load-adapter.ts(top-levelawait importin try/catch)packages/adapter-reactOptimizationProvider+useOptimizationcontext,InstrumentedNodewrapper wired intoNodeRenderer,optimization?: OptimizationOptionprop on both renderersInstrumentedNodeonly wraps personalized nodes (display: contentsdiv); non-personalized nodes are byte-untouched.packages/adapter-reactuseEffectcallsadapter.attachInteractionRuntime({ views, clicks, hovers })post-hydrationexamples/nextjsgetExperienceWithOverrides+ sourceMap threadingGuarantees
optimizationprop → byte-for-byte identical output. Verified with exact-string SSR assertions in the test suite.adapterFactory === null→ surface degrades to no-op.Bundle size
/[slug]route size/[slug]First Load JS/advanced/[slug]route size/advanced/[slug]First Load JSFollow-up: broader framework/platform support
packages/adapter-react/src/optimization/load-adapter.tshard-codes the peer specifier@contentful/optimization-react-web/experiences-adapter. Extending toexperiences-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 shipsoptimization-web,optimization-node,optimization-nextjs,optimization-react-native— the underlyingNodeInteractionRuntimeis 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.tsshows two rough edges a small change in@contentful/experience-deliverycould smooth:GetExperienceWithOverridesViewResponseshape isn't structurallyExperiencePayload— the example stripsextensionsand casts (rest as unknown as ExperiencePayload). A typed unwrap helper (or exposing apayloadaccessor on the response) would remove the cast.extensions: { sourceMap: {} }in the request body, then re-plumb it from the response intoresolveExperience({ sourceMap }). A single{ withSourceMap: true }request flag + a top-levelsourceMapaccessor on the response would collapse this to one line.Neither blocks the spike; both would shrink the customer's call-site.