From a4dac073d15efb34daae31f8e56404c8de06d16b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:23:44 +0200 Subject: [PATCH 01/21] =?UTF-8?q?=E2=9C=A8=20feat(core):=20Add=20isomorphi?= =?UTF-8?q?c=20OptimizationRuntime=20and=20SnapshotRuntime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a single runtime contract that the client-side stateful SDK and a server-side read-only runtime both satisfy, so framework layers can render from either backing without branching on environment. - `OptimizationRuntime` (derived via `Pick`) is the seam hooks bind to: pure resolvers, the `states` read surface, and event/lifecycle actions. - `createSnapshotRuntime` implements it from a serialized `OptimizationData` snapshot: resolvers delegate to the shared static resolvers (no `ApiClient`, no browser globals), `states` are static observables, actions are inert dev-warn no-ops. - `staticObservable` emits a constant value once and never changes, backing the server read surface. Exposed on a dedicated `./runtime` subpath so it stays out of the always-loaded base bundle and remains tree-shakeable for consumers that never render on the server. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/universal/core-sdk/package.json | 16 +- packages/universal/core-sdk/rslib.config.ts | 1 + .../src/runtime/OptimizationRuntime.ts | 58 +++++ .../src/runtime/SnapshotRuntime.test.ts | 139 ++++++++++ .../core-sdk/src/runtime/SnapshotRuntime.ts | 240 ++++++++++++++++++ .../universal/core-sdk/src/runtime/index.ts | 6 + .../core-sdk/src/signals/Observable.test.ts | 36 ++- .../core-sdk/src/signals/Observable.ts | 35 +++ 8 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts create mode 100644 packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts create mode 100644 packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts create mode 100644 packages/universal/core-sdk/src/runtime/index.ts diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json index 2d1bb7630..f1d1eec45 100644 --- a/packages/universal/core-sdk/package.json +++ b/packages/universal/core-sdk/package.json @@ -52,6 +52,16 @@ "default": "./dist/bridge-support.cjs" } }, + "./runtime": { + "import": { + "types": "./dist/runtime.d.mts", + "default": "./dist/runtime.mjs" + }, + "require": { + "types": "./dist/runtime.d.cts", + "default": "./dist/runtime.cjs" + } + }, "./api-client": { "import": { "types": "./dist/api-client.d.mts", @@ -92,9 +102,11 @@ "bundleSize": { "gzipBudgets": { "index.cjs": 18300, - "index.mjs": 17800, + "index.mjs": 18600, "bridge-support.cjs": 1200, - "bridge-support.mjs": 2100, + "bridge-support.mjs": 2300, + "runtime.cjs": 8000, + "runtime.mjs": 8000, "preview-support.cjs": 5300, "preview-support.mjs": 5500 } diff --git a/packages/universal/core-sdk/rslib.config.ts b/packages/universal/core-sdk/rslib.config.ts index b4410b9f8..bf5fca923 100644 --- a/packages/universal/core-sdk/rslib.config.ts +++ b/packages/universal/core-sdk/rslib.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ logger: './src/logger.ts', constants: './src/constants.ts', 'bridge-support': './src/bridge-support/index.ts', + runtime: './src/runtime/index.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', 'preview-support': './src/preview-support/index.ts', diff --git a/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts new file mode 100644 index 000000000..5bf19b51f --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/OptimizationRuntime.ts @@ -0,0 +1,58 @@ +import type CoreStateful from '../CoreStateful' + +/** + * Members of the stateful core surface consumed by framework layers (React, + * Angular, etc.) that must also be satisfiable by a request-scoped, read-only + * server runtime. + * + * @internal + */ +type OptimizationRuntimeMembers = + // Pure, environment-agnostic resolvers. + | 'resolveOptimizedEntry' + | 'getMergeTagValue' + | 'getFlag' + // Read surface. + | 'states' + | 'locale' + | 'hasConsent' + // Event actions (inert server-side). + | 'identify' + | 'page' + | 'screen' + | 'track' + | 'trackView' + | 'trackClick' + | 'trackHover' + | 'trackFlagView' + // State + lifecycle actions (inert server-side). + | 'consent' + | 'reset' + | 'flush' + | 'setLocale' + | 'destroy' + +/** + * Runtime contract shared by the client-side stateful core and any server-side + * snapshot runtime. + * + * @remarks + * The interface is derived from {@link CoreStateful} with {@link https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys | Pick}, + * so the browser SDK satisfies it by construction and a server implementation + * (see `createSnapshotRuntime`) is forced to match the exact same signatures. + * This is the single seam a framework provider binds to: it renders children + * from either backing without branching on environment. + * + * The three capability tiers behave differently by environment: + * + * - **Resolve** (`resolveOptimizedEntry`, `getMergeTagValue`, `getFlag`) — pure + * functions; identical on server and client. + * - **Read** (`states`) — live signals on the client; static, request-scoped + * observables on the server. + * - **Actions** (`identify`, `page`, `screen`, `track`, `consent`, `reset`, + * `flush`, `setLocale`) — real side effects on the client; inert no-ops on the + * server, where there is no user interaction to track. + * + * @public + */ +export interface OptimizationRuntime extends Pick {} diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts new file mode 100644 index 000000000..4092161e6 --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts @@ -0,0 +1,139 @@ +import type { OptimizationData } from '@contentful/optimization-api-client/api-schemas' +import { describe, expect, it } from '@rstest/core' +import { mergeTagEntry } from '../test/fixtures/mergeTagEntry' +import { optimizedEntry } from '../test/fixtures/optimizedEntry' +import { profile } from '../test/fixtures/profile' +import { selectedOptimizations } from '../test/fixtures/selectedOptimizations' +import { createSnapshotRuntime } from './SnapshotRuntime' + +const snapshotData: OptimizationData = { + profile, + selectedOptimizations, + changes: [ + { + type: 'Variable', + key: 'theme', + value: 'dark', + meta: { experienceId: 'exp-theme', variantIndex: 1 }, + }, + ], +} + +describe('SnapshotRuntime', () => { + describe('resolve surface', () => { + it('resolves the optimized variant using the snapshot selectedOptimizations', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + const fromSnapshot = runtime.resolveOptimizedEntry(optimizedEntry) + const fromExplicit = runtime.resolveOptimizedEntry(optimizedEntry, selectedOptimizations) + + expect(fromSnapshot.selectedOptimization).toBeDefined() + expect(fromSnapshot.entry.sys.id).toBe(fromExplicit.entry.sys.id) + expect(fromSnapshot.selectedOptimization).toEqual(fromExplicit.selectedOptimization) + }) + + it('returns the baseline entry when the snapshot has no optimizations', () => { + const runtime = createSnapshotRuntime() + + const result = runtime.resolveOptimizedEntry(optimizedEntry) + + expect(result.entry).toBe(optimizedEntry) + expect(result.selectedOptimization).toBeUndefined() + }) + + it('resolves merge tags against the snapshot profile', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('EU') + }) + + it('falls back to the merge-tag fallback without a profile', () => { + const runtime = createSnapshotRuntime() + + expect(runtime.getMergeTagValue(mergeTagEntry)).toBe('Nowhere') + }) + + it('resolves flags from the snapshot changes', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.getFlag('theme')).toBe('dark') + expect(runtime.getFlag('missing')).toBeUndefined() + }) + }) + + describe('read surface', () => { + it('exposes static observables seeded from the snapshot', () => { + const runtime = createSnapshotRuntime({ + data: snapshotData, + consent: true, + locale: 'de-DE', + }) + + expect(runtime.states.consent.current).toBe(true) + expect(runtime.states.locale.current).toBe('de-DE') + expect(runtime.states.profile.current).toEqual(profile) + expect(runtime.states.selectedOptimizations.current).toEqual(selectedOptimizations) + expect(runtime.states.canOptimize.current).toBe(true) + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'success' }) + expect(runtime.locale).toBe('de-DE') + expect(runtime.hasConsent('track')).toBe(true) + }) + + it('reports canOptimize false and idle request state without server data', () => { + const runtime = createSnapshotRuntime() + + expect(runtime.states.canOptimize.current).toBe(false) + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'idle' }) + expect(runtime.states.profile.current).toBeUndefined() + expect(runtime.hasConsent('track')).toBe(false) + }) + + it('emits each state value once on subscribe', () => { + const runtime = createSnapshotRuntime({ data: snapshotData, consent: true }) + const received: Array = [] + + const subscription = runtime.states.consent.subscribe((value) => { + received.push(value) + }) + + expect(received).toEqual([true]) + subscription.unsubscribe() + }) + + it('defaults preview panel state to closed', () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(runtime.states.previewPanelAttached.current).toBe(false) + expect(runtime.states.previewPanelOpen.current).toBe(false) + }) + }) + + describe('inert actions', () => { + it('treats event actions as accepted:false no-ops', async () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + await expect(runtime.identify({ userId: 'user-1' })).resolves.toEqual({ accepted: false }) + await expect(runtime.page()).resolves.toEqual({ accepted: false }) + await expect(runtime.track({ event: 'purchase' })).resolves.toEqual({ accepted: false }) + await expect( + runtime.trackView({ componentId: 'component-1', viewId: 'view-1', viewDurationMs: 100 }), + ).resolves.toEqual({ accepted: false }) + }) + + it('treats state and lifecycle actions as no-ops that do not throw', async () => { + const runtime = createSnapshotRuntime({ data: snapshotData }) + + expect(() => { + runtime.consent(true) + }).not.toThrow() + expect(() => { + runtime.reset() + }).not.toThrow() + expect(() => { + runtime.destroy() + }).not.toThrow() + expect(runtime.setLocale('en-US')).toBeUndefined() + await expect(runtime.flush()).resolves.toBeUndefined() + }) + }) +}) diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts new file mode 100644 index 000000000..83dd307df --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts @@ -0,0 +1,240 @@ +import type { + ChangeArray, + Json, + MergeTagEntry, + OptimizationData, + Profile, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import { createScopedLogger } from '@contentful/optimization-api-client/logger' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' +import type { CoreStates } from '../CoreStateful' +import type { EventEmissionResult } from '../events' +import type { ResolvedData } from '../resolvers' +import { FlagsResolver, MergeTagValueResolver, OptimizedEntryResolver } from '../resolvers' +import { staticObservable } from '../signals' +import type { OptimizationRuntime } from './OptimizationRuntime' + +const logger = createScopedLogger('Optimization:SnapshotRuntime') + +/** + * Request-scoped, read-only state used to seed a {@link SnapshotRuntime}. + * + * @remarks + * Mirrors the serializable server-to-client handoff shape. `data` is the + * resolved {@link OptimizationData} the server computed for the request; + * `consent` and `locale` carry the request's policy so read hooks observe the + * same values the live client SDK will report after hydration. + * + * @public + */ +export interface OptimizationSnapshot { + /** Optimization data resolved server-side for the current request. */ + readonly data?: OptimizationData + /** Event consent for the request, if known. */ + readonly consent?: boolean + /** Profile-continuity persistence consent for the request, if known. */ + readonly persistenceConsent?: boolean + /** Active locale for the request, if known. */ + readonly locale?: string +} + +/** + * Reason logged when an action method is invoked on the read-only runtime. + * + * @internal + */ +const INERT_ACTION_WARNING = + 'Optimization action called on the server (read-only runtime); it is a no-op. ' + + 'Actions such as identify/page/track only run in the browser after hydration.' + +const ACCEPTED_NOOP_RESULT: EventEmissionResult = { accepted: false } + +/** + * A read-only {@link OptimizationRuntime} backed by a request-scoped snapshot. + * + * @remarks + * Used during server-side rendering and the initial client render, before the + * live stateful SDK exists. It satisfies the exact same interface the browser + * SDK does, so a framework provider can render children from either backing + * without branching on environment: + * + * - **Resolve** methods delegate to the shared static resolvers — identical + * behavior to the browser SDK, with no browser globals and no API client. + * - **`states`** are static observables over the snapshot: `current` returns the + * snapshot value and `subscribe` emits it once and never changes, so + * `useSyncExternalStore` server snapshots stay stable. + * - **Actions** are inert no-ops that warn in development. There is no request, + * queue, or user interaction to service on the server. + * + * Create instances with {@link createSnapshotRuntime}. + * + * @public + */ +class SnapshotRuntime implements OptimizationRuntime { + private readonly snapshot: OptimizationSnapshot + private readonly changes: ChangeArray | undefined + private readonly currentSelectedOptimizations: SelectedOptimizationArray | undefined + private readonly currentProfile: Profile | undefined + + readonly states: CoreStates + + constructor(snapshot: OptimizationSnapshot = {}) { + this.snapshot = snapshot + this.changes = snapshot.data?.changes + this.currentSelectedOptimizations = snapshot.data?.selectedOptimizations + this.currentProfile = snapshot.data?.profile + + const canOptimize = this.currentSelectedOptimizations !== undefined + + this.states = { + blockedEventStream: staticObservable(undefined), + consent: staticObservable(snapshot.consent), + persistenceConsent: staticObservable(snapshot.persistenceConsent ?? snapshot.consent), + eventStream: staticObservable(undefined), + locale: staticObservable(snapshot.locale), + canOptimize: staticObservable(canOptimize), + optimizationPossible: staticObservable(true), + experienceRequestState: staticObservable( + snapshot.data ? { status: 'success' } : { status: 'idle' }, + ), + selectedOptimizations: staticObservable(this.currentSelectedOptimizations), + previewPanelAttached: staticObservable(false), + previewPanelOpen: staticObservable(false), + profile: staticObservable(this.currentProfile), + flag: (name: string) => staticObservable(this.getFlag(name)), + } + } + + resolveOptimizedEntry< + S extends EntrySkeletonType = EntrySkeletonType, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations?: SelectedOptimizationArray, + ): ResolvedData + resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >(entry: Entry, selectedOptimizations?: SelectedOptimizationArray): ResolvedData + resolveOptimizedEntry< + S extends EntrySkeletonType, + M extends ChainModifiers, + L extends LocaleCode = LocaleCode, + >( + entry: Entry, + selectedOptimizations?: SelectedOptimizationArray, + ): ResolvedData { + return OptimizedEntryResolver.resolve( + entry, + selectedOptimizations ?? this.currentSelectedOptimizations, + ) + } + + getMergeTagValue( + embeddedEntryNodeTarget: MergeTagEntry, + profile: Profile | undefined = this.currentProfile, + ): string | undefined { + return MergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) + } + + getFlag(name: string, changes: ChangeArray | undefined = this.changes): Json { + return FlagsResolver.resolve(changes)[name] + } + + get locale(): string | undefined { + return this.snapshot.locale + } + + hasConsent(): boolean { + return this.snapshot.consent === true + } + + async identify(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async page(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async screen(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async track(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async trackView(): Promise { + return await Promise.resolve(this.inertEventEmission()) + } + + async trackClick(): Promise { + this.warnInert() + await Promise.resolve() + } + + async trackHover(): Promise { + this.warnInert() + await Promise.resolve() + } + + async trackFlagView(): Promise { + this.warnInert() + await Promise.resolve() + } + + consent(): void { + this.warnInert() + } + + reset(): void { + this.warnInert() + } + + async flush(): Promise { + this.warnInert() + await Promise.resolve() + } + + setLocale(): string | undefined { + this.warnInert() + return this.snapshot.locale + } + + destroy(): void { + // No-op: the snapshot runtime owns no listeners, timers, or singletons. + } + + private inertEventEmission(): EventEmissionResult { + this.warnInert() + return ACCEPTED_NOOP_RESULT + } + + private warnInert(): void { + logger.warn(INERT_ACTION_WARNING) + } +} + +/** + * Create a read-only {@link OptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves entries/merge tags/flags from the snapshot, + * exposes static state observables, and treats actions as no-ops. + * + * @example + * ```ts + * const runtime = createSnapshotRuntime({ data: serverOptimizationData, consent: true }) + * const { entry } = runtime.resolveOptimizedEntry(baselineEntry) + * ``` + * + * @public + */ +export function createSnapshotRuntime(snapshot?: OptimizationSnapshot): OptimizationRuntime { + return new SnapshotRuntime(snapshot) +} + +export type { SnapshotRuntime } diff --git a/packages/universal/core-sdk/src/runtime/index.ts b/packages/universal/core-sdk/src/runtime/index.ts new file mode 100644 index 000000000..35397c4f9 --- /dev/null +++ b/packages/universal/core-sdk/src/runtime/index.ts @@ -0,0 +1,6 @@ +export type { OptimizationRuntime } from './OptimizationRuntime' +export { + createSnapshotRuntime, + type OptimizationSnapshot, + type SnapshotRuntime, +} from './SnapshotRuntime' diff --git a/packages/universal/core-sdk/src/signals/Observable.test.ts b/packages/universal/core-sdk/src/signals/Observable.test.ts index 1db3d123a..e78c288fa 100644 --- a/packages/universal/core-sdk/src/signals/Observable.test.ts +++ b/packages/universal/core-sdk/src/signals/Observable.test.ts @@ -1,5 +1,39 @@ import { signal } from '@preact/signals-core' -import { toObservable } from './Observable' +import { staticObservable, toObservable } from './Observable' + +describe('staticObservable', () => { + it('exposes the constant value as current', () => { + expect(staticObservable('value').current).toBe('value') + expect(staticObservable(undefined).current).toBeUndefined() + }) + + it('emits the value once on subscribe and never again', () => { + const received: number[] = [] + const subscription = staticObservable(42).subscribe((value) => { + received.push(value) + }) + + expect(received).toEqual([42]) + + // No mechanism can change a static value, so the callback never fires again. + subscription.unsubscribe() + expect(received).toEqual([42]) + }) + + it('emits non-nullish values through subscribeOnce and skips nullish ones', () => { + const receivedValue: number[] = [] + staticObservable(7).subscribeOnce((value) => { + receivedValue.push(value) + }) + expect(receivedValue).toEqual([7]) + + const receivedNullish: unknown[] = [] + staticObservable(undefined).subscribeOnce((value) => { + receivedNullish.push(value) + }) + expect(receivedNullish).toEqual([]) + }) +}) describe('Observable helpers', () => { it('exposes current as a deep-cloned snapshot of the signal value', () => { diff --git a/packages/universal/core-sdk/src/signals/Observable.ts b/packages/universal/core-sdk/src/signals/Observable.ts index b1348d3c6..f628cc039 100644 --- a/packages/universal/core-sdk/src/signals/Observable.ts +++ b/packages/universal/core-sdk/src/signals/Observable.ts @@ -55,6 +55,41 @@ function isNonNullish(value: TValue): value is NonNullable { return value !== undefined && value !== null } +const NOOP_SUBSCRIPTION: Subscription = { unsubscribe: () => undefined } + +/** + * Create an {@link Observable} over a fixed value that never changes. + * + * @typeParam T - Value type emitted by the observable. + * @param value - The constant value exposed as `current` and emitted on subscribe. + * @returns An observable that emits `value` once on subscribe and never again. + * + * @remarks + * Use this for read-only, request-scoped state surfaces such as server-side + * rendering, where a snapshot is known up front and cannot update. `subscribe` + * invokes `next(value)` synchronously and returns a no-op {@link Subscription}; + * `subscribeOnce` invokes `next(value)` synchronously only when `value` is + * non-nullish. Unlike {@link toObservable}, no cloning is performed — the caller + * owns snapshot isolation for the constant value. + * + * @public + */ +export function staticObservable(value: T): Observable { + return { + current: value, + + subscribe(next) { + next(value) + return NOOP_SUBSCRIPTION + }, + + subscribeOnce(next) { + if (isNonNullish(value)) next(value) + return NOOP_SUBSCRIPTION + }, + } +} + function toError(value: unknown): Error { if (value instanceof Error) return value return new Error(`Subscriber threw non-Error value: ${String(value)}`) From c179f64d24a28e014d9f4527bf25dbb67ac3ef3e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:24:10 +0200 Subject: [PATCH 02/21] =?UTF-8?q?=E2=9C=A8=20feat(web-sdk):=20Re-export=20?= =?UTF-8?q?the=20isomorphic=20runtime=20on=20a=20`./runtime`=20subpath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `@contentful/optimization-web/runtime` pass-through entry that re-exports `@contentful/optimization-core/runtime`, mirroring the existing `core-sdk`, `api-client`, and `api-schemas` pass-through subpaths. This lets the React layer consume the snapshot runtime through the Web SDK it already depends on. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/web-sdk/package.json | 10 ++++++++++ packages/web/web-sdk/rslib.config.ts | 2 ++ packages/web/web-sdk/src/runtime.ts | 1 + 3 files changed, 13 insertions(+) create mode 100644 packages/web/web-sdk/src/runtime.ts diff --git a/packages/web/web-sdk/package.json b/packages/web/web-sdk/package.json index b4bac47b7..458944b64 100644 --- a/packages/web/web-sdk/package.json +++ b/packages/web/web-sdk/package.json @@ -92,6 +92,16 @@ "default": "./dist/core-sdk.cjs" } }, + "./runtime": { + "import": { + "types": "./dist/runtime.d.mts", + "default": "./dist/runtime.mjs" + }, + "require": { + "types": "./dist/runtime.d.cts", + "default": "./dist/runtime.cjs" + } + }, "./api-client": { "import": { "types": "./dist/api-client.d.mts", diff --git a/packages/web/web-sdk/rslib.config.ts b/packages/web/web-sdk/rslib.config.ts index 88147bd85..7ddc803de 100644 --- a/packages/web/web-sdk/rslib.config.ts +++ b/packages/web/web-sdk/rslib.config.ts @@ -61,6 +61,7 @@ export default defineConfig({ 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', + runtime: './src/runtime.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', }, @@ -100,6 +101,7 @@ export default defineConfig({ 'tracking-attributes': './src/tracking-attributes.ts', 'web-components': './src/web-components/index.ts', 'core-sdk': './src/core-sdk.ts', + runtime: './src/runtime.ts', 'api-client': './src/api-client.ts', 'api-schemas': './src/api-schemas.ts', }, diff --git a/packages/web/web-sdk/src/runtime.ts b/packages/web/web-sdk/src/runtime.ts new file mode 100644 index 000000000..1609e2621 --- /dev/null +++ b/packages/web/web-sdk/src/runtime.ts @@ -0,0 +1 @@ +export * from '@contentful/optimization-core/runtime' From 676904742755477e3cd94b54559303fda7521a5c Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:24:53 +0200 Subject: [PATCH 03/21] =?UTF-8?q?=E2=9C=A8=20feat(react-web):=20Make=20Opt?= =?UTF-8?q?imizationProvider=20server-side=20renderable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the blank-page-on-SSR bug (NT-3560): the provider gated all children on an SDK created inside `useLayoutEffect`, which never runs during server rendering, so it returned `null` and produced empty HTML. The provider is now isomorphic: it seeds context with a read-only snapshot runtime for the server render and the initial client render, then upgrades to the live `ContentfulOptimization` in the mount effect and swaps context. Same snapshot on both sides keeps hydration mismatch-free. - `WebOptimizationRuntime` composes the universal runtime with the browser-only surface (`tracking`, `trackCurrentPage`); `createWebSnapshotRuntime` provides no-op versions for the server backing. - Context becomes `{ sdk, isReady, error }` where `sdk` is the always-present isomorphic runtime. `useOptimization()` no longer throws at render time on the server — reads/resolves work everywhere, tracking no-ops server-side. - Read hooks now pass a real `getServerSnapshot` to `useSyncExternalStore`. - Preserve stable context identity for injected-SDK providers with no async setup. Tests updated to the isomorphic contract (children render on the server; the live SDK is not constructed there) plus a new SSR first-paint test file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/context/OptimizationContext.tsx | 22 ++++- .../context/OptimizationContext.types.test.ts | 10 +- .../src/hooks/useOptimization.ts | 19 ++-- .../src/hooks/useOptimizationState.ts | 8 +- .../react-web-sdk/src/index.test.tsx | 31 +++--- ...ptimizationProvider.onStatesReady.test.tsx | 24 +++-- .../OptimizationProvider.ssr.test.tsx | 94 +++++++++++++++++++ .../src/provider/OptimizationProvider.tsx | 94 ++++++++++++++----- .../react-web-sdk/src/runtime/webRuntime.ts | 66 +++++++++++++ 9 files changed, 312 insertions(+), 56 deletions(-) create mode 100644 packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx create mode 100644 packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 0a221b06b..87d09ce67 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -1,9 +1,27 @@ -import type ContentfulOptimization from '@contentful/optimization-web' import { createContext } from 'react' -export type OptimizationSdk = ContentfulOptimization +import type { WebOptimizationRuntime } from '../runtime/webRuntime' + +/** + * The runtime object exposed to React consumers. + * + * @remarks + * On the client after hydration this is the live `ContentfulOptimization` + * instance; during server rendering and the initial client render it is a + * read-only snapshot runtime with the same shape. Both satisfy + * {@link WebOptimizationRuntime}, so consumers never branch on environment. + * + * @public + */ +export type OptimizationSdk = WebOptimizationRuntime export interface OptimizationContextValue { + /** + * The isomorphic Optimization runtime. Always present once a provider is + * mounted: a snapshot-backed runtime on the server and initial client render, + * the live SDK after hydration. Consumers use the same object in every + * environment. + */ readonly sdk: OptimizationSdk | undefined readonly isReady: boolean readonly error: Error | undefined diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts index b981178c8..bee1a82fb 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.types.test.ts @@ -5,12 +5,12 @@ export function acceptReactWebSdk(runtime: ContentfulOptimization): Optimization return runtime } -export function acceptConcreteWebSdk(sdk: OptimizationSdk): ContentfulOptimization { - return sdk -} - describe('React Web OptimizationSdk type contract', () => { - it('aliases the concrete Web SDK runtime', () => { + it('is satisfied by the concrete Web SDK runtime', () => { + // The live ContentfulOptimization instance is assignable to OptimizationSdk + // (verified by acceptReactWebSdk type-checking). OptimizationSdk is the + // broader isomorphic runtime surface, so the reverse is intentionally not + // guaranteed — a snapshot runtime also satisfies it on the server. expect(true).toBe(true) }) }) diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts index d2792e18f..275b19c66 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimization.ts @@ -24,24 +24,29 @@ export function useOptimizationContext(): OptimizationContextValue { } /** - * Returns the initialized Contentful Optimization SDK instance. + * Returns the Contentful Optimization runtime. + * + * @remarks + * The returned object is isomorphic and always present once a provider is + * mounted: a read-only snapshot runtime during server rendering and the initial + * client render, and the live SDK after hydration. It is safe to call in any + * environment — reads and entry resolution work everywhere; event and + * browser-only tracking calls are no-ops on the server. Throws only when used + * outside an {@link OptimizationProvider}. * * @public */ export function useOptimization(): OptimizationSdk { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() - if (!sdk || !isReady) { + if (!sdk) { if (error) { throw new Error(`ContentfulOptimization SDK failed to initialize: ${error.message}`, { cause: error, }) } - throw new Error( - 'ContentfulOptimization SDK is still initializing. ' + - 'This should not happen when using the loading gate in OptimizationProvider.', - ) + throw getMissingProviderError() } return sdk diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts index 50e335988..13984ffd4 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useOptimizationState.ts @@ -38,7 +38,13 @@ function useObservableState(observable: ObservableLike): T { const getSnapshot = useCallback(() => snapshotRef.current, []) - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + // On the server (and the initial hydration render) React uses the third + // argument. Read directly from the observable's current value so the server + // snapshot matches the value the client is seeded with, avoiding hydration + // mismatches. The observable is a static, request-scoped snapshot server-side. + const getServerSnapshot = useCallback(() => observable.current, [observable]) + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } /** diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 2cfea12d8..26cce0a35 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -141,7 +141,7 @@ describe('@contentful/optimization-react-web core providers', () => { withoutLocale.unmount() }) - it('does not create an owned optimization instance during server render', () => { + it('renders children on the server without creating an owned optimization instance', () => { let renderedChild = false function Probe(): null { @@ -149,7 +149,7 @@ describe('@contentful/optimization-react-web core providers', () => { return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(renderedChild).toBe(false) + // The isomorphic provider renders children on the server (backed by a + // snapshot runtime) but never constructs the live browser SDK there. + expect(renderedChild).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -507,12 +508,14 @@ describe('@contentful/optimization-react-web core providers', () => { }) it('supports live updates fallback semantics for dependent components', () => { - const results: boolean[] = [] + // The isomorphic provider renders children once from the snapshot runtime and + // again after swapping to the live SDK, so capture the resolved value per + // labeled component rather than asserting a fixed render count. + const resolved: Record = {} - function Probe({ liveUpdates }: { liveUpdates?: boolean }): null { + function Probe({ label, liveUpdates }: { label: string; liveUpdates?: boolean }): null { const context = useLiveUpdates() - const isLive = liveUpdates ?? context.globalLiveUpdates - results.push(isLive) + resolved[label] = liveUpdates ?? context.globalLiveUpdates return null } @@ -524,8 +527,8 @@ describe('@contentful/optimization-react-web core providers', () => { api={testConfig.api} liveUpdates={true} > - - + + ) } @@ -538,7 +541,7 @@ describe('@contentful/optimization-react-web core providers', () => { api={testConfig.api} liveUpdates={false} > - + ) } @@ -546,7 +549,11 @@ describe('@contentful/optimization-react-web core providers', () => { renderClient().unmount() renderClient().unmount() - expect(results).toEqual([true, false, true]) + expect(resolved).toEqual({ + 'global-live': true, + 'override-off': false, + 'override-on': true, + }) }) it('destroys the optimization singleton on provider unmount', () => { diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index c7569c612..b3ae8997d 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -334,15 +334,17 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not construct owned sdk instances during server render', () => { + it('renders children from a snapshot runtime during server render without constructing the owned sdk', () => { let childRendered = false + let isReadyDuringRender = false function Probe(): null { childRendered = true + isReadyDuringRender = useOptimizationContext().isReady return null } - const markup = renderToString( + renderToString( { , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + // Children render on the server (the provider is isomorphic), backed by a + // read-only snapshot runtime, but the live browser SDK is never constructed. + expect(childRendered).toBe(true) + expect(isReadyDuringRender).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -407,24 +411,28 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('does not render injected sdk children during server render when state setup must run first', () => { + it('renders injected sdk children during server render while onStatesReady defers to the client', () => { const sdk = createOptimizationSdk() const onStatesReady = rs.fn() let childRendered = false + let capturedSdk: ReturnType | undefined = undefined function Probe(): null { childRendered = true + capturedSdk = useOptimization() return null } - const markup = renderToString( + renderToString( , ) - expect(markup).toBe('') - expect(childRendered).toBe(false) + // The injected SDK backs the server render directly, so children render. The + // onStatesReady side effect runs only on the client (in the mount effect). + expect(childRendered).toBe(true) + expect(capturedSdk).toBe(sdk) expect(onStatesReady).not.toHaveBeenCalled() }) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx new file mode 100644 index 000000000..5894970a7 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.ssr.test.tsx @@ -0,0 +1,94 @@ +import type { OptimizationData } from '@contentful/optimization-web/api-schemas' +import { describe, expect, it } from '@rstest/core' +import type { ReactElement } from 'react' +import { renderToString } from 'react-dom/server' +import { useConsentState, useProfileState } from '../hooks/useOptimizationState' +import { OptimizationProvider } from './OptimizationProvider' + +const testConfig = { + clientId: 'test-client', + environment: 'test', + api: { insightsBaseUrl: 'http://localhost/insights', experienceBaseUrl: 'http://localhost/exp' }, +} + +function createServerOptimizationState(profileId: string): OptimizationData { + return { + changes: [], + selectedOptimizations: [], + profile: { + id: profileId, + stableId: profileId, + random: 0.5, + audiences: [], + traits: {}, + location: {}, + session: { + id: `${profileId}-session`, + isReturningVisitor: false, + landingPage: { + path: '/', + query: {}, + referrer: '', + search: '', + title: '', + url: 'http://localhost/', + }, + count: 1, + activeSessionLength: 0, + averageSessionLength: 0, + }, + }, + } +} + +describe('OptimizationProvider server rendering', () => { + it('renders read-state hooks into server HTML from configured defaults', () => { + function Probe(): ReactElement { + const consent = useConsentState() + return {String(consent)} + } + + const markup = renderToString( + + + , + ) + + // The read hook resolves against the snapshot runtime during server render, + // via useSyncExternalStore's getServerSnapshot — no throw, real HTML. + expect(markup).toContain('data-testid="consent"') + expect(markup).toContain('true') + }) + + it('renders profile from serverOptimizationState during server render', () => { + const serverOptimizationState = createServerOptimizationState('server-profile') + + function Probe(): ReactElement { + const profile = useProfileState() + return {profile?.id ?? 'anonymous'} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('server-profile') + }) + + it('reflects a no-consent default in server HTML', () => { + function Probe(): ReactElement { + const consent = useConsentState() + return {consent === undefined ? 'unset' : String(consent)} + } + + const markup = renderToString( + + + , + ) + + expect(markup).toContain('unset') + }) +}) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index eaa002daa..4cadf076e 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -9,9 +9,17 @@ import { type OnStatesReady as SharedOnStatesReady, type TrackEntryInteractionOptions as SharedTrackEntryInteractionOptions, } from '@contentful/optimization-web/presentation' -import { useLayoutEffect, useRef, useState, type PropsWithChildren, type ReactElement } from 'react' +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from 'react' import { OptimizationContext, type OptimizationSdk } from '../context/OptimizationContext' +import { createWebSnapshotRuntime, type WebOptimizationRuntime } from '../runtime/webRuntime' /** * Provider-owned callback for app-level subscriptions once SDK state is ready. @@ -27,7 +35,9 @@ type ProviderSdkBinding = OptimizationRootSdkBinding interface ProviderState { readonly error: Error | undefined readonly isReady: boolean - readonly sdk: OptimizationSdk | undefined + /** Whether `runtime` is the live browser SDK (vs the initial snapshot runtime). */ + readonly isLive: boolean + readonly runtime: WebOptimizationRuntime | undefined } interface ServerOptimizationStateProps { @@ -159,6 +169,15 @@ function isPromiseLike(value: T | Promise): value is Promise { return value instanceof Promise } +/** + * Whether an injected SDK can back the initial render directly, with no + * asynchronous setup to run first. + * + * @remarks + * When true, the initial-render runtime is already the final live runtime, so + * the mount effect must not re-set state — this keeps the context identity + * stable across re-renders. + */ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): boolean { return ( props.sdk !== undefined && @@ -167,20 +186,49 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): ) } -export function OptimizationProvider(props: OptimizationProviderProps): ReactElement | null { +/** + * Build the runtime used for the initial render (server render and the first + * client render, before the mount effect runs). + * + * @remarks + * With a config-driven provider this is a read-only snapshot runtime seeded from + * server state plus the configured consent/locale defaults, so it reports the + * same values the live SDK will after hydration. With an injected SDK the live + * instance is already available and is used directly. + */ +function createInitialRuntime(props: OptimizationProviderProps): WebOptimizationRuntime { + if (props.sdk !== undefined) { + return props.sdk + } + + return createWebSnapshotRuntime({ + data: props.serverOptimizationState, + consent: props.defaults?.consent, + persistenceConsent: props.defaults?.persistenceConsent, + locale: props.locale, + }) +} + +export function OptimizationProvider(props: OptimizationProviderProps): ReactElement { const { children } = props const initialPropsRef = useRef(props) const liveLocale = props.sdk === undefined ? props.locale : undefined - const canRenderInjectedSdk = canUseInjectedSdkDuringInitialRender(props) - const [state, setState] = useState(() => ({ - error: undefined, - isReady: canRenderInjectedSdk, - sdk: canRenderInjectedSdk ? props.sdk : undefined, - })) + const [state, setState] = useState(() => { + const injectedIsLive = canUseInjectedSdkDuringInitialRender(props) + + return { + error: undefined, + isReady: true, + isLive: injectedIsLive, + runtime: createInitialRuntime(props), + } + }) useLayoutEffect(() => { const { current: initialProps } = initialPropsRef + // An injected SDK with no async setup already backs the initial render; do + // not re-set state so the context value identity stays stable. if (canUseInjectedSdkDuringInitialRender(initialProps)) { return } @@ -203,12 +251,12 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } sdkBinding = initializedBinding - setState({ error: undefined, isReady: true, sdk: initializedBinding.sdk }) + setState({ error: undefined, isReady: true, isLive: true, runtime: initializedBinding.sdk }) } function setInitializationError(error: unknown): void { if (!setupState.disposed) { - setState({ error: toError(error), isReady: false, sdk: undefined }) + setState({ error: toError(error), isReady: false, isLive: false, runtime: undefined }) } } @@ -237,22 +285,26 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle }, []) useLayoutEffect(() => { - if (state.sdk === undefined || props.sdk !== undefined || liveLocale === undefined) { + if (!state.isLive || state.runtime === undefined || props.sdk !== undefined) { + return + } + if (liveLocale === undefined) { return } try { - state.sdk.setLocale(liveLocale) + state.runtime.setLocale(liveLocale) } catch (error: unknown) { - setState({ error: toError(error), isReady: true, sdk: state.sdk }) + setState({ error: toError(error), isReady: true, isLive: true, runtime: state.runtime }) } - }, [liveLocale, props.sdk, state.sdk]) + }, [liveLocale, props.sdk, state.isLive, state.runtime]) - const shouldRenderChildren = state.isReady || state.error !== undefined - - if (!shouldRenderChildren) { - return null - } + const contextValue = useMemo( + () => ({ sdk: state.runtime, isReady: state.isReady, error: state.error }), + [state.runtime, state.isReady, state.error], + ) - return {children} + return ( + {children} + ) } diff --git a/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts new file mode 100644 index 000000000..79c08857d --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/runtime/webRuntime.ts @@ -0,0 +1,66 @@ +import type ContentfulOptimization from '@contentful/optimization-web' +import { + createSnapshotRuntime, + type OptimizationRuntime, + type OptimizationSnapshot, +} from '@contentful/optimization-web/runtime' + +/** + * Members of the live web SDK that go beyond the universal + * {@link OptimizationRuntime} surface: browser-only imperative APIs used inside + * effects (never during render). + * + * @internal + */ +type WebOnlyRuntimeMembers = 'tracking' | 'trackCurrentPage' + +/** + * The single runtime object consumed by the React layer. + * + * @remarks + * Composes the universal, isomorphic {@link OptimizationRuntime} (pure resolvers, + * read `states`, and event actions) with the browser-only web SDK surface + * (`tracking`, `trackCurrentPage`). The live {@link ContentfulOptimization} + * instance satisfies it by construction; a server/initial-render backing is + * produced by {@link createWebSnapshotRuntime}, where the browser-only members + * are inert no-ops. + * + * Every member is safe to reference in any environment: render-time members + * behave correctly on the server, and effect-only members are no-ops there (the + * server never runs effects, so this only matters defensively). + * + * @public + */ +export interface WebOptimizationRuntime + extends OptimizationRuntime, Pick {} + +const NOOP_TRACKING: WebOptimizationRuntime['tracking'] = { + enable: () => undefined, + disable: () => undefined, + enableElement: () => undefined, + disableElement: () => undefined, + clearElement: () => undefined, +} + +/** + * Create a read-only {@link WebOptimizationRuntime} from a request-scoped snapshot. + * + * @param snapshot - Server-resolved optimization state for the current request. + * @returns A runtime that resolves and reads from the snapshot, with browser-only + * tracking APIs as inert no-ops. + * + * @remarks + * Used by the provider during server rendering and the initial client render, + * before the live SDK exists. Delegates the universal surface to + * {@link createSnapshotRuntime} and adds no-op `tracking`/`trackCurrentPage`. + * + * @public + */ +export function createWebSnapshotRuntime(snapshot?: OptimizationSnapshot): WebOptimizationRuntime { + const runtime = createSnapshotRuntime(snapshot) + + return Object.assign(runtime, { + tracking: NOOP_TRACKING, + trackCurrentPage: async () => await Promise.resolve({ accepted: false as const }), + }) +} From e4fe34cab6e3d3d51fe9e690254c3237e528d9dd Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:25:51 +0200 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9C=A8=20feat(nextjs-sdk=5Fssr):=20See?= =?UTF-8?q?d=20the=20provider=20with=20server=20optimization=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve request-scoped optimization data in the root layout and pass it to `OptimizationRoot` as `serverOptimizationState`, so the isomorphic provider renders identified/personalized first-paint state with JavaScript disabled. Drop `SKIP_NO_JS` from the E2E flags: the JavaScript-disabled SSR first-paint and variant-resolution suites (from PR #335) now pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- implementations/nextjs-sdk_ssr/.env.example | 3 ++- implementations/nextjs-sdk_ssr/app/layout.tsx | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/implementations/nextjs-sdk_ssr/.env.example b/implementations/nextjs-sdk_ssr/.env.example index 07e791524..09257f777 100644 --- a/implementations/nextjs-sdk_ssr/.env.example +++ b/implementations/nextjs-sdk_ssr/.env.example @@ -1,7 +1,8 @@ DOTENV_CONFIG_QUIET=true # SKIP_NO_JS skips the JavaScript-disabled variant resolution suite (SSR, JavaScript disabled). -E2E_FLAGS=SSR,SKIP_NO_JS +# The provider renders personalized state server-side, so the JS-disabled suite runs by default. +E2E_FLAGS=SSR APP_PORT=3001 PUBLIC_NINETAILED_CLIENT_ID="mock-client-id" diff --git a/implementations/nextjs-sdk_ssr/app/layout.tsx b/implementations/nextjs-sdk_ssr/app/layout.tsx index a2c84a055..53e28608e 100644 --- a/implementations/nextjs-sdk_ssr/app/layout.tsx +++ b/implementations/nextjs-sdk_ssr/app/layout.tsx @@ -2,11 +2,13 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { optimization } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' +import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' import 'e2e-web/theme.css' import type { Metadata } from 'next' -import { cookies } from 'next/headers' +import { cookies, headers } from 'next/headers' import Link from 'next/link' import { Suspense, type ReactNode } from 'react' @@ -17,9 +19,21 @@ export const metadata: Metadata = { } export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - const cookieStore = await cookies() + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) const appConsent = getAppConsent(cookieStore) + // Resolve the request-scoped optimization state on the server so the + // isomorphic provider renders identified/personalized first-paint state even + // with JavaScript disabled. Only fetched when consent permits it. + const serverOptimizationState = appConsent + ? await getNextjsServerOptimizationData(optimization, { + consent: { events: true, persistence: true }, + cookies: cookieStore, + headers: headerStore, + locale: appConfig.locale, + }).then(({ data }) => data) + : undefined + return ( @@ -35,6 +49,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac version: '0.1.0', }} defaults={{ consent: appConsent, persistenceConsent: appConsent }} + serverOptimizationState={serverOptimizationState} > From 8cae73713fa9fdc5afd38369ea6ad279c734c870 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:30:19 +0200 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=93=9D=20docs(concepts):=20Add=20se?= =?UTF-8?q?rver-side=20rendering=20and=20hydration=20concept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the isomorphic runtime seam, the three capability tiers, the render→hydrate→swap lifecycle, the React Server Component boundary, and the hydration determinism contract. Add it to the concepts index and cross-link it from the profile-synchronization concept. Co-Authored-By: Claude Opus 4.8 (1M context) --- documentation/concepts/README.md | 5 + ...nchronization-between-client-and-server.md | 4 +- .../server-side-rendering-and-hydration.md | 183 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 documentation/concepts/server-side-rendering-and-hydration.md diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md index e0d7c52b3..6e3f62f79 100644 --- a/documentation/concepts/README.md +++ b/documentation/concepts/README.md @@ -8,6 +8,7 @@ children: - ./interaction-tracking-in-web-sdks.md - ./interaction-tracking-in-node-and-stateless-environments.md - ./profile-synchronization-between-client-and-server.md + - ./server-side-rendering-and-hydration.md - ./react-native-sdk-interaction-tracking-mechanics.md - ./ios-sdk-runtime-and-interaction-mechanics.md - ./android-sdk-runtime-and-interaction-mechanics.md @@ -45,6 +46,10 @@ they are not the first stop for installation or setup commands. - [Profile synchronization between client and server](./profile-synchronization-between-client-and-server.md) - explains how profile identity, profile data, cookies, browser storage, and Experience API responses work together when Node and Web SDK runtimes share a visitor journey. +- [Server-side rendering and hydration](./server-side-rendering-and-hydration.md) - explains how the + React layer renders personalized content on the server through an isomorphic runtime seam, where + the server-versus-client boundary falls, and the determinism contract that keeps server HTML and + the first client render identical. - [React Native SDK interaction tracking mechanics](./react-native-sdk-interaction-tracking-mechanics.md) - explains how the React Native SDK observes, gates, and emits tracking events, covering event types, the viewport state machine, default visibility and timing, consent gating, scroll context, diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 3099e2b7b..21f0b227f 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -111,7 +111,9 @@ profile-changing events: For consent gates, see [Consent management in the Optimization SDK Suite](./consent-management-in-the-optimization-sdk-suite.md). For state shape and observable state mechanics, see -[Core state management](./core-state-management.md). +[Core state management](./core-state-management.md). For how the React provider renders this data on +the server and hydrates it without a mismatch, see +[Server-side rendering and hydration](./server-side-rendering-and-hydration.md). ## Runtime responsibilities diff --git a/documentation/concepts/server-side-rendering-and-hydration.md b/documentation/concepts/server-side-rendering-and-hydration.md new file mode 100644 index 000000000..986606063 --- /dev/null +++ b/documentation/concepts/server-side-rendering-and-hydration.md @@ -0,0 +1,183 @@ +--- +title: Server-side rendering and hydration +--- + +# Server-side rendering and hydration + +Use this document to understand how the React layer renders personalized content on the server and +hands it off to the browser without a blank first paint or a hydration mismatch. It explains the +isomorphic runtime seam that lets the same hooks and components run in both environments, where the +server-versus-client boundary actually falls, and the determinism contract that keeps server HTML +and the first client render identical. + +For installation and setup, start with the [Optimization SDK guides](../guides/README.md). For how +profile data and identifiers move between runtimes, see +[Profile synchronization between client and server](./profile-synchronization-between-client-and-server.md). +For how a baseline entry resolves to a variant, see +[Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md). + +
+ Table of Contents + + +- [Runtime applicability](#runtime-applicability) +- [The problem SSR poses](#the-problem-ssr-poses) +- [Capability tiers](#capability-tiers) +- [The isomorphic runtime seam](#the-isomorphic-runtime-seam) + - [The provider chooses the backing](#the-provider-chooses-the-backing) + - [Reading state on the server](#reading-state-on-the-server) + - [Actions and tracking on the server](#actions-and-tracking-on-the-server) +- [The render lifecycle](#the-render-lifecycle) +- [The React Server Component boundary](#the-react-server-component-boundary) +- [The hydration determinism contract](#the-hydration-determinism-contract) +- [Framework handoff](#framework-handoff) + + +
+ +## Runtime applicability + +| Runtime | SSR role | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **React Web SDK** (`@contentful/optimization-react-web`) | Owns the isomorphic provider, hooks, and `OptimizedEntry`. Renders on the server and hydrates in the browser. | +| **Core SDK** (`@contentful/optimization-core`) | Provides the `OptimizationRuntime` contract and `createSnapshotRuntime`, the read-only runtime used for server rendering. Exposed on the `./runtime` subpath. | +| **Node SDK** (`@contentful/optimization-node`) | Produces the request-scoped `OptimizationData` snapshot the server render is seeded with, using `forRequest()`. | +| **Next.js SDK** (`@contentful/optimization-nextjs`) | Binds the request context, resolves the snapshot, and forwards it to the provider. | + +## The problem SSR poses + +The browser SDK (`ContentfulOptimization`) is stateful and browser-bound: its constructor reads +`localStorage` and `document.cookie`, attaches listeners, and registers a `window` singleton. It +cannot be constructed during server rendering, and React does not run effects (`useEffect`, +`useLayoutEffect`) on the server at all — only the render function and `useState`/`useMemo` +initializers run. + +A provider that constructs the SDK in an effect and gates its children on that SDK therefore renders +nothing on the server: the effect never runs, so the children never mount, and `renderToString` +emits an empty tree. With JavaScript disabled that is a permanently blank page; with JavaScript +enabled it is a blank first paint until hydration. + +The fix is not to make the browser SDK run on the server. It is to recognize that rendering +personalized content does not require the _stateful browser_ SDK — only the personalization data and +the pure logic that resolves it. + +## Capability tiers + +The SDK surface splits into three tiers by what each capability needs, not by environment: + +| Tier | Members | Needs a browser? | Server behavior | +| --------------- | ------------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------- | +| **Resolve** | `resolveOptimizedEntry`, `getMergeTagValue`, `getFlag` | No — pure functions | Identical to the client | +| **Read state** | `states.{consent, profile, selectedOptimizations, canOptimize, …}` | No | Static values from the request snapshot | +| **Act / track** | `identify`, `page`, `track`, `tracking.*`, `trackCurrentPage` | Yes | Inert no-ops (there is no user interaction to record on the server) | + +The resolve tier already lives on `CoreBase`, the shared base class of both the stateful and +stateless runtimes, so variant resolution is available server-side with no browser globals. Reading +state server-side needs only a static view of the request's evaluated data. Only interaction +tracking genuinely needs the browser — and it is effect-only by nature (it observes DOM elements), +so it never executes during a server render. + +## The isomorphic runtime seam + +`OptimizationRuntime` is the single interface the hooks and components bind to. It is derived from +the stateful runtime, so the live browser SDK satisfies it by construction, and a server +implementation is forced to match the same signatures. Two runtimes implement it: + +- **The live SDK** (`ContentfulOptimization`) on the client after hydration. +- **A snapshot runtime** (`createSnapshotRuntime`, on `@contentful/optimization-core/runtime`) for + the server render and the initial client render. Its resolve methods delegate to the shared static + resolvers, its `states` are static observables over a serialized `OptimizationData` snapshot, and + its actions are inert no-ops that warn in development. + +Consumers never see two objects or branch on environment. They call `useOptimization()` and receive +one runtime whose behavior is correct wherever it runs. + +### The provider chooses the backing + +`OptimizationProvider` is the only place that decides which runtime backs the context: + +- On the server render and the first client render, it seeds the context with a snapshot runtime + built from the configured consent, locale, and any `serverOptimizationState`. `isReady` is `true`, + so children render immediately. +- In the mount effect (client only), it constructs the live `ContentfulOptimization`, hydrates it + with the same snapshot, and swaps the context to point at it. + +From that point the context is fully interactive: a later consent grant or identify call updates the +live signals and re-renders dependent components. + +### Reading state on the server + +Read hooks (`useConsentState`, `useProfileState`, `useSelectedOptimizationsState`, and the rest) +subscribe through `useSyncExternalStore`. On the server and during the first hydration render, React +calls the hook's `getServerSnapshot`, which reads the runtime observable's current value directly. +Because the snapshot runtime's observables are static, that value is stable and matches the value +the client is seeded with. + +### Actions and tracking on the server + +Event actions (`identify`, `page`, `track`) and browser-only tracking (`tracking.enableElement`, +`trackCurrentPage`) are safe to call on the snapshot runtime; they are inert no-ops. In practice the +server never reaches the browser-only ones, because they run inside effects, which do not execute +during server rendering. Keeping them present and safe means application code calls them the same +way everywhere without an environment check. + +## The render lifecycle + +```text +SERVER BROWSER +------ ------- +1. Resolve request OptimizationData +2. Render with a snapshot runtime ──► 3. Paint personalized HTML immediately + • render() + useState run (also what a JS-disabled browser or + • effects DO NOT run crawler sees) + • getServerSnapshot supplies state + • OptimizedEntry resolves variants ──► 4. Download JS + 5. Hydrate: first render reuses the SAME + snapshot, so markup matches + 6. Effect constructs the live SDK, + hydrates it, swaps the context + 7. Interactive: state changes re-render +``` + +## The React Server Component boundary + +In the Next.js App Router, "isomorphic" applies to Client Components (`'use client'`) that are +server-rendered for first paint and then hydrate — this covers `OptimizedEntry` and every read hook. +React hooks cannot run inside a true asynchronous Server Component; that is a React rule, not an SDK +limitation. + +Code that runs only in a Server Component — the fetch/decide step that produces the snapshot, or a +server-only entry render — uses the same-named imperative method, +`runtime.resolveOptimizedEntry(entry, selectedOptimizations)`, rather than a parallel vocabulary. +The developer story is therefore: resolve the snapshot in a Server Component or server helper, then +write the personalized UI as Client Components that server-render for first paint and hydrate for +interactivity. + +## The hydration determinism contract + +For hydration to succeed, the server render and the first client render must produce identical +markup. The rules that guarantee it: + +- **Seed the first client render synchronously from the snapshot.** Never resolve initial + personalization state inside an effect — that guarantees a flash and a hydration mismatch. +- **The serialized snapshot must be the exact value the client provider is seeded with.** Variant + resolution is a pure function of the baseline entry and `selectedOptimizations`, so identical + inputs produce identical output on both sides. +- **Reconcile later, not during hydration.** If the browser later discovers newer state (for example + a locally cached profile), it applies after hydration through a normal re-render — the + React-blessed path — rather than by rendering different initial markup. + +## Framework handoff + +The snapshot is a small, serializable `OptimizationData` shape (`profile`, `selectedOptimizations`, +`changes`). Each framework binds the same three seams — resolve on the server, transport the +snapshot, hydrate on the client: + +- **Next.js** resolves the snapshot with the server helpers and passes it to the provider through + `serverOptimizationState`. +- **Angular** carries the snapshot through `TransferState` and initializes read-only signals on the + server, then the live Web SDK in the browser. + +Because the resolve/read tiers are shared and the transport is one serializable shape, adding a new +framework is a thin adapter over the same contract rather than a reimplementation of personalization +logic. From 47eb4a179e34a74c166c7202fd4736d9d6416541 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:33:58 +0200 Subject: [PATCH 06/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk?= =?UTF-8?q?=5Fssr):=20Deduplicate=20server=20optimization=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root layout now seeds the provider with server optimization state, and the pages already resolve the same data for entry variants. Wrap the resolution in a request-scoped `cache()` helper so the layout and pages share one call, emitting exactly one Experience `page()` event per request instead of two. Co-Authored-By: Claude Opus 4.8 (1M context) --- implementations/nextjs-sdk_ssr/app/layout.tsx | 18 +++-------- .../nextjs-sdk_ssr/app/page-two/page.tsx | 18 ++--------- implementations/nextjs-sdk_ssr/app/page.tsx | 22 +++---------- .../nextjs-sdk_ssr/lib/optimization.ts | 32 ++++++++++++++++++- 4 files changed, 43 insertions(+), 47 deletions(-) diff --git a/implementations/nextjs-sdk_ssr/app/layout.tsx b/implementations/nextjs-sdk_ssr/app/layout.tsx index 53e28608e..f6f442fb7 100644 --- a/implementations/nextjs-sdk_ssr/app/layout.tsx +++ b/implementations/nextjs-sdk_ssr/app/layout.tsx @@ -2,13 +2,12 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' -import { optimization } from '@/lib/optimization' +import { getServerOptimizationData } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' -import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' import 'e2e-web/theme.css' import type { Metadata } from 'next' -import { cookies, headers } from 'next/headers' +import { cookies } from 'next/headers' import Link from 'next/link' import { Suspense, type ReactNode } from 'react' @@ -19,20 +18,13 @@ export const metadata: Metadata = { } export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) + const cookieStore = await cookies() const appConsent = getAppConsent(cookieStore) // Resolve the request-scoped optimization state on the server so the // isomorphic provider renders identified/personalized first-paint state even - // with JavaScript disabled. Only fetched when consent permits it. - const serverOptimizationState = appConsent - ? await getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: appConfig.locale, - }).then(({ data }) => data) - : undefined + // with JavaScript disabled. Deduplicated per request with the page components. + const serverOptimizationState = await getServerOptimizationData() return ( diff --git a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx index 8de467ce3..517fc4b32 100644 --- a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx @@ -1,29 +1,17 @@ import { ControlPanel } from '@/components/ControlPanel' import { CustomViewTracker } from '@/components/CustomViewTracker' import { EntryCard } from '@/components/EntryCard' -import { appConfig } from '@/lib/config' import { loadPageEntries } from '@/lib/contentful' -import { optimization } from '@/lib/optimization' -import { getAppConsent, toIdMap } from '@/lib/util' +import { getServerOptimizationData, optimization } from '@/lib/optimization' +import { toIdMap } from '@/lib/util' import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' -import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' import { PAGES } from 'e2e-web' -import { cookies, headers } from 'next/headers' import Link from 'next/link' export default async function PageTwo() { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - const [entries, optimizationData] = await Promise.all([ loadPageEntries(PAGES.pageTwo.ids), - getAppConsent(cookieStore) - ? getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: appConfig.locale, - }).then(({ data }) => data) - : undefined, + getServerOptimizationData(), ]) const entriesById = toIdMap(entries) diff --git a/implementations/nextjs-sdk_ssr/app/page.tsx b/implementations/nextjs-sdk_ssr/app/page.tsx index 49e00b721..176159c2c 100644 --- a/implementations/nextjs-sdk_ssr/app/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page.tsx @@ -1,31 +1,17 @@ import { ControlPanel } from '@/components/ControlPanel' import { EntryCard } from '@/components/EntryCard' import { LiveEntryCard } from '@/components/LiveEntryCard' -import { appConfig } from '@/lib/config' import { type ContentEntry, loadPageEntries } from '@/lib/contentful' -import { optimization } from '@/lib/optimization' -import { getAppConsent, toIdMap } from '@/lib/util' +import { getServerOptimizationData, optimization } from '@/lib/optimization' +import { toIdMap } from '@/lib/util' import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' -import { - type ServerTrackingResolvedData, - getNextjsServerOptimizationData, -} from '@contentful/optimization-nextjs/server' +import { type ServerTrackingResolvedData } from '@contentful/optimization-nextjs/server' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' -import { cookies, headers } from 'next/headers' export default async function Home() { - const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - const [entries, optimizationData] = await Promise.all([ loadPageEntries(PAGES.home.ids), - getAppConsent(cookieStore) - ? getNextjsServerOptimizationData(optimization, { - consent: { events: true, persistence: true }, - cookies: cookieStore, - headers: headerStore, - locale: appConfig.locale, - }).then(({ data }) => data) - : undefined, + getServerOptimizationData(), ]) const entriesById = toIdMap(entries) diff --git a/implementations/nextjs-sdk_ssr/lib/optimization.ts b/implementations/nextjs-sdk_ssr/lib/optimization.ts index 600335ec4..c6cee142e 100644 --- a/implementations/nextjs-sdk_ssr/lib/optimization.ts +++ b/implementations/nextjs-sdk_ssr/lib/optimization.ts @@ -1,5 +1,12 @@ -import { createNextjsOptimization } from '@contentful/optimization-nextjs/server' +import type { OptimizationData } from '@contentful/optimization-nextjs/server' +import { + createNextjsOptimization, + getNextjsServerOptimizationData, +} from '@contentful/optimization-nextjs/server' +import { cookies, headers } from 'next/headers' +import { cache } from 'react' import { appConfig } from './config' +import { getAppConsent } from './util' export const optimization = createNextjsOptimization({ clientId: appConfig.clientId, @@ -12,3 +19,26 @@ export const optimization = createNextjsOptimization({ version: '0.1.0', }, }) + +/** + * Resolve the request-scoped Optimization data once per request. + * + * The root layout and the page both need this data — the layout to seed the + * isomorphic provider, the page to resolve entry variants. `cache` deduplicates + * the call across the request so exactly one Experience `page()` event is + * emitted, regardless of how many server components ask for it. + */ +export const getServerOptimizationData = cache(async (): Promise => { + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) + + if (!getAppConsent(cookieStore)) return undefined + + const { data } = await getNextjsServerOptimizationData(optimization, { + consent: { events: true, persistence: true }, + cookies: cookieStore, + headers: headerStore, + locale: appConfig.locale, + }) + + return data +}) From 603962121400f432797294950ac48b3db4a43d12 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 20:40:58 +0200 Subject: [PATCH 07/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk)?= =?UTF-8?q?:=20Deprecate=20redundant=20SSR=20handoff=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the isomorphic provider renders personalized state on the server and hydrates the live SDK from `serverOptimizationState`, the page-level handoff and server-only entry components are redundant for the default SSR path. - Deprecate `NextjsOptimizationState` in favor of passing `serverOptimizationState` to the provider/root; the provider already hydrates the same data on the client. - Deprecate `ServerOptimizedEntry` in favor of the isomorphic `OptimizedEntry`, noting it remains valid for pure zero-JS Server Component rendering. - Drop the now-redundant `NextjsOptimizationState` from the `nextjs-sdk_ssr` reference pages; the seeded provider fully covers hydration (verified: no duplicate client Experience request after hydration). - Update the Next.js runtime type contract test for the widened isomorphic `OptimizationSdk` type. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-sdk_ssr/app/page-two/page.tsx | 2 -- implementations/nextjs-sdk_ssr/app/page.tsx | 2 -- packages/web/frameworks/nextjs-sdk/src/client.ts | 12 ++++++++++++ .../frameworks/nextjs-sdk/src/runtime-types.test.ts | 4 ---- packages/web/frameworks/nextjs-sdk/src/server.tsx | 13 +++++++++++++ 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx index 517fc4b32..5c57985cc 100644 --- a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx @@ -4,7 +4,6 @@ import { EntryCard } from '@/components/EntryCard' import { loadPageEntries } from '@/lib/contentful' import { getServerOptimizationData, optimization } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { PAGES } from 'e2e-web' import Link from 'next/link' @@ -35,7 +34,6 @@ export default async function PageTwo() { -
diff --git a/implementations/nextjs-sdk_ssr/app/page.tsx b/implementations/nextjs-sdk_ssr/app/page.tsx index 176159c2c..f089fa397 100644 --- a/implementations/nextjs-sdk_ssr/app/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page.tsx @@ -4,7 +4,6 @@ import { LiveEntryCard } from '@/components/LiveEntryCard' import { type ContentEntry, loadPageEntries } from '@/lib/contentful' import { getServerOptimizationData, optimization } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { type ServerTrackingResolvedData } from '@contentful/optimization-nextjs/server' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' @@ -41,7 +40,6 @@ export default async function Home() {
-
diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index 1d8c714fb..ef053cfa5 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -21,6 +21,18 @@ export interface NextjsOptimizationStateProps { readonly data: OptimizationData | undefined } +/** + * Hydrates server-resolved Optimization data into the live client SDK. + * + * @deprecated Pass the server-resolved `OptimizationData` to `OptimizationRoot` + * (or `OptimizationProvider`) via the `serverOptimizationState` prop instead. + * The provider now renders personalized state on the server and hydrates the + * same data into the live SDK on the client, so a separate page-level + * hydration marker is redundant. This component remains only for setups that + * seed the provider by configuration and hydrate page-specific data later. + * + * @public + */ export function NextjsOptimizationState({ data }: NextjsOptimizationStateProps): null { const sdk = useOptimization() diff --git a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts index a9c5c098b..eb00af451 100644 --- a/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts +++ b/packages/web/frameworks/nextjs-sdk/src/runtime-types.test.ts @@ -7,10 +7,6 @@ export function acceptNextjsClientSdk(runtime: WebContentfulOptimization): Optim return runtime } -export function acceptConcreteWebRuntime(sdk: OptimizationSdk): WebContentfulOptimization { - return sdk -} - export function acceptNextjsServerSdk( runtime: NodeContentfulOptimization, ): NextjsServerOptimization { diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index db48a9e3d..18babb5cf 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -340,6 +340,19 @@ export function persistNextjsAnonymousId( } } +/** + * Renders a server-resolved entry in a Server Component with tracking + * attributes and no client JavaScript for the entry itself. + * + * @deprecated Prefer the isomorphic `OptimizedEntry` from + * `@contentful/optimization-react-web`, which now server-renders for first paint + * and hydrates for interactivity and live updates, using a single component and + * automatic variant resolution. Use this component only when you specifically + * need a pure Server Component with zero client JavaScript for the entry and no + * live updates, and are resolving `resolvedData` yourself on the server. + * + * @public + */ export function ServerOptimizedEntry({ as, baselineEntry, From a9ae10c877af2d6efd4097c8d3348b8cd1230a0d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 21:16:37 +0200 Subject: [PATCH 08/21] =?UTF-8?q?=F0=9F=93=9D=20docs(nextjs):=20Make=20ser?= =?UTF-8?q?verOptimizationState=20the=20documented=20SSR=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the Next.js SSR guide and READMEs to lead with the isomorphic provider pattern now that it renders personalized state on the server: - Seed `OptimizationRoot`/`OptimizationProvider` with `serverOptimizationState` in the layout as the single server-to-browser handoff; resolve it once per request with a React `cache()` helper shared by layout and pages. - Render entries with the isomorphic `OptimizedEntry`. - Present `NextjsOptimizationState` and `ServerOptimizedEntry` as deprecated, documenting the edge cases where each still applies. - Refresh the SSR reference README architecture and E2E flags (`SKIP_NO_JS` removed; JS-disabled first-paint suites now pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...he-optimization-sdk-in-a-nextjs-app-ssr.md | 156 ++++++++++-------- implementations/nextjs-sdk_ssr/README.md | 54 +++--- packages/web/frameworks/nextjs-sdk/README.md | 80 +++++---- 3 files changed, 158 insertions(+), 132 deletions(-) diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md index c21923139..ff53fbee1 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md @@ -50,17 +50,17 @@ preference, or regional rule, use the policy-dependent consent section before re In this snippet, `fetchEntryFromContentful()` is an app-owned Contentful CDA helper. It must return one single-locale entry with linked optimization entries and variants included. The - `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`. - `` is valid when this page renders under SDK context provided by - `OptimizationRoot` or `OptimizationProvider`, such as a shared App Router layout. If you have not - added that provider yet, omit the marker until you complete the client provider section. + `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`. Entry + resolution runs entirely on the server, so the initial HTML shows the selected variant without + any browser SDK context. The client provider section adds the browser SDK for page events, + tracking, and consent controls, and hands this same server data to the browser through + `serverOptimizationState`. **Adapt this to your use case:** ```tsx // app/page.tsx import { APP_LOCALE, optimization } from '@/lib/optimization-server' - import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server' import { cookies, headers } from 'next/headers' @@ -92,7 +92,6 @@ preference, or regional rule, use the policy-dependent consent section before re return (
-

{String(resolvedEntry.fields.title ?? '')}

) @@ -133,24 +132,24 @@ preference, or regional rule, use the policy-dependent consent section before re Use this table as the setup inventory for the full SSR integration: -| Setup item | Category | Required for quick start | Where to configure | -| ------------------------------------------------------------------ | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------ | -| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` | -| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | -| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and `OptimizationRoot` props for browser integrations | -| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | -| Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with `include: 10` and one `locale` | -| Server Component entry resolution | Required for first integration | Yes | App Router pages and server components | -| Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` | -| Browser SDK context, state handoff, and route tracker | Required for first integration | Conditional | App Router layout and pages | -| Server request consent policy | Common but policy-dependent | Yes | Server calls, browser controls, CMP, or account controls | -| Profile persistence and anonymous ID cookie continuity | Common but policy-dependent | No | Server helper cookies, browser state handoff, ESR persistence, and `ctfl-opt-aid` | -| Browser identify and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks | -| Experience API and Insights API endpoint overrides | Advanced or production-only | No | SDK `api` config for mock, proxy, or regional endpoints | -| Entry interaction tracking | Optional | No | `ServerOptimizedEntry`, `getServerTrackingAttributes()`, and `trackEntryInteraction` | -| Third-party analytics forwarding | Optional | No | `OptimizationRoot` `onStatesReady` subscription and app-owned analytics code | -| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, server helper structure, and tracker settings | -| Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide | +| Setup item | Category | Required for quick start | Where to configure | +| ------------------------------------------------------------------ | ------------------------------ | ------------------------ | --------------------------------------------------------------------------------- | +| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` | +| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager | +| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and `OptimizationRoot` props for browser integrations | +| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client | +| Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with `include: 10` and one `locale` | +| Server Component entry resolution | Required for first integration | Yes | App Router pages and server components | +| Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` | +| Browser SDK context, state handoff, and route tracker | Required for first integration | Conditional | App Router layout and pages | +| Server request consent policy | Common but policy-dependent | Yes | Server calls, browser controls, CMP, or account controls | +| Profile persistence and anonymous ID cookie continuity | Common but policy-dependent | No | Server helper cookies, browser state handoff, ESR persistence, and `ctfl-opt-aid` | +| Browser identify and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks | +| Experience API and Insights API endpoint overrides | Advanced or production-only | No | SDK `api` config for mock, proxy, or regional endpoints | +| Entry interaction tracking | Optional | No | `OptimizedEntry`, `getServerTrackingAttributes()`, and `trackEntryInteraction` | +| Third-party analytics forwarding | Optional | No | `OptimizationRoot` `onStatesReady` subscription and app-owned analytics code | +| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, server helper structure, and tracker settings | +| Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide | The application owns Contentful fetching, locale selection, route policy, consent policy, identity policy, and component rendering. The Next.js adapter owns SDK composition: the server entry @@ -275,15 +274,22 @@ consent controls, identify, and reset run in the browser through the Next.js cli 2. Pass browser-safe configuration to `OptimizationRoot`. If a Client Component reads environment variables directly, use `NEXT_PUBLIC_` variables. A Server Component layout can also read server-side config and pass the values as props intentionally. -3. Use `serverOptimizationState={optimizationData}` on `OptimizationRoot` or `OptimizationProvider` - when that provider or root receives the server data directly. When a shared layout owns the SDK - context and cannot receive page data, render - `` under that context near the server-rendered - optimized content. +3. Resolve the request optimization data in the layout and pass it to `OptimizationRoot` (or + `OptimizationProvider`) through `serverOptimizationState={optimizationData}`. The provider + renders the same personalized state on the server and hydrates the live browser SDK with it on + the client, so the layout is the single handoff point. Deduplicate the resolution with the page + components using a request-scoped cache; see + [Caching and request deduplication](#caching-and-request-deduplication). 4. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks. 5. Set `initialPageEvent="skip"` when the server already emitted the page event for the initial route. Leave route changes enabled so client-side navigation continues to emit page events. +> [!NOTE] +> +> `serverOptimizationState` on the provider is the recommended handoff. The +> `` marker is deprecated; it remains only for setups that configure the +> provider without server data and hydrate page-specific data later under existing SDK context. + **Adapt this to your use case:** ```tsx @@ -303,19 +309,24 @@ consent controls, identify, and reset run in the browser through the Next.js cli ``` -For policy-dependent consent, derive the initial tracker behavior from the same source that the -server used: +For policy-dependent consent, resolve the request optimization data and derive the initial tracker +behavior from the same source that the server used. Passing `serverOptimizationState` makes the +provider render identified and personalized state at first paint, even with JavaScript disabled: **Adapt this to your use case:** ```tsx const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted' +// Deduplicated per request; see "Caching and request deduplication". +const optimizationData = appConsent ? await getServerOptimizationData() : undefined @@ -406,37 +417,47 @@ export function OptimizationControls() { **Integration category:** Optional -The browser client can automatically observe server-rendered entry wrappers when the markup contains -the `data-ctfl-*` tracking attributes. Use `ServerOptimizedEntry` to render those attributes from -the same baseline entry and resolved data used for SSR content. +Render entries with the isomorphic `OptimizedEntry` from `@contentful/optimization-nextjs/client`. +It resolves the variant on the server for first paint, emits the `data-ctfl-*` tracking attributes +the browser observes, and hydrates for interaction tracking and live updates — one component for +both environments. Because the provider is seeded with `serverOptimizationState`, `OptimizedEntry` +resolves the same variant on the server and the client without a hydration mismatch. -1. Wrap server-rendered entry content with `ServerOptimizedEntry`. -2. Pass the original baseline entry and the full `ResolvedData` returned by - `resolveOptimizedEntry()`. -3. Use `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes` - when an existing server-rendered element or design-system component must own the wrapper markup. -4. Use `trackEntryInteraction` on `OptimizationRoot` only to opt out of interaction types the app +1. Render `OptimizedEntry` with the baseline entry; it reads the current `selectedOptimizations` + from provider context. +2. Use `trackEntryInteraction` on `OptimizationRoot` only to opt out of interaction types the app must not observe. -5. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only +3. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only when an entry needs per-element tracking behavior. **Adapt this to your use case:** ```tsx - -

{resolvedData.entry.fields.title}

-
+import { OptimizedEntry } from '@contentful/optimization-nextjs/client' + +function ArticleEntry({ baselineEntry }: { baselineEntry: Entry }) { + return ( + + {(entry) =>

{String(entry.fields.title ?? '')}

} +
+ ) +} ``` -Use the lower-level helper when the wrapper element comes from your component library. The component -must forward the `data-ctfl-*` attributes to the DOM element that the browser SDK observes: +> [!NOTE] +> +> `ServerOptimizedEntry` is deprecated in favor of `OptimizedEntry`. It remains valid only when you +> need a pure Server Component with zero client JavaScript for the entry and no live updates, and +> are resolving `resolvedData` yourself on the server. + +Use the lower-level `getServerTrackingAttributes()` helper when a design-system component must own +the wrapper markup and forward the `data-ctfl-*` attributes to the DOM element the browser SDK +observes: **Adapt this to your use case:** @@ -584,8 +605,8 @@ export const dynamic = 'force-dynamic' Use ESR when a route handler, edge function, or other request-rendered surface owns the incoming `Request` and outgoing `Response`. Do not use ESR for the default App Router Server Component path -when `cookies()`, `headers()`, `getNextjsServerOptimizationData()`, and `NextjsOptimizationState` -fit the route. +when `cookies()`, `headers()`, `getNextjsServerOptimizationData()`, and the provider's +`serverOptimizationState` handoff fit the route. 1. Import `getNextjsEsrOptimizationData()` from `@contentful/optimization-nextjs/esr`. 2. Pass the incoming `Request` or `NextRequest`, request consent, locale, and optional page payload. @@ -621,16 +642,17 @@ export async function GET(request: Request) { **Integration category:** Advanced or production-only -The SSR pattern keeps `ServerOptimizedEntry` content server-authoritative. Content resolved and -rendered on the server stays fixed after browser startup until the next server request. Client-side -SDK actions such as `identify()`, `consent()`, `reset()`, live updates, or preview-panel changes do -not rewrite that server-rendered markup in place. +The SSR pattern keeps primary content server-authoritative. Content resolved and rendered on the +server stays fixed after browser startup until the next server request. Client-side SDK actions such +as `identify()`, `consent()`, `reset()`, or preview-panel changes do not rewrite that +server-rendered markup in place unless the entry opts into live updates. -SSR routes can still include browser-owned islands when the page needs a localized reactive area. -Render those islands with the client entry, such as `OptimizedEntry`, `useOptimizedEntry()`, or -`LiveUpdatesProvider`, and treat that island as browser-owned after hydration. This is useful for -secondary widgets, preview/editor tools, or content blocks where `liveUpdates` and preview-panel -variant changes are acceptable without changing the route's primary server-first content model. +`OptimizedEntry` defaults to this server-first behavior: it renders the server-resolved variant and +holds it after hydration. Opt a specific entry into browser reactivity with `liveUpdates`, or add +browser-owned islands with `useOptimizedEntry()` or `LiveUpdatesProvider`, and treat those as +browser-owned after hydration. This is useful for secondary widgets, preview/editor tools, or +content blocks where live-update and preview-panel variant changes are acceptable without changing +the route's primary server-first content model. Use the hybrid guide when browser takeover is the route's main content model: the same primary entry must render server-personalized HTML for first paint and then continue re-resolving in the browser @@ -670,7 +692,7 @@ Before releasing a Next.js SSR integration, verify these checks: | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | The page always renders baseline content | No optimization data, missing consent, all-locale CDA payloads, or unresolved optimization links | Confirm the server helper returned `selectedOptimizations`, fetch with one `locale`, and use `include: 10` | | The browser emits a duplicate first page event | The initial page tracker emitted after a server page call | Set `initialPageEvent="skip"` when the server already emitted the initial page event | -| Entry view, click, or hover events do not appear | Missing `data-ctfl-*` attributes, opted-out `trackEntryInteraction`, or denied browser consent | Render `ServerOptimizedEntry`, inspect opt-out settings, and inspect blocked-event state | +| Entry view, click, or hover events do not appear | Missing `data-ctfl-*` attributes, opted-out `trackEntryInteraction`, or denied browser consent | Render `OptimizedEntry`, inspect opt-out settings, and inspect blocked-event state | | A Server Component fails with browser globals or hook errors | A server file imported the Next.js client entry or React SDK hooks | Move hook usage to a Client Component with `'use client'` and keep server files on the server entry | | Identify works but content does not change immediately | Expected SSR behavior | Navigate or refresh so the next server request resolves entries with the updated profile | | Anonymous profile continuity is lost | The anonymous ID cookie is absent, `HttpOnly`, denied by persistence consent, or cleared on reset | Inspect `ctfl-opt-aid`, server or ESR persistence, browser consent state, and withdrawal logic | @@ -680,5 +702,5 @@ Before releasing a Next.js SSR integration, verify these checks: - [`implementations/nextjs-sdk_ssr`](../../implementations/nextjs-sdk_ssr/README.md) - Working Next.js App Router SSR application using `@contentful/optimization-nextjs/server`, `@contentful/optimization-nextjs/request-handler`, and `@contentful/optimization-nextjs/client`. - Use it to compare proxy request context forwarding, server entry resolution, - `ServerOptimizedEntry`, App Router layout tracking, and browser controls. + Use it to compare proxy request context forwarding, server entry resolution, the + `serverOptimizationState` handoff, App Router layout tracking, and browser controls. diff --git a/implementations/nextjs-sdk_ssr/README.md b/implementations/nextjs-sdk_ssr/README.md index caebe6f96..22251be1d 100644 --- a/implementations/nextjs-sdk_ssr/README.md +++ b/implementations/nextjs-sdk_ssr/README.md @@ -39,17 +39,19 @@ dynamically loads `@contentful/optimization-web-preview-panel`. Use this implementation when you need a server-rendered Next.js example where personalized content is resolved before browser startup. It demonstrates: -- Server-rendered personalized first paint with `getNextjsServerOptimizationData()` -- Server-rendered tracking markup with `ServerOptimizedEntry` +- Server-rendered personalized first paint with `getNextjsServerOptimizationData()`, deduplicated + per request with a React `cache()` helper (`getServerOptimizationData` in `lib/optimization.ts`) +- Seeding `OptimizationRoot` with `serverOptimizationState` in the layout so the provider renders + identified and personalized state on the server, correct even with JavaScript disabled +- Server-rendered entries through the isomorphic `OptimizedEntry` - Request URL capture through `createNextjsOptimizationContextHandler()` - Browser-side page, view, click, and hover tracking through the adapter client entry -- A client-side live updates island with `OptimizedEntry` - `initialPageEvent="skip"` when the server already resolved the same initial page -In this SSR pattern, content rendered through `ServerOptimizedEntry` is static after browser -startup. Client actions such as consent, identify, and reset update browser SDK state and analytics, -but server-rendered content changes only on the next server request. The live updates section is a -client-side island that uses `OptimizedEntry` to demonstrate browser-side re-resolution. +In this SSR pattern, `OptimizedEntry` renders the server-resolved variant and holds it after +hydration. Client actions such as consent, identify, and reset update browser SDK state and +analytics, but server-rendered content changes only on the next server request. The live updates +section opts specific entries into `liveUpdates` to demonstrate browser-side re-resolution. ## Architecture @@ -59,24 +61,27 @@ Request createNextjsOptimizationContextHandler() forwards sanitized request URL context for Server Components - app/page.tsx - fetches CDA entries - calls getNextjsServerOptimizationData() with cookies() and headers() - resolves entries through the request-bound SDK - renders NextjsOptimizationState near optimized content for server-to-browser state handoff - renders children through ServerOptimizedEntry + lib/optimization.ts + getServerOptimizationData() — React cache() + calls getNextjsServerOptimizationData() with cookies() and headers() + one Experience page() call per request, shared by layout and pages -Browser startup - app/layout.tsx + app/layout.tsx (Server Component) mounts OptimizationRoot from @contentful/optimization-nextjs/client + passes serverOptimizationState={await getServerOptimizationData()} + provider renders personalized state on the server and hydrates the live SDK passes initialPageEvent="skip" only after consented server page resolution - browser tracking - NextAppAutoPageTracker handles route page events - default interaction observers read data-ctfl-* attributes + app/page.tsx + fetches CDA entries + reads the same getServerOptimizationData() (deduplicated) + renders entries through the isomorphic OptimizedEntry - client live updates island - LiveEntryCard uses OptimizedEntry for browser-side re-resolution examples +Browser startup + provider swaps the snapshot runtime for the live browser SDK + NextAppAutoPageTracker handles route page events + default interaction observers read data-ctfl-* attributes + entries opted into liveUpdates re-resolve in the browser ``` ## CDA locale handling @@ -140,10 +145,11 @@ pnpm setup:e2e:nextjs-sdk_ssr pnpm test:e2e:nextjs-sdk_ssr ``` -The SSR E2E run uses `E2E_FLAGS=SSR,SKIP_NO_JS` from `.env.example`. It runs the shared navigation, -tracking, and live updates specs against SSR-rendered markup; `SKIP_NO_JS` skips the -JavaScript-disabled variant-resolution block. Hydration-only no-client-Experience-request checks -remain behind `HYDRATION`, and request URL forwarding plus tracking-attribute mapping are covered by +The SSR E2E run uses `E2E_FLAGS=SSR` from `.env.example`. It runs the shared navigation, tracking, +and live updates specs against SSR-rendered markup, plus the SSR first-paint and JavaScript-disabled +variant-resolution blocks — the provider renders personalized state on the server, so those pass +without JavaScript. Hydration-only no-client-Experience-request checks remain behind `HYDRATION`, +and request URL forwarding plus tracking-attribute mapping are covered by `@contentful/optimization-nextjs` package unit tests. Use Playwright UI or codegen when needed: diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 3d07717c6..753665b09 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -45,21 +45,24 @@ already installed by your app instead of installing its own copy. ## Server setup +Resolve the request Optimization data in a Server Component. Wrap it in React `cache()` so the +layout and pages share one call per request. Entry resolution is pure and runs on the server. + ```tsx import { - ServerOptimizedEntry, createNextjsOptimization, getNextjsServerOptimizationData, } from '@contentful/optimization-nextjs/server' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { cookies, headers } from 'next/headers' +import { cache } from 'react' -const sdk = createNextjsOptimization({ +export const sdk = createNextjsOptimization({ clientId: 'client-id', environment: 'main', }) -export default async function Page() { +// One Experience `page()` call per request, shared across server components. +export const getServerOptimizationData = cache(async () => { const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) const { data } = await getNextjsServerOptimizationData(sdk, { consent: { events: true, persistence: true }, @@ -67,32 +70,32 @@ export default async function Page() { headers: headerStore, locale: 'en-US', }) - - const resolvedData = sdk.resolveOptimizedEntry(entry, data?.selectedOptimizations) - - return ( - <> - - - {resolvedData.entry.fields.title} - - - ) -} + return data +}) ``` -`NextjsOptimizationState` must render under SDK context. That context can come from -`OptimizationRoot` or `OptimizationProvider`, commonly mounted in a shared App Router layout. - ## Client setup +Mount `OptimizationRoot` in the App Router layout and pass the server data through +`serverOptimizationState`. The provider renders personalized state on the server (so the first paint +is correct even without JavaScript) and hydrates the live browser SDK with the same data on the +client. + ```tsx import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' +import { getServerOptimizationData } from '@/lib/optimization' import { Suspense, type ReactNode } from 'react' -export function Providers({ children }: { children: ReactNode }) { +export default async function RootLayout({ children }: { children: ReactNode }) { + const serverOptimizationState = await getServerOptimizationData() + return ( - + @@ -105,31 +108,26 @@ export function Providers({ children }: { children: ReactNode }) { Use `initialPageEvent="skip"` only when the server already called `page()` for the same initial route. Route changes still emit normally. -## Server-to-browser state handoff - -Use `serverOptimizationState={data}` on `OptimizationRoot` or `OptimizationProvider` when that -provider or root receives the server Optimization data directly: - -```tsx - - {children} - -``` +## Rendering optimized entries -When a shared App Router layout owns the SDK context and a page owns the server data, render -`NextjsOptimizationState` under that context near the server-rendered optimized content: +Render entries with the isomorphic `OptimizedEntry`. It resolves the variant on the server for first +paint, emits the tracking attributes the browser observes, and hydrates for interaction tracking and +live updates — one component in both environments. ```tsx - +import { OptimizedEntry } from '@contentful/optimization-nextjs/client' +; + {(resolvedEntry) =>

{String(resolvedEntry.fields.title ?? '')}

} +
``` -Keep `defaults` for configuration or default state such as consent. Pass server-returned profile, -selected optimizations, and changes through `serverOptimizationState` or `NextjsOptimizationState`. +> [!NOTE] +> +> `NextjsOptimizationState` and `ServerOptimizedEntry` are deprecated. Pass server data through the +> provider's `serverOptimizationState` prop and render entries with `OptimizedEntry`. +> `NextjsOptimizationState` remains for configuring the provider without server data and hydrating +> page-specific data later; `ServerOptimizedEntry` remains for pure zero-JavaScript Server Component +> rendering where you resolve `resolvedData` yourself. ## Request context setup From b6b8e68f7d8b9f377ce09af3bee7c7f8ef2aa2ba Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 21:24:55 +0200 Subject: [PATCH 09/21] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Demote=20deprecate?= =?UTF-8?q?d=20Next.js=20SSR=20handoff=20APIs=20across=20guides=20and=20co?= =?UTF-8?q?ncepts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the isomorphic provider change: update secondary docs to present `serverOptimizationState` on the provider and the isomorphic `OptimizedEntry` as the default, and note `NextjsOptimizationState` / `ServerOptimizedEntry` as deprecated with the edge cases where each still applies. - Hybrid SSR + CSR guide: render `OptimizedEntry` for server-rendered entries. - React Web README: document that the provider renders on the server and hydrates; link the SSR concept. - Concepts (locale, entry resolution, node tracking, profile sync): mark the deprecated handoff APIs and point to the provider pattern. - Hybrid reference README: add a deprecation note pointing to `serverOptimizationState`. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-personalization-and-variant-resolution.md | 5 +- ...king-in-node-and-stateless-environments.md | 21 ++++---- ...-handling-in-the-optimization-sdk-suite.md | 17 +++--- ...nchronization-between-client-and-server.md | 53 ++++++++++--------- ...ptimization-sdk-in-a-nextjs-app-ssr-csr.md | 26 +++++---- implementations/nextjs-sdk_hybrid/README.md | 8 +++ .../web/frameworks/react-web-sdk/README.md | 40 ++++++++------ 7 files changed, 98 insertions(+), 72 deletions(-) diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index be3cb69ee..df14909bd 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -88,8 +88,9 @@ the runtime: | iOS | `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` | SwiftUI `OptimizedEntry`; UIKit can call the client directly | | Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` | -Next.js uses the Node server and React Web client surfaces, plus Next.js adapter components such as -`ServerOptimizedEntry` for server-rendered entries. +Next.js uses the Node server and React Web client surfaces. The isomorphic `OptimizedEntry` resolves +entries on the server for first paint and hydrates on the client; the deprecated +`ServerOptimizedEntry` remains only for pure zero-JavaScript Server Component rendering. ## Inputs and constraints 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 b7b8e6f73..e20166787 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -75,10 +75,11 @@ Apply these constraints before choosing server-only, hybrid, or manual tracking: views without entry views. - Browser Insights delivery needs a current Web SDK profile. In direct Web SDK initialization, the profile can come from `defaults.profile`. In React Web and Next.js provider handoff, pass - server-returned Optimization data through `serverOptimizationState`. In Next.js page-level - handoff, render `NextjsOptimizationState` under existing SDK context. The profile can also come - from browser-persisted profile state that persistence consent allows the SDK to load, or a browser - Experience API call such as `page()`, `identify()`, `track()`, or sticky `trackView()`. + server-returned Optimization data through `serverOptimizationState` (the deprecated + `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing + provider). The profile can also come from browser-persisted profile state that persistence consent + allows the SDK to load, or a browser Experience API call such as `page()`, `identify()`, + `track()`, or sticky `trackView()`. - Browser storage is best-effort. The Web SDK uses `localStorage` and the `ctfl-opt-aid` cookie when persistence consent permits continuity; if storage fails or is unavailable, continuity is limited to in-memory state. @@ -312,8 +313,9 @@ of tracking that can only be measured in the browser. ### Render tracking metadata on resolved entries Use SDK helpers when available instead of copying the attribute map into application code. In -Next.js, `ServerOptimizedEntry` renders the Web SDK tracking attributes from the baseline entry and -the `ResolvedData` returned by `resolveOptimizedEntry()`. For custom SSR wrappers, call +Next.js, the isomorphic `OptimizedEntry` renders the Web SDK tracking attributes as part of +resolving the entry on the server; the deprecated `ServerOptimizedEntry` does the same from an +explicit `ResolvedData` for pure zero-JavaScript server rendering. For custom SSR wrappers, call `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes`. Non-Next runtimes can call `resolveOptimizedEntryTrackingAttributes()` from `@contentful/optimization-web/tracking-attributes` when they already have the same baseline entry @@ -393,9 +395,10 @@ delivery. Choose one of these patterns before enabling interaction tracking: - **Bootstrap the server profile.** For direct Web SDK initialization, serialize the `profile` returned by the server's `page()` or `identify()` call and pass it as `defaults.profile`. For - React Web and Next.js, pass the server `OptimizationData` through `serverOptimizationState`, or - render `NextjsOptimizationState` under an existing SDK context when a Next.js page owns the data. - Use this when the same server response already rendered personalized HTML from that profile. + React Web and Next.js, pass the server `OptimizationData` through `serverOptimizationState` (the + deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an + existing provider). Use this when the same server response already rendered personalized HTML from + that profile. - **Re-evaluate in the browser.** Persist `ctfl-opt-aid` on the server, initialize the Web SDK in the browser, call `page()` after your consent policy allows it, then enable tracking after the page response populates browser profile state. diff --git a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md index b46048c27..48c75c675 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -185,12 +185,12 @@ current request locale. Server Components pass `headers()` to `getNextjsServerOp the SDK can derive page context from the request URL captured by the Next.js proxy or middleware helper. -Locale handoff is separate from server optimization state handoff. When the browser provider has the -server data at its boundary, pass it with `serverOptimizationState` on `OptimizationRoot`. When a -shared App Router layout owns the provider and the page owns request-local data, render -`NextjsOptimizationState` near the server-rendered optimized content. Keep `defaults` for +Locale handoff is separate from server optimization state handoff. Pass the server data with +`serverOptimizationState` on `OptimizationRoot`, resolving it in the layout so the provider renders +personalized state on the server and hydrates the same data on the client. Keep `defaults` for configuration or default state such as consent policy, not for server-returned profile, selected -optimizations, or changes. +optimizations, or changes. (The deprecated `NextjsOptimizationState` marker remains for hydrating +page-specific data under an existing provider that was not seeded with server data.) ## Node and stateless SDKs @@ -230,9 +230,10 @@ Pass direct single-locale field values to the runtime-specific entry resolution - Web and Node `resolveOptimizedEntry()`. - React Web and React Native `OptimizedEntry` and `useEntryResolver()`. -- React Web and Next.js client `useOptimizedEntry()`. -- Next.js server `resolveOptimizedEntry()`; pass the baseline entry and returned `ResolvedData` to - `ServerOptimizedEntry` when server-rendered tracking attributes are needed. +- React Web and Next.js client `useOptimizedEntry()`, and the isomorphic `OptimizedEntry` that + resolves on the server and hydrates on the client. +- Next.js server `resolveOptimizedEntry()` for Server Component resolution; the deprecated + `ServerOptimizedEntry` remains for pure zero-JavaScript server rendering. - iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI `OptimizedEntry(entry:)`. - Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 21f0b227f..6439156b9 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -105,8 +105,9 @@ profile-changing events: client state unless both runtimes also use the same profile ID or the same Experience API response. In React Web and Next.js handoff, keep `defaults` for configuration or default state such as consent policy, and pass server-returned Optimization data through - `serverOptimizationState` when the provider or root receives the data directly. In Next.js, render - `NextjsOptimizationState` only as a page-level marker under existing SDK context. + `serverOptimizationState` on the provider or root, which renders it on the server and hydrates it + on the client. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific + data under an existing provider that was not seeded with server data. For consent gates, see [Consent management in the Optimization SDK Suite](./consent-management-in-the-optimization-sdk-suite.md). @@ -142,11 +143,11 @@ The shared cookie is enough when the browser performs personalization after hydr enough when the server already rendered profile-derived HTML and the browser must continue from the same evaluated data before its first client-side Experience response. -| Path | Use when | Browser startup contract | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. | -| **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For React Web and Next.js direct provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. For Next.js page-level handoff, render `NextjsOptimizationState` under existing SDK context. | -| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. | +| Path | Use when | Browser startup contract | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. | +| **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For React Web and Next.js provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing provider. | +| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. | Direct Web SDK bootstrapping must use the same `OptimizationData` response that drove the server render: @@ -166,10 +167,10 @@ const optimization = new ContentfulOptimization({ If the browser re-resolves entries from stale localStorage while the server rendered from a newer profile evaluation, the user can see a mismatched variant or profile-derived value. For direct Web SDK initialization, use explicit defaults. For React Web and Next.js, pass the server -`OptimizationData` to `serverOptimizationState` when the provider or root receives the data -directly, or render `NextjsOptimizationState` under an existing SDK context when a Next.js page owns -the data. A fresh client-side `page()` response or a render boundary can also prevent stale cached -state from driving visible content. +`OptimizationData` to `serverOptimizationState`; the provider renders that state on the server and +hydrates the same data on the client (the deprecated `NextjsOptimizationState` marker remains for +hydrating page-specific data under an existing provider). A fresh client-side `page()` response or a +render boundary can also prevent stale cached state from driving visible content. Personalized HTML is not shared-cache safe unless the cache varies on all personalization inputs. Raw Contentful entries are the safer cache boundary; resolve variants per request or per profile @@ -497,17 +498,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 React Web and Next.js direct provider handoff, use `serverOptimizationState`. For Next.js page-level handoff, use `NextjsOptimizationState` under existing SDK context. | -| Server uses `preflight` for normal flows | The API evaluates without storing the mutation, which breaks durable profile continuity expectations. | Reserve `preflight` for preview or non-persistent evaluation. | -| Full profile serialized unnecessarily | More profile data reaches the browser than the UI needs. | Share only the profile ID unless hydration needs profile data, changes, or selections. | +| Case | What happens | Mitigation | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` is `HttpOnly` | The server can read it, but the Web SDK cannot adopt it. | Use a readable cookie for hybrid Node and Web SDK continuity. | +| Cookie domain or path mismatch | The browser and server use different profile IDs or no shared ID. | Set `path: '/'` and a domain that covers the pages that initialize the Web SDK. | +| Cookie differs from localStorage | The Web SDK clears cached profile-continuity data and adopts the cookie ID when persistence consent is `true`. | Treat this as expected when the server changes identity. | +| Cookie changes after SDK construction | The running Web SDK does not continuously watch cookies. | Reinitialize intentionally after teardown or update identity through SDK event flows. | +| Multiple browser tabs | Tabs share storage, but in-memory signals are per runtime and do not auto-sync from storage events. | Let each tab refresh state through Experience events or reload-sensitive application flows. | +| Offline browser Experience events | Events queue locally and no new profile data is available until a successful flush. | Design UI so cached selections are acceptable while offline. | +| Missing browser profile for Insights | Insights delivery is skipped because stateful Insights events use the current profile signal. | Ensure an Experience call has returned a profile before relying on Insights-only tracking. For direct Web SDK initialization, bootstrap a valid `defaults.profile` when the server already evaluated the profile. For React Web and Next.js provider handoff, use `serverOptimizationState`. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing provider. | +| 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 @@ -525,10 +526,10 @@ Use this checklist when implementing a hybrid Node and browser profile flow: - Confirm persistence consent resolves to `true` before expecting the Web SDK to load persisted profile-continuity state or adopt `ctfl-opt-aid`. - Render from the `OptimizationData` response that matches the current identity state. -- Bootstrap direct Web SDK `defaults`, use React Web or Next.js `serverOptimizationState` for direct - provider handoff, or use `NextjsOptimizationState` under existing Next.js SDK context for - page-level handoff, when server-rendered personalized output must match client-side resolution - before the first browser Experience response. +- Bootstrap direct Web SDK `defaults`, or use React Web or Next.js `serverOptimizationState` for + provider handoff (the deprecated `NextjsOptimizationState` marker remains for page-level + hydration), when server-rendered personalized output must match client-side resolution before the + first browser Experience response. - Clear both browser state and server persistence when consent revocation must end profile continuity. - Cache raw Contentful delivery payloads, not profile-evaluated SDK responses or personalized HTML diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md index 0937c11d1..08c51e1fb 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md @@ -562,25 +562,30 @@ export default async function Home() { } ``` -When a route renders entry HTML without client takeover, wrap the resolved entry with -`ServerOptimizedEntry` from the server entrypoint so the browser interaction tracker can read the -same `data-ctfl-*` metadata after browser startup. +When a route renders entry HTML without client takeover, render the isomorphic `OptimizedEntry`. It +resolves the variant on the server for first paint and emits the same `data-ctfl-*` metadata the +browser interaction tracker reads after browser startup. **Follow this pattern:** ```tsx -import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server' +import { OptimizedEntry } from '@contentful/optimization-nextjs/client' -function ServerRenderedEntry({ baselineEntry, resolvedData }: ServerRenderedEntryProps) { +function ServerRenderedEntry({ baselineEntry }: ServerRenderedEntryProps) { return ( - // Render data-ctfl-* attributes for browser entry-interaction tracking. - -

{String(resolvedData.entry.fields.title ?? '')}

-
+ + {(entry) =>

{String(entry.fields.title ?? '')}

} +
) } ``` +> [!NOTE] +> +> `ServerOptimizedEntry` is deprecated in favor of `OptimizedEntry`. Use it only for a pure Server +> Component with zero client JavaScript for the entry and no live updates, resolving `resolvedData` +> yourself on the server. + ### Browser root and server optimization state **Integration category:** Required for first integration @@ -774,7 +779,8 @@ delivery. them; use `trackEntryInteraction` only to opt out of interaction types the app must not observe. 2. Use `OptimizedEntry` props such as `clickable`, `trackViews`, `trackClicks`, `trackHovers`, `viewDurationUpdateIntervalMs`, and `hoverDurationUpdateIntervalMs` for per-entry control. -3. Use `ServerOptimizedEntry` for server-rendered entries that need the same tracking metadata. +3. `OptimizedEntry` already renders server-side and emits tracking metadata; use the deprecated + `ServerOptimizedEntry` only for a pure zero-JavaScript Server Component entry. 4. Use `sdk.tracking.enableElement(...)` from `useOptimization()` only for app-owned manual observation cases. 5. Verify consent gates. Page events can be allowed before full consent, but entry views, clicks, diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md index 8dde2705a..318606c5f 100644 --- a/implementations/nextjs-sdk_hybrid/README.md +++ b/implementations/nextjs-sdk_hybrid/README.md @@ -49,6 +49,14 @@ handoff. It demonstrates: This hybrid pattern keeps App Router server fetching in place, hands Optimization state to the browser, and lets the browser SDK own entry resolution and reactive updates after startup. +> [!NOTE] +> +> This implementation still uses `NextjsOptimizationState` for the state handoff, which is now +> deprecated. New integrations should pass server Optimization data to `OptimizationRoot` through +> the `serverOptimizationState` prop instead; the provider renders personalized state on the server +> and hydrates the same data on the client. See the +> [Next.js SSR guide](../../documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md). + ## Architecture ```text diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md index 6dac7505e..9a12df745 100644 --- a/packages/web/frameworks/react-web-sdk/README.md +++ b/packages/web/frameworks/react-web-sdk/README.md @@ -86,6 +86,12 @@ Experience API responses and events need to use the same language:
``` +The provider renders on the server as well as the client. During server-side rendering it renders +children from a read-only snapshot (seeded by `serverOptimizationState` and consent/locale +defaults), then constructs and hydrates the live browser SDK after mount. Hooks and `OptimizedEntry` +work in both environments; event and tracking calls are no-ops during server rendering. See +[Server-side rendering and hydration](../../../../documentation/concepts/server-side-rendering-and-hydration.md). + ## When to use this package Use `@contentful/optimization-react-web` for React browser applications that need provider-based SDK @@ -99,23 +105,23 @@ or custom framework adapters. such as `liveUpdates`, `onStatesReady`, and `serverOptimizationState`. The Web SDK `autoTrackEntryInteraction` option is exposed as the React `trackEntryInteraction` prop. -| Prop | Required? | Default | Description | -| ------------------------- | --------- | --------------------------------------------- | -------------------------------------------------------------------------- | -| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | -| `environment` | No | `'main'` | Contentful environment identifier | -| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | -| `app` | No | `undefined` | Application metadata attached to outgoing event context | -| `locale` | No | `undefined` | SDK Experience API and default event locale | -| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | -| `serverOptimizationState` | No | `undefined` | Server-returned Optimization state to apply before provider children mount | -| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | -| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | -| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | -| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | -| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | -| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | -| `logLevel` | No | `'error'` | Minimum log level for the default console sink | -| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | +| Prop | Required? | Default | Description | +| ------------------------- | --------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests | +| `environment` | No | `'main'` | Contentful environment identifier | +| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options | +| `app` | No | `undefined` | Application metadata attached to outgoing event context | +| `locale` | No | `undefined` | SDK Experience API and default event locale | +| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent | +| `serverOptimizationState` | No | `undefined` | Server-resolved Optimization state; renders personalized state during SSR and hydrates the client SDK | +| `allowedEventTypes` | No | `['identify', 'page']` | Event types allowed before consent is explicitly set | +| `trackEntryInteraction` | No | `{ views: true, clicks: true, hovers: true }` | Automatic entry interaction tracking for `OptimizedEntry` elements | +| `cookie` | No | `{ domain: undefined, expires: 365 }` | Anonymous ID cookie settings inherited from the Web SDK | +| `liveUpdates` | No | `false` | Whether `OptimizedEntry` components react continuously to SDK state | +| `onStatesReady` | No | `undefined` | Provider-managed app-level state subscription hook | +| `queuePolicy` | No | SDK defaults | Flush retry behavior and offline queue bounds | +| `logLevel` | No | `'error'` | Minimum log level for the default console sink | +| `onEventBlocked` | No | `undefined` | Callback invoked when consent or guard logic blocks an event | Use `OptimizationProvider` directly when an application or framework adapter needs direct provider control, including integrations that supply an SDK instance: From d1d094a329d790b4e15ff2e4fe0c17024d40584d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 21:35:48 +0200 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=90=9B=20fix(react-web):=20Render?= =?UTF-8?q?=20serverOptimizationState=20at=20first=20paint=20for=20injecte?= =?UTF-8?q?d=20SDKs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createInitialRuntime` returned the injected SDK unchanged, so an injected-SDK provider given `serverOptimizationState` showed empty state on the first render until the mount effect hydrated it. Back the initial render with a snapshot runtime whenever the provider cannot use the injected SDK directly (server state or onStatesReady present), so first paint reflects the server-resolved state for both owned and injected SDKs. Update the onStatesReady ordering assertions to the isomorphic contract: children render first from the server snapshot, then the mount effect runs onStatesReady on the live SDK and children re-render. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ptimizationProvider.onStatesReady.test.tsx | 20 ++++++++++++------- .../src/provider/OptimizationProvider.tsx | 19 +++++++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index b3ae8997d..321dca2f6 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -223,7 +223,7 @@ describe('OptimizationProvider onStatesReady', () => { rendered.unmount() }) - it('applies serverOptimizationState to owned SDK instances before onStatesReady and child render', async () => { + it('applies serverOptimizationState to owned SDK instances for onStatesReady and child render', async () => { const serverOptimizationState = createServerOptimizationState('owned-server-profile') const setupOrder: string[] = [] let profileFromOnStatesReady: OptimizationData['profile'] | undefined = undefined @@ -250,7 +250,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + // The child renders first from the server snapshot, then the mount effect + // runs onStatesReady on the live SDK and the child re-renders against it. + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) expect(profileFromChild).toEqual(serverOptimizationState.profile) rendered.unmount() @@ -277,7 +279,7 @@ describe('OptimizationProvider onStatesReady', () => { sdk.destroy() }) - it('applies serverOptimizationState to injected SDK instances before onStatesReady and child render', async () => { + it('applies serverOptimizationState to injected SDK instances for onStatesReady and child render', async () => { const serverOptimizationState = createServerOptimizationState('injected-ready-profile') const sdk = new ContentfulOptimization(testConfig) const setupOrder: string[] = [] @@ -303,7 +305,9 @@ describe('OptimizationProvider onStatesReady', () => { , ) - expect(setupOrder).toEqual(['onStatesReady', 'child']) + // The child renders first from the server snapshot, then the mount effect + // hydrates the injected SDK, runs onStatesReady, and the child re-renders. + expect(setupOrder).toEqual(['child', 'onStatesReady', 'child']) expect(profileFromOnStatesReady).toEqual(serverOptimizationState.profile) expect(profileFromChild).toEqual(serverOptimizationState.profile) rendered.unmount() @@ -429,10 +433,12 @@ describe('OptimizationProvider onStatesReady', () => { , ) - // The injected SDK backs the server render directly, so children render. The - // onStatesReady side effect runs only on the client (in the mount effect). + // With onStatesReady present, the server render is backed by a read-only + // snapshot runtime (not the injected SDK), so children render immediately + // while the onStatesReady side effect defers to the client mount effect. expect(childRendered).toBe(true) - expect(capturedSdk).toBe(sdk) + expect(capturedSdk).toBeDefined() + expect(capturedSdk).not.toBe(sdk) expect(onStatesReady).not.toHaveBeenCalled() }) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index 4cadf076e..d08287920 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -191,14 +191,23 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): * client render, before the mount effect runs). * * @remarks - * With a config-driven provider this is a read-only snapshot runtime seeded from - * server state plus the configured consent/locale defaults, so it reports the - * same values the live SDK will after hydration. With an injected SDK the live - * instance is already available and is used directly. + * When an injected SDK can back the initial render directly (no server state and + * no `onStatesReady`), the live instance is used as-is. Otherwise the initial + * render uses a read-only snapshot runtime seeded from `serverOptimizationState` + * plus the configured consent/locale defaults, so first paint reflects the + * server-resolved state — whether the SDK is owned or injected — and matches the + * value the live SDK reports after the effect hydrates it. */ function createInitialRuntime(props: OptimizationProviderProps): WebOptimizationRuntime { if (props.sdk !== undefined) { - return props.sdk + // An injected SDK with no async setup already backs the initial render. + if (canUseInjectedSdkDuringInitialRender(props)) { + return props.sdk + } + + // An injected SDK with server state renders that state first, then the + // effect hydrates the live instance with the same data. + return createWebSnapshotRuntime({ data: props.serverOptimizationState }) } return createWebSnapshotRuntime({ From fb0daca385b756214d546eddd126ebf3a03e15fa Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Thu, 2 Jul 2026 21:43:51 +0200 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=90=9B=20fix(react-web):=20Keep=20t?= =?UTF-8?q?he=20injected=20SDK=20backing=20the=20render=20when=20only=20on?= =?UTF-8?q?StatesReady=20is=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix substituted a snapshot runtime whenever the injected SDK could not back the render "directly", but that condition also triggered on `onStatesReady` alone — replacing the caller's SDK with an empty snapshot and forcing a nonsensical "runtime is not the injected SDK" assertion. Split the two concerns: `injectedSdkBacksInitialRender` (snapshot only when `serverOptimizationState` must paint first) governs the render backing, while `canUseInjectedSdkDuringInitialRender` (also requires no `onStatesReady`) governs whether the mount effect can be skipped. `onStatesReady` now keeps the injected SDK as the rendered runtime and still runs on the client, so the assertion is the expected identity check. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ptimizationProvider.onStatesReady.test.tsx | 9 ++-- .../src/provider/OptimizationProvider.tsx | 52 ++++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx index 321dca2f6..5b006ce6e 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.onStatesReady.test.tsx @@ -433,12 +433,11 @@ describe('OptimizationProvider onStatesReady', () => { , ) - // With onStatesReady present, the server render is backed by a read-only - // snapshot runtime (not the injected SDK), so children render immediately - // while the onStatesReady side effect defers to the client mount effect. + // The injected SDK backs the server render directly (no serverOptimizationState + // to paint first), so children render against it. The onStatesReady side + // effect defers to the client mount effect and does not run during SSR. expect(childRendered).toBe(true) - expect(capturedSdk).toBeDefined() - expect(capturedSdk).not.toBe(sdk) + expect(capturedSdk).toBe(sdk) expect(onStatesReady).not.toHaveBeenCalled() }) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index d08287920..d6f181090 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -186,28 +186,36 @@ function canUseInjectedSdkDuringInitialRender(props: OptimizationProviderProps): ) } +/** + * Whether the initial render is backed by the injected live SDK directly. + * + * @remarks + * An injected SDK backs the initial render unless `serverOptimizationState` must + * paint first. `onStatesReady` does not change this — it is a client-only effect + * — so an injected SDK is used as-is even when `onStatesReady` is provided. + */ +function injectedSdkBacksInitialRender(props: OptimizationProviderProps): boolean { + return props.sdk !== undefined && props.serverOptimizationState === undefined +} + /** * Build the runtime used for the initial render (server render and the first * client render, before the mount effect runs). * * @remarks - * When an injected SDK can back the initial render directly (no server state and - * no `onStatesReady`), the live instance is used as-is. Otherwise the initial - * render uses a read-only snapshot runtime seeded from `serverOptimizationState` - * plus the configured consent/locale defaults, so first paint reflects the - * server-resolved state — whether the SDK is owned or injected — and matches the - * value the live SDK reports after the effect hydrates it. + * An injected SDK backs the initial render directly unless `serverOptimizationState` + * is provided, in which case a read-only snapshot paints the server-resolved + * state first and the effect hydrates the injected SDK with the same data. A + * config-driven provider always renders from a snapshot seeded with server state + * plus the configured consent/locale defaults, matching the value the live SDK + * reports after the effect hydrates it. */ function createInitialRuntime(props: OptimizationProviderProps): WebOptimizationRuntime { if (props.sdk !== undefined) { - // An injected SDK with no async setup already backs the initial render. - if (canUseInjectedSdkDuringInitialRender(props)) { - return props.sdk - } - - // An injected SDK with server state renders that state first, then the - // effect hydrates the live instance with the same data. - return createWebSnapshotRuntime({ data: props.serverOptimizationState }) + // Render the injected SDK directly unless server state must paint first. + return injectedSdkBacksInitialRender(props) + ? props.sdk + : createWebSnapshotRuntime({ data: props.serverOptimizationState }) } return createWebSnapshotRuntime({ @@ -222,16 +230,12 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const { children } = props const initialPropsRef = useRef(props) const liveLocale = props.sdk === undefined ? props.locale : undefined - const [state, setState] = useState(() => { - const injectedIsLive = canUseInjectedSdkDuringInitialRender(props) - - return { - error: undefined, - isReady: true, - isLive: injectedIsLive, - runtime: createInitialRuntime(props), - } - }) + const [state, setState] = useState(() => ({ + error: undefined, + isReady: true, + isLive: injectedSdkBacksInitialRender(props), + runtime: createInitialRuntime(props), + })) useLayoutEffect(() => { const { current: initialProps } = initialPropsRef From fb2d87014ed9150464d554fd392647729780385a Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 09:38:48 +0200 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=90=9B=20fix(web):=20Render=20resol?= =?UTF-8?q?ved=20OptimizedEntry=20variants=20during=20SSR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OptimizedEntry` rendered a hidden loading target during server rendering, so server HTML showed no visible variant content with JavaScript disabled. Three gaps caused this, each fixed so the server render and the client's first render agree (no hydration mismatch): - `OptimizedEntryController` only read `states.selectedOptimizations` in `connect()` (a client effect), so the constructor-time snapshot resolved the baseline. Extract `primeStateFromSdk()` that reads the current state values and call it at construction as well as in `resubscribe()`. - `useOptimizedEntry` seeded `isPresentationReady` to `false` and only corrected it in an effect, which never runs on the server. Seed it from context readiness so SSR presents content instead of the loading state. - `SnapshotRuntime` reported an `idle` experience request state without server data, which reads as "loading". A snapshot is settled by definition (no request is in flight for the render it backs), so report `success`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/runtime/SnapshotRuntime.test.ts | 6 ++- .../core-sdk/src/runtime/SnapshotRuntime.ts | 9 ++-- .../optimized-entry/OptimizedEntry.test.tsx | 7 +-- .../src/optimized-entry/useOptimizedEntry.ts | 5 ++- .../presentation/OptimizedEntryController.ts | 44 +++++++++++++++---- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts index 4092161e6..a539e1e03 100644 --- a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.test.ts @@ -79,11 +79,13 @@ describe('SnapshotRuntime', () => { expect(runtime.hasConsent('track')).toBe(true) }) - it('reports canOptimize false and idle request state without server data', () => { + it('reports canOptimize false and a settled request state without server data', () => { const runtime = createSnapshotRuntime() expect(runtime.states.canOptimize.current).toBe(false) - expect(runtime.states.experienceRequestState.current).toEqual({ status: 'idle' }) + // A snapshot is always settled — no experience request is in flight for the + // render it backs — so consumers present content instead of a loading state. + expect(runtime.states.experienceRequestState.current).toEqual({ status: 'success' }) expect(runtime.states.profile.current).toBeUndefined() expect(runtime.hasConsent('track')).toBe(false) }) diff --git a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts index 83dd307df..5fae470ba 100644 --- a/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts +++ b/packages/universal/core-sdk/src/runtime/SnapshotRuntime.ts @@ -95,9 +95,12 @@ class SnapshotRuntime implements OptimizationRuntime { locale: staticObservable(snapshot.locale), canOptimize: staticObservable(canOptimize), optimizationPossible: staticObservable(true), - experienceRequestState: staticObservable( - snapshot.data ? { status: 'success' } : { status: 'idle' }, - ), + // A snapshot is a settled, request-scoped result: no experience request is + // in flight for the render it backs (server render and the client's first + // render). Report `success` so consumers such as OptimizedEntry present + // resolved-or-baseline content instead of a loading state — and so the + // server render matches the client's first render before hydration. + experienceRequestState: staticObservable({ status: 'success' }), selectedOptimizations: staticObservable(this.currentSelectedOptimizations), previewPanelAttached: staticObservable(false), previewPanelOpen: staticObservable(false), diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx index b86bfb675..99df73cdc 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx @@ -604,7 +604,7 @@ describe('OptimizedEntry', () => { await spanView.unmount() }) - it('renders invisible loading target during SSR for non-optimized entries', () => { + it('renders visible resolved content during SSR when the runtime is ready', () => { const { optimization } = createRuntime((entry) => ({ entry })) const markup = renderToStringWithoutWindow(() => @@ -616,9 +616,10 @@ describe('OptimizedEntry', () => { ), ) - expect(markup).toContain('data-ctfl-loading-layout-target="true"') - expect(markup).toContain('visibility:hidden') + // The provider context is ready, so the entry presents its resolved content + // during SSR rather than the hidden loading target. expect(markup).toContain('baseline') + expect(markup).not.toContain('visibility:hidden') }) it('renders non-optimized content after sdk initialization', async () => { diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index 988354e7d..529b06dcb 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -49,7 +49,10 @@ export function useOptimizedEntrySnapshot({ }: UseOptimizedEntrySnapshotParams): OptimizedEntrySnapshot { const { sdk, isReady } = useOptimizationContext() const liveUpdatesContext = useLiveUpdates() - const [isPresentationReady, setIsPresentationReady] = useState(false) + // Seed from context readiness so the server render (and the first client + // render) presents resolved content instead of the loading state; effects, + // which do not run on the server, would otherwise leave this false during SSR. + const [isPresentationReady, setIsPresentationReady] = useState(isReady) const controllerOptions = useMemo( () => ({ diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts index ba3a6d1fa..0ca8ead51 100644 --- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts +++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts @@ -208,9 +208,42 @@ export class OptimizedEntryController { constructor(options: OptimizedEntryControllerOptions) { this.options = normalizeOptions(options) + // Prime from the SDK state's current values so the initial snapshot resolves + // the selected variant during server rendering and the first client render, + // before connect() attaches live subscriptions. + this.primeStateFromSdk() this.snapshot = this.createSnapshot() } + /** + * Read the current SDK state values into local fields without subscribing. + * + * @remarks + * Used at construction (so the first snapshot resolves the variant, including + * during SSR where {@link OptimizedEntryController.connect} never runs) and at + * the start of {@link OptimizedEntryController.resubscribe}. + */ + private primeStateFromSdk(): void { + const { options } = this + const { sdk, isSdkStateReady } = options + if (!sdk || !isSdkStateReady) { + return + } + + const { states } = sdk + const { canOptimize, experienceRequestState, optimizationPossible, selectedOptimizations } = + states + const { current: currentSelectedOptimizations } = selectedOptimizations + const { current: currentCanOptimize } = canOptimize + const { current: currentExperienceRequestState } = experienceRequestState + const { current: currentOptimizationPossible } = optimizationPossible + + this.acceptSelectedOptimizations(currentSelectedOptimizations) + this.canOptimize = currentCanOptimize + this.hasExperienceRequestSettled = isExperienceRequestSettled(currentExperienceRequestState) + this.optimizationPossible = currentOptimizationPossible + } + setSnapshotListener(listener: OptimizedEntrySnapshotListener | undefined): void { this.listener = listener } @@ -290,18 +323,11 @@ export class OptimizedEntryController { return } + this.primeStateFromSdk() + const { states } = sdk const { canOptimize, experienceRequestState, optimizationPossible, selectedOptimizations } = states - const { current: currentSelectedOptimizations } = selectedOptimizations - const { current: currentCanOptimize } = canOptimize - const { current: currentExperienceRequestState } = experienceRequestState - const { current: currentOptimizationPossible } = optimizationPossible - - this.acceptSelectedOptimizations(currentSelectedOptimizations) - this.canOptimize = currentCanOptimize - this.hasExperienceRequestSettled = isExperienceRequestSettled(currentExperienceRequestState) - this.optimizationPossible = currentOptimizationPossible this.subscriptions = [ selectedOptimizations.subscribe((nextSelectedOptimizations) => { From 4aae1620960478bcaa167fa51300f999d824fa82 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 09:39:13 +0200 Subject: [PATCH 13/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk?= =?UTF-8?q?=5Fssr):=20Render=20entries=20with=20the=20isomorphic=20Optimiz?= =?UTF-8?q?edEntry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deprecated `ServerOptimizedEntry` in `EntryCard` with the isomorphic `OptimizedEntry`. `EntryCard` becomes a client component that resolves variants from provider context and renders merge tags via `useMergeTagResolver`, so the pages no longer pre-resolve entries or thread `resolvedData`/`getMergeTagValue` props across the server/client boundary. The SSR reference now demonstrates a single entry component for both server first paint and client interactivity. Verified: the JavaScript-disabled SSR variant-resolution E2E suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-sdk_ssr/app/page-two/page.tsx | 30 ++------ implementations/nextjs-sdk_ssr/app/page.tsx | 45 ++--------- .../nextjs-sdk_ssr/components/EntryCard.tsx | 74 +++++++++++-------- 3 files changed, 57 insertions(+), 92 deletions(-) diff --git a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx index 5c57985cc..936aed7d6 100644 --- a/implementations/nextjs-sdk_ssr/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page-two/page.tsx @@ -2,26 +2,18 @@ import { ControlPanel } from '@/components/ControlPanel' import { CustomViewTracker } from '@/components/CustomViewTracker' import { EntryCard } from '@/components/EntryCard' import { loadPageEntries } from '@/lib/contentful' -import { getServerOptimizationData, optimization } from '@/lib/optimization' import { toIdMap } from '@/lib/util' import { PAGES } from 'e2e-web' import Link from 'next/link' export default async function PageTwo() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.pageTwo.ids), - getServerOptimizationData(), - ]) + // EntryCard's OptimizedEntry resolves variants from provider context, so the + // page only needs the baseline entries. + const entries = await loadPageEntries(PAGES.pageTwo.ids) const entriesById = toIdMap(entries) const autoEntry = entriesById.get(PAGES.pageTwo.auto) const manualEntry = entriesById.get(PAGES.pageTwo.manual) - const autoResolved = autoEntry - ? optimization.resolveOptimizedEntry(autoEntry, optimizationData?.selectedOptimizations) - : undefined - const manualResolved = manualEntry - ? optimization.resolveOptimizedEntry(manualEntry, optimizationData?.selectedOptimizations) - : undefined return (
@@ -41,12 +33,8 @@ export default async function PageTwo() {

Auto-observed

- {autoEntry && autoResolved ? ( - + {autoEntry ? ( + ) : (

Auto tracked entry is unavailable.

)} @@ -58,12 +46,8 @@ export default async function PageTwo() {

Manually-observed

- {manualEntry && manualResolved ? ( - + {manualEntry ? ( + ) : (

Manual tracked entry is unavailable.

)} diff --git a/implementations/nextjs-sdk_ssr/app/page.tsx b/implementations/nextjs-sdk_ssr/app/page.tsx index f089fa397..bff15acbf 100644 --- a/implementations/nextjs-sdk_ssr/app/page.tsx +++ b/implementations/nextjs-sdk_ssr/app/page.tsx @@ -1,32 +1,17 @@ import { ControlPanel } from '@/components/ControlPanel' import { EntryCard } from '@/components/EntryCard' import { LiveEntryCard } from '@/components/LiveEntryCard' -import { type ContentEntry, loadPageEntries } from '@/lib/contentful' -import { getServerOptimizationData, optimization } from '@/lib/optimization' +import { loadPageEntries } from '@/lib/contentful' import { toIdMap } from '@/lib/util' -import { type ServerTrackingResolvedData } from '@contentful/optimization-nextjs/server' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' export default async function Home() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.home.ids), - getServerOptimizationData(), - ]) + // Entries are fetched server-side; the isomorphic OptimizedEntry (inside + // EntryCard) resolves the variant from provider context on both server and + // client, so the page no longer pre-resolves or threads resolved data. + const entries = await loadPageEntries(PAGES.home.ids) const entriesById = toIdMap(entries) - const resolvedById = new Map( - entries.map((entry) => [ - entry.sys.id, - optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations), - ]), - ) - - const profile = optimizationData?.profile - const getMergeTagValue = (entry: unknown): string | undefined => - optimization.getMergeTagValue(entry as never, profile) - const resolveEntry = (entry: ContentEntry): ServerTrackingResolvedData => - optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations) - const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates) return ( @@ -79,17 +64,13 @@ export default async function Home() {
{PAGES.home.auto.flatMap((id) => { const entry = entriesById.get(id) - const resolvedData = resolvedById.get(id) - if (!entry || !resolvedData) return [] + if (!entry) return [] return [ , ] })} @@ -103,18 +84,8 @@ export default async function Home() {
{PAGES.home.manual.flatMap((id) => { const entry = entriesById.get(id) - const resolvedData = resolvedById.get(id) - if (!entry || !resolvedData) return [] - return [ - , - ] + if (!entry) return [] + return [] })}
diff --git a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx index 93f17d363..c84784ac0 100644 --- a/implementations/nextjs-sdk_ssr/components/EntryCard.tsx +++ b/implementations/nextjs-sdk_ssr/components/EntryCard.tsx @@ -1,13 +1,12 @@ +'use client' + import type { ContentEntry, ContentEntrySkeleton } from '@/lib/contentful' import { isRecord, isResolvedContentfulEntry, isRichTextDocument, } from '@contentful/optimization-nextjs/api-schemas' -import { - ServerOptimizedEntry, - type ServerTrackingResolvedData, -} from '@contentful/optimization-nextjs/server' +import { OptimizedEntry, useMergeTagResolver } from '@contentful/optimization-nextjs/client' import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer' import { INLINES } from '@contentful/rich-text-types' import type { EntryClickScenario } from 'e2e-web' @@ -20,41 +19,38 @@ type MergeTagResolver = (entry: unknown) => string | undefined interface EntryCardProps { baselineEntry: ContentEntry clickScenario?: EntryClickScenario - getMergeTagValue?: MergeTagResolver manualTracking: boolean - resolveEntry?: (entry: ContentEntry) => ServerTrackingResolvedData - resolvedData: ServerTrackingResolvedData } -function buildRenderOptions(getMergeTagValue?: MergeTagResolver): Options { +function buildRenderOptions(getMergeTagValue: MergeTagResolver): Options { return { renderNode: { [INLINES.EMBEDDED_ENTRY]: (node): string => { const { data } = node if (!isRecord(data) || !('target' in data)) return '' - return getMergeTagValue?.(data.target) ?? '' + return getMergeTagValue(data.target) ?? '' }, }, } } -export function EntryCard({ +function EntryContent({ baselineEntry, clickScenario, - getMergeTagValue, - manualTracking, - resolveEntry, - resolvedData, -}: EntryCardProps): JSX.Element { - const resolvedEntry = resolvedData.entry as ContentEntry - const autoTrackViews = !manualTracking + resolvedEntry, +}: { + baselineEntry: ContentEntry + clickScenario?: EntryClickScenario + resolvedEntry: ContentEntry +}): JSX.Element { + const { getMergeTagValue } = useMergeTagResolver() const richText = Object.values(resolvedEntry.fields).find(isRichTextDocument) const nested = Array.isArray(resolvedEntry.fields.nested) ? resolvedEntry.fields.nested.filter(isResolvedContentfulEntry) : [] - const renderOptions = buildRenderOptions(getMergeTagValue) + const renderOptions = buildRenderOptions((entry) => getMergeTagValue(entry as never)) - const content = ( + return (
( ))}
) : null}
) +} + +export function EntryCard({ + baselineEntry, + clickScenario, + manualTracking, +}: EntryCardProps): JSX.Element { + const autoTrackViews = !manualTracking return (
- - {autoTrackViews && clickScenario === 'ancestor' ? ( -
- {content} -
- ) : ( - content - )} -
+ {(resolvedEntry) => + autoTrackViews && clickScenario === 'ancestor' ? ( +
+ +
+ ) : ( + + ) + } +
) } From a948438431f1fc10a7a97acc1165cea55b217552 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 09:44:56 +0200 Subject: [PATCH 14/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk?= =?UTF-8?q?=5Fhybrid):=20Migrate=20off=20NextjsOptimizationState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed `OptimizationRoot` with `serverOptimizationState` in the layout (using the existing request-scoped `getOptimizationData` cache) and drop the deprecated `` from both pages. The provider now renders personalized state on the server and hydrates the live SDK, and `OptimizedEntry` re-resolves in the browser for the CSR takeover. Update the README architecture and remove the deprecation note. Verified: hybrid CSR + HYDRATION E2E passes, including the no-duplicate-client- Experience-request hydration check. Co-Authored-By: Claude Opus 4.8 (1M context) --- implementations/nextjs-sdk_hybrid/README.md | 31 ++++++++----------- .../nextjs-sdk_hybrid/app/layout.tsx | 5 +++ .../nextjs-sdk_hybrid/app/page-two/page.tsx | 8 +---- .../nextjs-sdk_hybrid/app/page.tsx | 11 +++---- 4 files changed, 23 insertions(+), 32 deletions(-) diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md index 318606c5f..41218315d 100644 --- a/implementations/nextjs-sdk_hybrid/README.md +++ b/implementations/nextjs-sdk_hybrid/README.md @@ -40,22 +40,16 @@ and Optimization state, then the browser SDK resolves entries and owns reactive handoff. It demonstrates: - Server request context forwarding through proxy -- Server-to-browser state handoff through `NextjsOptimizationState` -- Browser-side entry resolution with `OptimizedEntry` after browser startup +- Server-to-browser state handoff through `serverOptimizationState` on `OptimizationRoot` +- Isomorphic entry rendering with `OptimizedEntry`, which resolves on the server and re-resolves in + the browser after startup - Live re-resolution after consent, identify, reset, and client-side route changes - `initialPageEvent="skip"` when the server request helper already emitted the initial page event - Preview panel attachment behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` This hybrid pattern keeps App Router server fetching in place, hands Optimization state to the -browser, and lets the browser SDK own entry resolution and reactive updates after startup. - -> [!NOTE] -> -> This implementation still uses `NextjsOptimizationState` for the state handoff, which is now -> deprecated. New integrations should pass server Optimization data to `OptimizationRoot` through -> the `serverOptimizationState` prop instead; the provider renders personalized state on the server -> and hydrates the same data on the client. See the -> [Next.js SSR guide](../../documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md). +browser through the provider's `serverOptimizationState` prop, and lets the browser SDK own entry +re-resolution and reactive updates after startup. ## Architecture @@ -70,17 +64,18 @@ First request getOptimizationData() calls getNextjsServerOptimizationData() with cookies() and headers() - app/page.tsx and app/page-two/page.tsx - fetch CDA entries server-side - render NextjsOptimizationState with server Optimization data - - app/layout.tsx + app/layout.tsx (Server Component) owns one OptimizationRoot for browser takeover and route tracking + passes serverOptimizationState={await getOptimizationData()} + provider renders personalized state on the server and hydrates the live SDK + + app/page.tsx and app/page-two/page.tsx + fetch CDA entries server-side and render them through OptimizedEntry Browser runtime - NextjsOptimizationState hydrates Optimization data into the nearest runtime + provider swaps the snapshot runtime for the live browser SDK NextAppAutoPageTracker emits route page events - OptimizedEntry resolves entries from current selectedOptimizations + OptimizedEntry re-resolves entries from current selectedOptimizations LiveUpdatesProvider controls reactive re-resolution ``` diff --git a/implementations/nextjs-sdk_hybrid/app/layout.tsx b/implementations/nextjs-sdk_hybrid/app/layout.tsx index dce121e4d..61f35d738 100644 --- a/implementations/nextjs-sdk_hybrid/app/layout.tsx +++ b/implementations/nextjs-sdk_hybrid/app/layout.tsx @@ -2,6 +2,7 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide import { PreviewPanel } from '@/components/PreviewPanel' import { TrackingLog } from '@/components/TrackingLog' import { appConfig } from '@/lib/config' +import { getOptimizationData } from '@/lib/optimization' import { getAppConsent } from '@/lib/util' import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client' import 'e2e-web/theme.css' @@ -29,6 +30,9 @@ export default async function RootLayout({ const cookieStore = await cookies() const appConsent = getAppConsent(cookieStore) const htmlLang = getHtmlLang(appConfig.locale) + // Seed the provider with server-resolved state (deduplicated per request with + // the pages) so first paint is personalized before the browser takes over. + const serverOptimizationState = await getOptimizationData() return ( @@ -45,6 +49,7 @@ export default async function RootLayout({ version: '0.1.0', }} defaults={{ consent: appConsent, persistenceConsent: appConsent }} + serverOptimizationState={serverOptimizationState} > diff --git a/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx b/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx index 5f780bfd2..1cf456d77 100644 --- a/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx +++ b/implementations/nextjs-sdk_hybrid/app/page-two/page.tsx @@ -2,17 +2,12 @@ import { ControlPanel } from '@/components/ControlPanel' import { CustomViewTracker } from '@/components/CustomViewTracker' import { EntryCard } from '@/components/EntryCard' import { loadPageEntries } from '@/lib/contentful' -import { getOptimizationData } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { PAGES } from 'e2e-web' import Link from 'next/link' export default async function PageTwo() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.pageTwo.ids), - getOptimizationData(), - ]) + const entries = await loadPageEntries(PAGES.pageTwo.ids) const entriesById = toIdMap(entries) const autoEntry = entriesById.get(PAGES.pageTwo.auto) const manualEntry = entriesById.get(PAGES.pageTwo.manual) @@ -28,7 +23,6 @@ export default async function PageTwo() { -
diff --git a/implementations/nextjs-sdk_hybrid/app/page.tsx b/implementations/nextjs-sdk_hybrid/app/page.tsx index 1e0e294c1..c53ec176b 100644 --- a/implementations/nextjs-sdk_hybrid/app/page.tsx +++ b/implementations/nextjs-sdk_hybrid/app/page.tsx @@ -1,18 +1,16 @@ import { ControlPanel } from '@/components/ControlPanel' import { EntryCard } from '@/components/EntryCard' import { loadPageEntries } from '@/lib/contentful' -import { getOptimizationData } from '@/lib/optimization' import { toIdMap } from '@/lib/util' -import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client' import { CLICK_SCENARIOS, PAGES } from 'e2e-web' const NESTED_CONTENT_TYPE = 'nestedContent' export default async function Home() { - const [entries, optimizationData] = await Promise.all([ - loadPageEntries(PAGES.home.ids), - getOptimizationData(), - ]) + // The layout seeds the provider with serverOptimizationState, and EntryCard's + // OptimizedEntry resolves variants from context, so the page only needs the + // baseline entries. + const entries = await loadPageEntries(PAGES.home.ids) const entriesById = toIdMap(entries) const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates) @@ -27,7 +25,6 @@ export default async function Home() {
-
From fb3d43b45cda0040602949fa5d93ddc6e59abc5f Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 10:13:55 +0200 Subject: [PATCH 15/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(react-web):?= =?UTF-8?q?=20Drop=20redundant=20isReady=20from=20provider=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the isomorphic rework, the context `isReady` flag was equivalent to `sdk !== undefined`: it started `true` (a snapshot always backs first paint) and only flipped to `false` in the initialization-error path, where `runtime` is set to `undefined` at the same time. The stored flag no longer carried independent meaning. Remove `isReady` from `OptimizationContextValue` and `ProviderState`, and derive readiness from `sdk` presence in the consumers: - `LiveUpdatesProvider` gates its subscription on `sdk` alone. - `useOptimizedEntry` seeds presentation readiness and `isSdkStateReady` from `sdk !== undefined`. Its own returned `isReady` (presentation-readiness from the controller snapshot) is unchanged. Sweep the context-value literals in tests and the dev app accordingly. The Next.js Pages Router mock `isReady` and `router.isReady` usage are unrelated and left as-is. Addresses PR review comment on OptimizationProvider ("Would this value ever change? Is it still necessary?"). Verified: @contentful/optimization-react-web typecheck + 107 unit tests pass under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/app/sections/ProvidersSection.tsx | 8 +++---- .../src/context/OptimizationContext.tsx | 1 - .../src/hooks/useMergeTagResolver.test.tsx | 2 +- .../react-web-sdk/src/index.test.tsx | 13 +++++------ .../src/optimized-entry/useOptimizedEntry.ts | 22 +++++++++++-------- .../src/provider/LiveUpdatesProvider.tsx | 6 ++--- ...ptimizationProvider.onStatesReady.test.tsx | 7 +++--- .../src/provider/OptimizationProvider.tsx | 12 +++++----- .../src/router/next-app.test.tsx | 2 +- .../src/router/next-pages.test.tsx | 2 +- .../src/router/react-router.test.tsx | 2 +- .../src/router/tanstack-router.test.tsx | 2 +- .../react-web-sdk/src/test/sdkTestUtils.tsx | 4 ++-- 13 files changed, 39 insertions(+), 44 deletions(-) diff --git a/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx b/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx index 59ace5d90..a1f3a2a53 100644 --- a/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx +++ b/packages/web/frameworks/react-web-sdk/dev/app/sections/ProvidersSection.tsx @@ -3,23 +3,21 @@ import { LiveUpdatesProvider, OptimizationProvider, type OptimizationSdk } from import { useOptimizationContext } from '../../../src/hooks/useOptimization' function DecoupledConsumer({ label }: { label: string }): ReactElement { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() return (

- {label}:{' '} - {error ? `Error — ${error.message}` : isReady && sdk ? 'SDK ready' : 'Initializing...'} + {label}: {error ? `Error — ${error.message}` : sdk ? 'SDK ready' : 'Initializing...'}

) } function ContextConsumer(): ReactElement { - const { sdk, isReady, error } = useOptimizationContext() + const { sdk, error } = useOptimizationContext() return (

useOptimizationContext()

-

{`isReady: ${String(isReady)}`}

{`sdk: ${sdk ? 'present' : 'undefined'}`}

{`error: ${error ? error.message : 'none'}`}

diff --git a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx index 87d09ce67..53ee4743e 100644 --- a/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx +++ b/packages/web/frameworks/react-web-sdk/src/context/OptimizationContext.tsx @@ -23,7 +23,6 @@ export interface OptimizationContextValue { * environment. */ readonly sdk: OptimizationSdk | undefined - readonly isReady: boolean readonly error: Error | undefined } diff --git a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx index f2d777d5f..687f24170 100644 --- a/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/hooks/useMergeTagResolver.test.tsx @@ -79,7 +79,7 @@ describe('useMergeTagResolver', () => { } renderToString( - + , ) diff --git a/packages/web/frameworks/react-web-sdk/src/index.test.tsx b/packages/web/frameworks/react-web-sdk/src/index.test.tsx index 26cce0a35..bce951a2c 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/index.test.tsx @@ -223,9 +223,7 @@ describe('@contentful/optimization-react-web core providers', () => { } renderToString( - + , ) @@ -233,7 +231,6 @@ describe('@contentful/optimization-react-web core providers', () => { expect(capturedContext).toEqual( expect.objectContaining({ sdk: undefined, - isReady: false, error: initializationError, }), ) @@ -247,7 +244,7 @@ describe('@contentful/optimization-react-web core providers', () => { const capturedError = captureRenderError( , @@ -317,7 +314,7 @@ describe('@contentful/optimization-react-web core providers', () => { }) renderToString( - + , ) @@ -426,13 +423,13 @@ describe('@contentful/optimization-react-web core providers', () => { }) const rendered = renderClient( - + , ) rendered.rerender( - + , ) diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts index 529b06dcb..3da9f6678 100644 --- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts @@ -47,12 +47,16 @@ export function useOptimizedEntrySnapshot({ trackViews, viewDurationUpdateIntervalMs, }: UseOptimizedEntrySnapshotParams): OptimizedEntrySnapshot { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const liveUpdatesContext = useLiveUpdates() - // Seed from context readiness so the server render (and the first client - // render) presents resolved content instead of the loading state; effects, - // which do not run on the server, would otherwise leave this false during SSR. - const [isPresentationReady, setIsPresentationReady] = useState(isReady) + // The isomorphic runtime is present from the initial render (snapshot on the + // server, live SDK after hydration), so SDK-state readiness tracks its + // presence. + const isSdkStateReady = sdk !== undefined + // Seed from readiness so the server render (and the first client render) + // presents resolved content instead of the loading state; effects, which do + // not run on the server, would otherwise leave this false during SSR. + const [isPresentationReady, setIsPresentationReady] = useState(isSdkStateReady) const controllerOptions = useMemo( () => ({ @@ -63,7 +67,7 @@ export function useOptimizedEntrySnapshot({ hasCustomLoadingFallback, isPreviewPanelOpen: liveUpdatesContext.previewPanelVisible, sdk, - isSdkStateReady: isReady, + isSdkStateReady, targetDisplay, clickable, hoverDurationUpdateIntervalMs, @@ -78,7 +82,7 @@ export function useOptimizedEntrySnapshot({ clickable, hasCustomLoadingFallback, hoverDurationUpdateIntervalMs, - isReady, + isSdkStateReady, liveUpdates, liveUpdatesContext.globalLiveUpdates, liveUpdatesContext.previewPanelVisible, @@ -94,8 +98,8 @@ export function useOptimizedEntrySnapshot({ const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()) useEffect(() => { - setIsPresentationReady(isReady) - }, [isReady]) + setIsPresentationReady(isSdkStateReady) + }, [isSdkStateReady]) useEffect(() => { controller.setSnapshotListener(setSnapshot) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx index b1d35f9cb..420d6875b 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/LiveUpdatesProvider.tsx @@ -11,11 +11,11 @@ export function LiveUpdatesProvider({ children, globalLiveUpdates = false, }: LiveUpdatesProviderProps): ReactElement { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [previewPanelVisible, setPreviewPanelVisible] = useState(false) useEffect(() => { - if (!sdk || !isReady) { + if (!sdk) { return } @@ -26,7 +26,7 @@ export function LiveUpdatesProvider({ return () => { sub.unsubscribe() } - }, [isReady, sdk]) + }, [sdk]) return ( { it('renders children from a snapshot runtime during server render without constructing the owned sdk', () => { let childRendered = false - let isReadyDuringRender = false + let sdkPresentDuringRender = false function Probe(): null { childRendered = true - isReadyDuringRender = useOptimizationContext().isReady + sdkPresentDuringRender = useOptimizationContext().sdk !== undefined return null } @@ -361,7 +361,7 @@ describe('OptimizationProvider onStatesReady', () => { // Children render on the server (the provider is isomorphic), backed by a // read-only snapshot runtime, but the live browser SDK is never constructed. expect(childRendered).toBe(true) - expect(isReadyDuringRender).toBe(true) + expect(sdkPresentDuringRender).toBe(true) expect(window.contentfulOptimization).toBeUndefined() }) @@ -467,7 +467,6 @@ describe('OptimizationProvider onStatesReady', () => { expect(capturedContext).toEqual({ sdk: undefined, - isReady: false, error, }) expect(destroySpy).toHaveBeenCalledTimes(1) diff --git a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx index d6f181090..74d7e55ed 100644 --- a/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx +++ b/packages/web/frameworks/react-web-sdk/src/provider/OptimizationProvider.tsx @@ -34,7 +34,6 @@ type ProviderSdkBinding = OptimizationRootSdkBinding interface ProviderState { readonly error: Error | undefined - readonly isReady: boolean /** Whether `runtime` is the live browser SDK (vs the initial snapshot runtime). */ readonly isLive: boolean readonly runtime: WebOptimizationRuntime | undefined @@ -232,7 +231,6 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle const liveLocale = props.sdk === undefined ? props.locale : undefined const [state, setState] = useState(() => ({ error: undefined, - isReady: true, isLive: injectedSdkBacksInitialRender(props), runtime: createInitialRuntime(props), })) @@ -264,12 +262,12 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle } sdkBinding = initializedBinding - setState({ error: undefined, isReady: true, isLive: true, runtime: initializedBinding.sdk }) + setState({ error: undefined, isLive: true, runtime: initializedBinding.sdk }) } function setInitializationError(error: unknown): void { if (!setupState.disposed) { - setState({ error: toError(error), isReady: false, isLive: false, runtime: undefined }) + setState({ error: toError(error), isLive: false, runtime: undefined }) } } @@ -308,13 +306,13 @@ export function OptimizationProvider(props: OptimizationProviderProps): ReactEle try { state.runtime.setLocale(liveLocale) } catch (error: unknown) { - setState({ error: toError(error), isReady: true, isLive: true, runtime: state.runtime }) + setState({ error: toError(error), isLive: true, runtime: state.runtime }) } }, [liveLocale, props.sdk, state.isLive, state.runtime]) const contextValue = useMemo( - () => ({ sdk: state.runtime, isReady: state.isReady, error: state.error }), - [state.runtime, state.isReady, state.error], + () => ({ sdk: state.runtime, error: state.error }), + [state.runtime, state.error], ) return ( diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx index 9e33d30d4..9d1268a2a 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-app.test.tsx @@ -47,7 +47,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx index e0b25558f..6bf86c84e 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/next-pages.test.tsx @@ -34,7 +34,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx index de8cc5bda..ad3004864 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/react-router.test.tsx @@ -36,7 +36,7 @@ async function renderTracker( await act(async () => { await Promise.resolve() root.render( - + {nextNode} diff --git a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx index d09731ed6..173864a8c 100644 --- a/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx +++ b/packages/web/frameworks/react-web-sdk/src/router/tanstack-router.test.tsx @@ -65,7 +65,7 @@ function buildTestRouter( ): ReturnType { function RootLayout(): ReactElement { return ( - + {tracker} diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx index 5c50853c3..ecae52986 100644 --- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx +++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx @@ -461,7 +461,7 @@ export async function renderWithOptimizationProviders( await act(async () => { await Promise.resolve() root.render( - + {node} , ) @@ -485,7 +485,7 @@ export function renderWithOptimizationProvidersToString( liveUpdatesContext = defaultLiveUpdatesContext(), ): string { return renderToString( - + {node} , ) From 18c4c2a0fa9155b353145ff462ef53c8076e7557 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 10:18:31 +0200 Subject: [PATCH 16/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk)?= =?UTF-8?q?:=20Delete=20deprecated=20NextjsOptimizationState=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider now renders `serverOptimizationState` on the server and hydrates the same data into the live SDK on the client, so the page-level `NextjsOptimizationState` marker was redundant. Its only residual use — seeding a configuration-only provider and hydrating page-specific data later — is served directly by the public `hydrateOptimizationData` primitive on `@contentful/optimization-web/bridge-support`. Delete the component and its props type from the client subpath (no implementation depended on it). Rewrite the client test to keep the export- absence guard for the previously removed server hydrator and extend it to assert `NextjsOptimizationState` is likewise gone, plus a positive check that the live client surface is still re-exported. Repoint the concept/guide/README mentions to `hydrateOptimizationData` for the page-level hydration use case. This is a clean breaking change to `@contentful/optimization-nextjs/client`, consistent with pre-release policy; no compatibility alias is kept. Verified: @contentful/optimization-nextjs typecheck + 25 unit tests pass under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...king-in-node-and-stateless-environments.md | 24 ++-- ...-handling-in-the-optimization-sdk-suite.md | 5 +- ...nchronization-between-client-and-server.md | 51 +++---- ...he-optimization-sdk-in-a-nextjs-app-ssr.md | 7 +- packages/web/frameworks/nextjs-sdk/README.md | 11 +- .../frameworks/nextjs-sdk/src/client.test.tsx | 135 +++--------------- .../web/frameworks/nextjs-sdk/src/client.ts | 31 ---- 7 files changed, 68 insertions(+), 196 deletions(-) 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 e20166787..c0be24944 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -75,11 +75,11 @@ Apply these constraints before choosing server-only, hybrid, or manual tracking: views without entry views. - Browser Insights delivery needs a current Web SDK profile. In direct Web SDK initialization, the profile can come from `defaults.profile`. In React Web and Next.js provider handoff, pass - server-returned Optimization data through `serverOptimizationState` (the deprecated - `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing - provider). The profile can also come from browser-persisted profile state that persistence consent - allows the SDK to load, or a browser Experience API call such as `page()`, `identify()`, - `track()`, or sticky `trackView()`. + server-returned Optimization data through `serverOptimizationState` (call + `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client + Component to hydrate page-specific data under an existing provider). The profile can also come + from browser-persisted profile state that persistence consent allows the SDK to load, or a browser + Experience API call such as `page()`, `identify()`, `track()`, or sticky `trackView()`. - Browser storage is best-effort. The Web SDK uses `localStorage` and the `ctfl-opt-aid` cookie when persistence consent permits continuity; if storage fails or is unavailable, continuity is limited to in-memory state. @@ -395,10 +395,10 @@ delivery. Choose one of these patterns before enabling interaction tracking: - **Bootstrap the server profile.** For direct Web SDK initialization, serialize the `profile` returned by the server's `page()` or `identify()` call and pass it as `defaults.profile`. For - React Web and Next.js, pass the server `OptimizationData` through `serverOptimizationState` (the - deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an - existing provider). Use this when the same server response already rendered personalized HTML from - that profile. + React Web and Next.js, pass the server `OptimizationData` through `serverOptimizationState` (call + `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client + Component to hydrate page-specific data under an existing provider). Use this when the same server + response already rendered personalized HTML from that profile. - **Re-evaluate in the browser.** Persist `ctfl-opt-aid` on the server, initialize the Web SDK in the browser, call `page()` after your consent policy allows it, then enable tracking after the page response populates browser profile state. @@ -409,9 +409,9 @@ delivery. Choose one of these patterns before enabling interaction tracking: In Next.js SSR integrations, `initialPageEvent="skip"` intentionally avoids the initial browser Experience API `page()` request when the server already emitted that page event. If that skip leaves -the browser without `serverOptimizationState` or `NextjsOptimizationState`, 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. +the browser without `serverOptimizationState` 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/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md index 48c75c675..82e47c733 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -189,8 +189,9 @@ Locale handoff is separate from server optimization state handoff. Pass the serv `serverOptimizationState` on `OptimizationRoot`, resolving it in the layout so the provider renders personalized state on the server and hydrates the same data on the client. Keep `defaults` for configuration or default state such as consent policy, not for server-returned profile, selected -optimizations, or changes. (The deprecated `NextjsOptimizationState` marker remains for hydrating -page-specific data under an existing provider that was not seeded with server data.) +optimizations, or changes. (To hydrate page-specific data under an existing provider that was not +seeded with server data, call `hydrateOptimizationData` from +`@contentful/optimization-web/bridge-support` inside a Client Component.) ## Node and stateless SDKs diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md index 6439156b9..2c917d671 100644 --- a/documentation/concepts/profile-synchronization-between-client-and-server.md +++ b/documentation/concepts/profile-synchronization-between-client-and-server.md @@ -106,8 +106,9 @@ profile-changing events: response. In React Web and Next.js handoff, keep `defaults` for configuration or default state such as consent policy, and pass server-returned Optimization data through `serverOptimizationState` on the provider or root, which renders it on the server and hydrates it - on the client. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific - data under an existing provider that was not seeded with server data. + on the client. To hydrate page-specific data under an existing provider that was not seeded with + server data, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` + inside a Client Component. For consent gates, see [Consent management in the Optimization SDK Suite](./consent-management-in-the-optimization-sdk-suite.md). @@ -143,11 +144,11 @@ The shared cookie is enough when the browser performs personalization after hydr enough when the server already rendered profile-derived HTML and the browser must continue from the same evaluated data before its first client-side Experience response. -| Path | Use when | Browser startup contract | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. | -| **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For React Web and Next.js provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing provider. | -| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. | +| Path | Use when | Browser startup contract | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. | +| **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For React Web and Next.js provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. To hydrate page-specific data under an existing provider, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client Component. | +| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. | Direct Web SDK bootstrapping must use the same `OptimizationData` response that drove the server render: @@ -168,9 +169,10 @@ If the browser re-resolves entries from stale localStorage while the server rend profile evaluation, the user can see a mismatched variant or profile-derived value. For direct Web SDK initialization, use explicit defaults. For React Web and Next.js, pass the server `OptimizationData` to `serverOptimizationState`; the provider renders that state on the server and -hydrates the same data on the client (the deprecated `NextjsOptimizationState` marker remains for -hydrating page-specific data under an existing provider). A fresh client-side `page()` response or a -render boundary can also prevent stale cached state from driving visible content. +hydrates the same data on the client (call `hydrateOptimizationData` from +`@contentful/optimization-web/bridge-support` inside a Client Component to hydrate page-specific +data under an existing provider). A fresh client-side `page()` response or a render boundary can +also prevent stale cached state from driving visible content. Personalized HTML is not shared-cache safe unless the cache varies on all personalization inputs. Raw Contentful entries are the safer cache boundary; resolve variants per request or per profile @@ -498,17 +500,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 React Web and Next.js provider handoff, use `serverOptimizationState`. The deprecated `NextjsOptimizationState` marker remains for hydrating page-specific data under an existing provider. | -| Server uses `preflight` for normal flows | The API evaluates without storing the mutation, which breaks durable profile continuity expectations. | Reserve `preflight` for preview or non-persistent evaluation. | -| Full profile serialized unnecessarily | More profile data reaches the browser than the UI needs. | Share only the profile ID unless hydration needs profile data, changes, or selections. | +| Case | What happens | Mitigation | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctfl-opt-aid` is `HttpOnly` | The server can read it, but the Web SDK cannot adopt it. | Use a readable cookie for hybrid Node and Web SDK continuity. | +| Cookie domain or path mismatch | The browser and server use different profile IDs or no shared ID. | Set `path: '/'` and a domain that covers the pages that initialize the Web SDK. | +| Cookie differs from localStorage | The Web SDK clears cached profile-continuity data and adopts the cookie ID when persistence consent is `true`. | Treat this as expected when the server changes identity. | +| Cookie changes after SDK construction | The running Web SDK does not continuously watch cookies. | Reinitialize intentionally after teardown or update identity through SDK event flows. | +| Multiple browser tabs | Tabs share storage, but in-memory signals are per runtime and do not auto-sync from storage events. | Let each tab refresh state through Experience events or reload-sensitive application flows. | +| Offline browser Experience events | Events queue locally and no new profile data is available until a successful flush. | Design UI so cached selections are acceptable while offline. | +| Missing browser profile for Insights | Insights delivery is skipped because stateful Insights events use the current profile signal. | Ensure an Experience call has returned a profile before relying on Insights-only tracking. For direct Web SDK initialization, bootstrap a valid `defaults.profile` when the server already evaluated the profile. For React Web and Next.js provider handoff, use `serverOptimizationState`. To hydrate page-specific data under an existing provider, call `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client Component. | +| 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 @@ -527,9 +529,10 @@ Use this checklist when implementing a hybrid Node and browser profile flow: profile-continuity state or adopt `ctfl-opt-aid`. - Render from the `OptimizationData` response that matches the current identity state. - Bootstrap direct Web SDK `defaults`, or use React Web or Next.js `serverOptimizationState` for - provider handoff (the deprecated `NextjsOptimizationState` marker remains for page-level - hydration), when server-rendered personalized output must match client-side resolution before the - first browser Experience response. + provider handoff (call `hydrateOptimizationData` from + `@contentful/optimization-web/bridge-support` inside a Client Component for page-level hydration), + when server-rendered personalized output must match client-side resolution before the first + browser Experience response. - Clear both browser state and server persistence when consent revocation must end profile continuity. - Cache raw Contentful delivery payloads, not profile-evaluated SDK responses or personalized HTML diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md index ff53fbee1..930bf065a 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md @@ -286,9 +286,10 @@ consent controls, identify, and reset run in the browser through the Next.js cli > [!NOTE] > -> `serverOptimizationState` on the provider is the recommended handoff. The -> `` marker is deprecated; it remains only for setups that configure the -> provider without server data and hydrate page-specific data later under existing SDK context. +> `serverOptimizationState` on the provider is the recommended handoff. To seed a configuration-only +> provider and hydrate page-specific data later under existing SDK context, call +> `hydrateOptimizationData` from `@contentful/optimization-web/bridge-support` inside a Client +> Component. **Adapt this to your use case:** diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index 753665b09..ac26b6912 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -123,11 +123,12 @@ import { OptimizedEntry } from '@contentful/optimization-nextjs/client' > [!NOTE] > -> `NextjsOptimizationState` and `ServerOptimizedEntry` are deprecated. Pass server data through the -> provider's `serverOptimizationState` prop and render entries with `OptimizedEntry`. -> `NextjsOptimizationState` remains for configuring the provider without server data and hydrating -> page-specific data later; `ServerOptimizedEntry` remains for pure zero-JavaScript Server Component -> rendering where you resolve `resolvedData` yourself. +> `ServerOptimizedEntry` is deprecated. Pass server data through the provider's +> `serverOptimizationState` prop and render entries with `OptimizedEntry`. To seed a +> configuration-only provider and hydrate page-specific data later, call `hydrateOptimizationData` +> from `@contentful/optimization-web/bridge-support` inside a Client Component. +> `ServerOptimizedEntry` remains for pure zero-JavaScript Server Component rendering where you +> resolve `resolvedData` yourself. ## Request context setup diff --git a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx index 7905b9653..750ac6c65 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/client.test.tsx @@ -1,10 +1,4 @@ -import ContentfulOptimization from '@contentful/optimization-web' -import type { OptimizationData } from '@contentful/optimization-web/api-schemas' -import { act } from 'react' -import { createRoot } from 'react-dom/client' -import { renderToString } from 'react-dom/server' import * as client from './client' -import { NextjsOptimizationState, OptimizationContext } from './client' type RemovedHydratorPrefix = 'Nextjs' type RemovedHydratorSuffixPrefix = 'ServerOptimization' @@ -15,125 +9,28 @@ type RemovedHydratorExportIsAbsent = RemovedHydratorExportName extends keyof typ ? false : true +type RemovedStateMarkerExportName = 'NextjsOptimizationState' +type RemovedStateMarkerExportIsAbsent = RemovedStateMarkerExportName extends keyof typeof client + ? false + : true + const removedHydratorExportIsAbsent: RemovedHydratorExportIsAbsent = true +const removedStateMarkerExportIsAbsent: RemovedStateMarkerExportIsAbsent = true const removedHydratorExportName = ['Nextjs', 'ServerOptimization', 'Hydrator'].join('') +const removedStateMarkerExportName = ['Nextjs', 'Optimization', 'State'].join('') -const testConfig = { - clientId: 'test-client-id', - environment: 'main', - api: { - insightsBaseUrl: 'http://localhost:8000/insights/', - experienceBaseUrl: 'http://localhost:8000/experience/', - }, -} - -function cleanupOptimizationSingleton(): void { - window.contentfulOptimization?.destroy() - document.body.innerHTML = '' -} - -void afterEach(() => { - cleanupOptimizationSingleton() -}) - -const optimizationData: OptimizationData = { - changes: [], - selectedOptimizations: [], - profile: { - id: 'server-profile-id', - stableId: 'server-profile-id', - random: 0.5, - audiences: [], - traits: {}, - location: {}, - session: { - id: 'server-session-id', - isReturningVisitor: false, - landingPage: { - path: '/', - query: {}, - referrer: '', - search: '', - title: '', - url: 'http://localhost/', - }, - count: 1, - activeSessionLength: 0, - averageSessionLength: 0, - }, - }, -} - -async function renderClientStateMarker( - sdk: ContentfulOptimization, - data: OptimizationData | undefined, -): Promise<{ unmount: () => void }> { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - await act(async () => { - root.render( - - - , - ) - await Promise.resolve() - await Promise.resolve() - }) - - return { - unmount() { - act(() => { - root.unmount() - }) - container.remove() - }, - } -} - -describe('Next.js client optimization state marker', () => { - it('exports NextjsOptimizationState without the removed server hydrator API', () => { +describe('Next.js client subpath', () => { + it('no longer exports the removed server hydrator or state marker APIs', () => { expect(removedHydratorExportIsAbsent).toBe(true) - expect(client.NextjsOptimizationState).toBeTypeOf('function') + expect(removedStateMarkerExportIsAbsent).toBe(true) expect(removedHydratorExportName in client).toBe(false) + expect(removedStateMarkerExportName in client).toBe(false) }) - it('renders no UI', () => { - const sdk = new ContentfulOptimization(testConfig) - - const markup = renderToString( - - - , - ) - - expect(markup).toBe('') - sdk.destroy() - }) - - it('hands server optimization state to the nearest optimization provider runtime', async () => { - const sdk = new ContentfulOptimization(testConfig) - - const rendered = await renderClientStateMarker(sdk, optimizationData) - - expect(sdk.states.profile.current).toEqual(optimizationData.profile) - expect(sdk.states.selectedOptimizations.current).toEqual(optimizationData.selectedOptimizations) - expect(sdk.states.experienceRequestState.current).toEqual({ status: 'success' }) - - rendered.unmount() - sdk.destroy() - }) - - it('does not change optimization state when data is undefined', async () => { - const sdk = new ContentfulOptimization(testConfig) - - const rendered = await renderClientStateMarker(sdk, undefined) - - expect(sdk.states.profile.current).toBeUndefined() - expect(sdk.states.selectedOptimizations.current).toBeUndefined() - - rendered.unmount() - sdk.destroy() + it('re-exports the live client surface for provider handoff', () => { + expect(client.OptimizationRoot).toBeTypeOf('function') + expect(client.OptimizationProvider).toBeTypeOf('function') + expect(client.NextAppAutoPageTracker).toBeTypeOf('function') + expect(client.NextPagesAutoPageTracker).toBeTypeOf('function') }) }) diff --git a/packages/web/frameworks/nextjs-sdk/src/client.ts b/packages/web/frameworks/nextjs-sdk/src/client.ts index ef053cfa5..f9e52588b 100644 --- a/packages/web/frameworks/nextjs-sdk/src/client.ts +++ b/packages/web/frameworks/nextjs-sdk/src/client.ts @@ -1,10 +1,5 @@ 'use client' -import { useOptimization } from '@contentful/optimization-react-web' -import type { OptimizationData } from '@contentful/optimization-web/api-schemas' -import { hydrateOptimizationData } from '@contentful/optimization-web/bridge-support' -import { useLayoutEffect } from 'react' - export * from '@contentful/optimization-react-web' export { NextAppAutoPageTracker, @@ -16,29 +11,3 @@ export { type NextPagesAutoPageContext, type NextPagesAutoPageTrackerProps, } from '@contentful/optimization-react-web/router/next-pages' - -export interface NextjsOptimizationStateProps { - readonly data: OptimizationData | undefined -} - -/** - * Hydrates server-resolved Optimization data into the live client SDK. - * - * @deprecated Pass the server-resolved `OptimizationData` to `OptimizationRoot` - * (or `OptimizationProvider`) via the `serverOptimizationState` prop instead. - * The provider now renders personalized state on the server and hydrates the - * same data into the live SDK on the client, so a separate page-level - * hydration marker is redundant. This component remains only for setups that - * seed the provider by configuration and hydrate page-specific data later. - * - * @public - */ -export function NextjsOptimizationState({ data }: NextjsOptimizationStateProps): null { - const sdk = useOptimization() - - useLayoutEffect(() => { - void hydrateOptimizationData(sdk, data) - }, [data, sdk]) - - return null -} From 3b06c6dbc46c677a3dadcd7df0e6a9eda77db0b8 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 10:21:41 +0200 Subject: [PATCH 17/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk)?= =?UTF-8?q?:=20Rename=20ServerOptimizedEntry=20to=20ServerOnlyOptimizedEnt?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-client-JavaScript Server Component entry keeps a genuinely distinct niche from the isomorphic `OptimizedEntry` (no hydration, no live updates), so undeprecate it rather than delete it. The old name read as "the server variant of OptimizedEntry", which is misleading now that `OptimizedEntry` also renders on the server; "server-only" names the defining trait — it ships no client component for the entry. Rename the component and its `*Props`/`*OwnProps` types to `ServerOnly*`, drop the `@deprecated` tag, and rewrite the TSDoc to present it as the supported zero-JS option while pointing power users at `getServerTrackingAttributes()` for custom wrapper markup. Update the server unit test and the concept, guide, and README mentions to the new name. This is a clean breaking rename on `@contentful/optimization-nextjs/server`, consistent with pre-release policy; no compatibility alias is kept. Verified: @contentful/optimization-nextjs typecheck + 25 unit tests pass under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-personalization-and-variant-resolution.md | 4 +-- ...king-in-node-and-stateless-environments.md | 18 ++++++------ ...-handling-in-the-optimization-sdk-suite.md | 4 +-- ...ptimization-sdk-in-a-nextjs-app-ssr-csr.md | 10 +++---- ...he-optimization-sdk-in-a-nextjs-app-ssr.md | 6 ++-- implementations/nextjs-sdk_hybrid/README.md | 2 +- packages/web/frameworks/nextjs-sdk/README.md | 11 ++++---- .../frameworks/nextjs-sdk/src/server.test.tsx | 4 +-- .../web/frameworks/nextjs-sdk/src/server.tsx | 28 +++++++++++-------- 9 files changed, 45 insertions(+), 42 deletions(-) diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md index df14909bd..730b3053b 100644 --- a/documentation/concepts/entry-personalization-and-variant-resolution.md +++ b/documentation/concepts/entry-personalization-and-variant-resolution.md @@ -89,8 +89,8 @@ the runtime: | Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` | Next.js uses the Node server and React Web client surfaces. The isomorphic `OptimizedEntry` resolves -entries on the server for first paint and hydrates on the client; the deprecated -`ServerOptimizedEntry` remains only for pure zero-JavaScript Server Component rendering. +entries on the server for first paint and hydrates on the client; `ServerOnlyOptimizedEntry` is +available for pure zero-JavaScript Server Component rendering. ## Inputs and constraints 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 c0be24944..92a996033 100644 --- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md +++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md @@ -51,13 +51,13 @@ between server and browser, see Choose the runtime path before designing the event flow. The SDK that renders or observes the interaction decides which facts are available. -| Path | Runtime responsibility | Use when | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. | -| `@contentful/optimization-web` | Own browser consent state, profile state, storage, automatic DOM observation, browser queues, and Insights delivery. | Non-React or custom browser code needs view, click, hover, route, or manual element tracking after HTML reaches the page. | -| `@contentful/optimization-react-web` | Wrap the Web SDK with React browser providers, hooks, router trackers, and `OptimizedEntry` from `@contentful/optimization-react-web`. | React browser apps need framework-owned state, route page tracking, entry wrappers, or browser-side entry personalization. | -| `@contentful/optimization-nextjs` | Own Next.js adapter surfaces: server helpers and `ServerOptimizedEntry` from `@contentful/optimization-nextjs/server`, request helpers from `@contentful/optimization-nextjs/request-handler`, tracking helpers from `@contentful/optimization-nextjs/tracking-attributes`, and client wrappers from `@contentful/optimization-nextjs/client`. | Next.js apps need server-owned personalization, request and cookie helpers, SSR tracking attributes, and client tracking boundaries. | -| First-party browser collector plus Node SDK | Observe browser interactions in application code, post observations to an app endpoint, validate policy, and call request-bound Node SDK tracking methods. | The browser cannot run the Web SDK, but the app can own DOM observation, payload mapping, profile continuity, and retries. | +| Path | Runtime responsibility | Use when | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. | +| `@contentful/optimization-web` | Own browser consent state, profile state, storage, automatic DOM observation, browser queues, and Insights delivery. | Non-React or custom browser code needs view, click, hover, route, or manual element tracking after HTML reaches the page. | +| `@contentful/optimization-react-web` | Wrap the Web SDK with React browser providers, hooks, router trackers, and `OptimizedEntry` from `@contentful/optimization-react-web`. | React browser apps need framework-owned state, route page tracking, entry wrappers, or browser-side entry personalization. | +| `@contentful/optimization-nextjs` | Own Next.js adapter surfaces: server helpers and `ServerOnlyOptimizedEntry` from `@contentful/optimization-nextjs/server`, request helpers from `@contentful/optimization-nextjs/request-handler`, tracking helpers from `@contentful/optimization-nextjs/tracking-attributes`, and client wrappers from `@contentful/optimization-nextjs/client`. | Next.js apps need server-owned personalization, request and cookie helpers, SSR tracking attributes, and client tracking boundaries. | +| First-party browser collector plus Node SDK | Observe browser interactions in application code, post observations to an app endpoint, validate policy, and call request-bound Node SDK tracking methods. | The browser cannot run the Web SDK, but the app can own DOM observation, payload mapping, profile continuity, and retries. | ## Constraints that decide delivery @@ -314,8 +314,8 @@ of tracking that can only be measured in the browser. Use SDK helpers when available instead of copying the attribute map into application code. In Next.js, the isomorphic `OptimizedEntry` renders the Web SDK tracking attributes as part of -resolving the entry on the server; the deprecated `ServerOptimizedEntry` does the same from an -explicit `ResolvedData` for pure zero-JavaScript server rendering. For custom SSR wrappers, call +resolving the entry on the server; `ServerOnlyOptimizedEntry` does the same from an explicit +`ResolvedData` for pure zero-JavaScript server rendering. For custom SSR wrappers, call `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes`. Non-Next runtimes can call `resolveOptimizedEntryTrackingAttributes()` from `@contentful/optimization-web/tracking-attributes` when they already have the same baseline entry diff --git a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md index 82e47c733..ea824e224 100644 --- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md +++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md @@ -233,8 +233,8 @@ Pass direct single-locale field values to the runtime-specific entry resolution - React Web and React Native `OptimizedEntry` and `useEntryResolver()`. - React Web and Next.js client `useOptimizedEntry()`, and the isomorphic `OptimizedEntry` that resolves on the server and hydrates on the client. -- Next.js server `resolveOptimizedEntry()` for Server Component resolution; the deprecated - `ServerOptimizedEntry` remains for pure zero-JavaScript server rendering. +- Next.js server `resolveOptimizedEntry()` for Server Component resolution; + `ServerOnlyOptimizedEntry` for pure zero-JavaScript server rendering. - iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI `OptimizedEntry(entry:)`. - Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md index 08c51e1fb..56bdbb437 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md @@ -582,9 +582,9 @@ function ServerRenderedEntry({ baselineEntry }: ServerRenderedEntryProps) { > [!NOTE] > -> `ServerOptimizedEntry` is deprecated in favor of `OptimizedEntry`. Use it only for a pure Server -> Component with zero client JavaScript for the entry and no live updates, resolving `resolvedData` -> yourself on the server. +> `OptimizedEntry` is the default for entry rendering. Use `ServerOnlyOptimizedEntry` only for a +> pure Server Component with zero client JavaScript for the entry and no live updates, resolving +> `resolvedData` yourself on the server. ### Browser root and server optimization state @@ -779,8 +779,8 @@ delivery. them; use `trackEntryInteraction` only to opt out of interaction types the app must not observe. 2. Use `OptimizedEntry` props such as `clickable`, `trackViews`, `trackClicks`, `trackHovers`, `viewDurationUpdateIntervalMs`, and `hoverDurationUpdateIntervalMs` for per-entry control. -3. `OptimizedEntry` already renders server-side and emits tracking metadata; use the deprecated - `ServerOptimizedEntry` only for a pure zero-JavaScript Server Component entry. +3. `OptimizedEntry` already renders server-side and emits tracking metadata; use + `ServerOnlyOptimizedEntry` only for a pure zero-JavaScript Server Component entry. 4. Use `sdk.tracking.enableElement(...)` from `useOptimization()` only for app-owned manual observation cases. 5. Verify consent gates. Page events can be allowed before full consent, but entry views, clicks, diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md index 930bf065a..56b449303 100644 --- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md +++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md @@ -452,9 +452,9 @@ function ArticleEntry({ baselineEntry }: { baselineEntry: Entry }) { > [!NOTE] > -> `ServerOptimizedEntry` is deprecated in favor of `OptimizedEntry`. It remains valid only when you -> need a pure Server Component with zero client JavaScript for the entry and no live updates, and -> are resolving `resolvedData` yourself on the server. +> `OptimizedEntry` is the default for entry rendering. Use `ServerOnlyOptimizedEntry` when you need +> a pure Server Component with zero client JavaScript for the entry and no live updates, resolving +> `resolvedData` yourself on the server. Use the lower-level `getServerTrackingAttributes()` helper when a design-system component must own the wrapper markup and forward the `data-ctfl-*` attributes to the DOM element the browser SDK diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md index 41218315d..4c1e166bd 100644 --- a/implementations/nextjs-sdk_hybrid/README.md +++ b/implementations/nextjs-sdk_hybrid/README.md @@ -158,7 +158,7 @@ The E2E suite reuses the shared `lib/e2e-web` browser scenarios for CSR and hydr the hybrid app configuration. It covers shared variant resolution, tracking, navigation, live updates, offline queue recovery, and the hybrid-specific hydration check that a consented server handoff does not issue a duplicate client Experience request. Package unit tests cover lower-level -Next.js adapter request-context, `ServerOptimizedEntry`, and initial page-event helper behavior. +Next.js adapter request-context, `ServerOnlyOptimizedEntry`, and initial page-event helper behavior. Use Playwright UI or codegen when needed: diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md index ac26b6912..9facf46de 100644 --- a/packages/web/frameworks/nextjs-sdk/README.md +++ b/packages/web/frameworks/nextjs-sdk/README.md @@ -123,12 +123,11 @@ import { OptimizedEntry } from '@contentful/optimization-nextjs/client' > [!NOTE] > -> `ServerOptimizedEntry` is deprecated. Pass server data through the provider's -> `serverOptimizationState` prop and render entries with `OptimizedEntry`. To seed a -> configuration-only provider and hydrate page-specific data later, call `hydrateOptimizationData` -> from `@contentful/optimization-web/bridge-support` inside a Client Component. -> `ServerOptimizedEntry` remains for pure zero-JavaScript Server Component rendering where you -> resolve `resolvedData` yourself. +> `OptimizedEntry` is the default for entry rendering. To seed a configuration-only provider and +> hydrate page-specific data later, call `hydrateOptimizationData` from +> `@contentful/optimization-web/bridge-support` inside a Client Component. Use +> `ServerOnlyOptimizedEntry` from `@contentful/optimization-nextjs/server` when you need a pure +> zero-JavaScript Server Component entry with no live updates, resolving `resolvedData` yourself. ## Request context setup diff --git a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx index 6f90b8ee5..75c865d51 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx @@ -1,6 +1,6 @@ import { NEXTJS_OPTIMIZATION_REQUEST_URL_HEADER, - ServerOptimizedEntry, + ServerOnlyOptimizedEntry, bindNextjsOptimizationRequest, createNextjsOptimization, createNextjsPageContext, @@ -415,7 +415,7 @@ describe('Next.js server helpers', () => { }) it('renders a server wrapper with tracking attributes and caller props', () => { - const element = ServerOptimizedEntry({ + const element = ServerOnlyOptimizedEntry({ as: 'article', baselineEntry, children: 'Rendered content', diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx index 18babb5cf..38e5f7f17 100644 --- a/packages/web/frameworks/nextjs-sdk/src/server.tsx +++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx @@ -144,7 +144,7 @@ export type NextjsPageContextInput = | NonNullable | CreateNextjsPageContextOptions -type ServerOptimizedEntryOwnProps = +type ServerOnlyOptimizedEntryOwnProps = ServerTrackingAttributeOptions & { readonly as?: TElement readonly baselineEntry: ServerTrackingBaselineEntry @@ -154,11 +154,11 @@ type ServerOptimizedEntryOwnProps type DataCtflAttributeName = `data-ctfl-${string}` -export type ServerOptimizedEntryProps = - ServerOptimizedEntryOwnProps & +export type ServerOnlyOptimizedEntryProps = + ServerOnlyOptimizedEntryOwnProps & Omit< JSX.IntrinsicElements[TElement], - keyof ServerOptimizedEntryOwnProps | DataCtflAttributeName + keyof ServerOnlyOptimizedEntryOwnProps | DataCtflAttributeName > export function createNextjsOptimization(config: OptimizationNodeConfig): ContentfulOptimization { @@ -344,16 +344,20 @@ export function persistNextjsAnonymousId( * Renders a server-resolved entry in a Server Component with tracking * attributes and no client JavaScript for the entry itself. * - * @deprecated Prefer the isomorphic `OptimizedEntry` from - * `@contentful/optimization-react-web`, which now server-renders for first paint - * and hydrates for interactivity and live updates, using a single component and - * automatic variant resolution. Use this component only when you specifically - * need a pure Server Component with zero client JavaScript for the entry and no - * live updates, and are resolving `resolvedData` yourself on the server. + * @remarks + * Use this component when you need a pure Server Component that ships zero client + * JavaScript for the entry and no live updates, resolving `resolvedData` + * yourself on the server. For the common case — first-paint server rendering + * plus client-side interactivity and live updates from a single component with + * automatic variant resolution — prefer the isomorphic `OptimizedEntry` from + * `@contentful/optimization-react-web`. + * + * When a design-system component must own the wrapper markup, use the + * lower-level {@link getServerTrackingAttributes} helper directly instead. * * @public */ -export function ServerOptimizedEntry({ +export function ServerOnlyOptimizedEntry({ as, baselineEntry, children, @@ -365,7 +369,7 @@ export function ServerOptimizedEntry): ReactElement { +}: ServerOnlyOptimizedEntryProps): ReactElement { const Element = as ?? 'div' const trackingAttributes: ServerTrackingAttributes = getServerTrackingAttributes( baselineEntry, From 4136108295d874b6d7a51ac940bd7d7d19b3d040 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 10:29:30 +0200 Subject: [PATCH 18/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(nextjs-sdk?= =?UTF-8?q?=5Fssr,nextjs-sdk=5Fhybrid):=20Derive=20readiness=20from=20sdk?= =?UTF-8?q?=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the provider-context change that removed `isReady`: the SSR and hybrid reference apps read `isReady` from `useOptimizationContext()` in their event- stream and flag-subscription hooks and in the preview-panel attach effect. Gate those effects on `sdk` presence alone, which is the equivalent condition now that the isomorphic runtime is present from the initial render. Verified: both implementations typecheck and lint clean against the rebuilt `@contentful/optimization-nextjs` tarball under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-sdk_hybrid/components/PreviewPanel.tsx | 6 +++--- implementations/nextjs-sdk_hybrid/lib/hooks.ts | 12 ++++++------ .../nextjs-sdk_ssr/components/PreviewPanel.tsx | 6 +++--- implementations/nextjs-sdk_ssr/lib/hooks.ts | 12 ++++++------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx b/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx index 4161f6fcf..d6c6891c9 100644 --- a/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx +++ b/implementations/nextjs-sdk_hybrid/components/PreviewPanel.tsx @@ -5,10 +5,10 @@ import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect, type JSX } from 'react' export function PreviewPanel(): JSX.Element | null { - const { isReady, sdk } = useOptimizationContext() + const { sdk } = useOptimizationContext() useEffect(() => { - if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined) { + if (!appConfig.previewPanelEnabled || sdk === undefined) { return } @@ -25,7 +25,7 @@ export function PreviewPanel(): JSX.Element | null { .catch((error: unknown) => { console.warn('Failed to attach the Contentful Optimization preview panel.', error) }) - }, [isReady, sdk]) + }, [sdk]) return null } diff --git a/implementations/nextjs-sdk_hybrid/lib/hooks.ts b/implementations/nextjs-sdk_hybrid/lib/hooks.ts index 153c739c8..4592ea6f0 100644 --- a/implementations/nextjs-sdk_hybrid/lib/hooks.ts +++ b/implementations/nextjs-sdk_hybrid/lib/hooks.ts @@ -44,7 +44,7 @@ export function useEventStream( parse: (event: unknown, id: string) => T | undefined, update: (previous: T[], next: T) => T[], ): EventStreamState { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [events, setEvents] = useState([]) const [rawCount, setRawCount] = useState(0) const nextId = useRef(0) @@ -52,7 +52,7 @@ export function useEventStream( const updateRef = useRef(update) useEffect(() => { - if (!isReady || sdk === undefined) return + if (sdk === undefined) return const subscription = sdk.states.eventStream.subscribe((event: unknown) => { const id = `event-${nextId.current}` @@ -69,22 +69,22 @@ export function useEventStream( setRawCount(0) nextId.current = 0 } - }, [isReady, sdk]) + }, [sdk]) return { events, rawCount } } export function useFlagSubscription(flagName: string): unknown { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [value, setValue] = useState(undefined) useEffect(() => { - if (!sdk || !isReady) return + if (!sdk) return const subscription = sdk.states.flag(flagName).subscribe(setValue) return () => { subscription.unsubscribe() } - }, [isReady, sdk, flagName]) + }, [sdk, flagName]) return value } diff --git a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx index 4161f6fcf..d6c6891c9 100644 --- a/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx +++ b/implementations/nextjs-sdk_ssr/components/PreviewPanel.tsx @@ -5,10 +5,10 @@ import { useOptimizationContext } from '@contentful/optimization-nextjs/client' import { useEffect, type JSX } from 'react' export function PreviewPanel(): JSX.Element | null { - const { isReady, sdk } = useOptimizationContext() + const { sdk } = useOptimizationContext() useEffect(() => { - if (!appConfig.previewPanelEnabled || !isReady || sdk === undefined) { + if (!appConfig.previewPanelEnabled || sdk === undefined) { return } @@ -25,7 +25,7 @@ export function PreviewPanel(): JSX.Element | null { .catch((error: unknown) => { console.warn('Failed to attach the Contentful Optimization preview panel.', error) }) - }, [isReady, sdk]) + }, [sdk]) return null } diff --git a/implementations/nextjs-sdk_ssr/lib/hooks.ts b/implementations/nextjs-sdk_ssr/lib/hooks.ts index 153c739c8..4592ea6f0 100644 --- a/implementations/nextjs-sdk_ssr/lib/hooks.ts +++ b/implementations/nextjs-sdk_ssr/lib/hooks.ts @@ -44,7 +44,7 @@ export function useEventStream( parse: (event: unknown, id: string) => T | undefined, update: (previous: T[], next: T) => T[], ): EventStreamState { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [events, setEvents] = useState([]) const [rawCount, setRawCount] = useState(0) const nextId = useRef(0) @@ -52,7 +52,7 @@ export function useEventStream( const updateRef = useRef(update) useEffect(() => { - if (!isReady || sdk === undefined) return + if (sdk === undefined) return const subscription = sdk.states.eventStream.subscribe((event: unknown) => { const id = `event-${nextId.current}` @@ -69,22 +69,22 @@ export function useEventStream( setRawCount(0) nextId.current = 0 } - }, [isReady, sdk]) + }, [sdk]) return { events, rawCount } } export function useFlagSubscription(flagName: string): unknown { - const { sdk, isReady } = useOptimizationContext() + const { sdk } = useOptimizationContext() const [value, setValue] = useState(undefined) useEffect(() => { - if (!sdk || !isReady) return + if (!sdk) return const subscription = sdk.states.flag(flagName).subscribe(setValue) return () => { subscription.unsubscribe() } - }, [isReady, sdk, flagName]) + }, [sdk, flagName]) return value } From 70aa495be9e171bea10e4c2f101aadcba1453992 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Fri, 3 Jul 2026 11:19:58 +0200 Subject: [PATCH 19/21] =?UTF-8?q?=F0=9F=90=9B=20fix(nextjs-sdk=5Fssr,nextj?= =?UTF-8?q?s-sdk=5Fhybrid):=20Resolve=20SSR=20variants=20regardless=20of?= =?UTF-8?q?=20consent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server helper short-circuited to `undefined` when the consent cookie was absent (`if (!getAppConsent(...)) return undefined`), so `serverOptimizationState` carried no data and every entry rendered as baseline in the first-paint HTML. The browser SDK, however, resolves the initial `page()` before consent by default (`allowedEventTypes` includes `page`), so after hydration the same entry became a variant — a visible baseline→variant mismatch. The Node SDK already defaults `allowedEventTypes` to `['identify','page']`, so the only thing suppressing server resolution was the app's own gate. Align the server to the client: always call the server helper and pass the real consent state (`{ events: consented, persistence: consented }`) instead of returning early. Consent gates event emission and profile persistence, not experience resolution, so first-paint SSR now matches the browser. Because the server always emits the initial page event now, the client always skips it (`initialPageEvent="skip"`), keeping a single page event per request. Add an SSR regression guard in the shared `ssr.spec.ts`: with JavaScript disabled, a known-optimized entry must render its variant (`data-ctfl-optimization-id`, non-baseline `data-ctfl-entry-id`, `data-ctfl-variant-index="1"`) for both a pre-consent new visitor and a consented visitor — the previous suite only asserted consent/identified status text, never the rendered variant. Note: this is a reference-app consent-policy alignment (resolve pre-consent, as the browser already does); the broader question of fine-grained consent control via `allowedEventTypes` is tracked separately. Verified: nextjs-sdk_ssr + nextjs-sdk_hybrid typecheck and lint clean; ssr.spec passes on chromium (5 passed, including both new variant assertions). Firefox/ WebKit are not installed locally (setup state), so those projects were not run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-sdk_hybrid/app/layout.tsx | 2 +- .../nextjs-sdk_hybrid/lib/optimization.ts | 9 +++-- implementations/nextjs-sdk_ssr/app/layout.tsx | 2 +- .../nextjs-sdk_ssr/lib/optimization.ts | 9 +++-- lib/e2e-web/e2e/ssr.spec.ts | 33 ++++++++++++++++++- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/implementations/nextjs-sdk_hybrid/app/layout.tsx b/implementations/nextjs-sdk_hybrid/app/layout.tsx index 61f35d738..39957da15 100644 --- a/implementations/nextjs-sdk_hybrid/app/layout.tsx +++ b/implementations/nextjs-sdk_hybrid/app/layout.tsx @@ -54,7 +54,7 @@ export default async function RootLayout({ - +