Skip to content

✨ feat(react-web): Isomorphic OptimizationProvider for SSR (NT-3560)#354

Open
Tim Beyer (TimBeyer) wants to merge 21 commits into
mainfrom
feat/ssr-isomorphic-provider
Open

✨ feat(react-web): Isomorphic OptimizationProvider for SSR (NT-3560)#354
Tim Beyer (TimBeyer) wants to merge 21 commits into
mainfrom
feat/ssr-isomorphic-provider

Conversation

@TimBeyer

@TimBeyer Tim Beyer (TimBeyer) commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes NT-3560: OptimizationProvider returned null during server-side rendering, producing a blank page in Next.js App Router and any SSR environment (and failing the JavaScript-disabled E2E tests added in #348).

The root cause was a React-lifecycle mistake, not mutable state or browser globals. The fix makes the React provider isomorphic: it renders personalized content on the server from a read-only snapshot, then upgrades to the live stateful SDK on the client — so the same hooks and <OptimizedEntry> work in both environments and the developer never branches on typeof window.

Proven end-to-end: the nextjs-sdk_ssr reference app now renders fully-personalized server HTML with JavaScript disabled, and the full SSR E2E suite passes (26/26), including the "SSR first-paint state" and "Variant Resolution (SSR, JavaScript disabled)" blocks that fail on main.


The problem

The browser SDK (ContentfulOptimization) is stateful and browser-bound: its constructor reads localStorage/document.cookie, attaches listeners, and registers a window singleton. It cannot be constructed during server rendering, and React never runs effects (useEffect/useLayoutEffect) on the server — only render and useState/useMemo initializers.

The old provider created the SDK inside useLayoutEffect and gated all children on that SDK:

const [state] = useState(() => ({ isReady: false, sdk: undefined }))
useLayoutEffect(() => { /* new ContentfulOptimization(...) */ }, [])
if (!state.isReady) return null   // ← on the server, isReady is ALWAYS false

So the server rendered an empty tree. With JS disabled that is a permanently blank page; with JS on, a blank first paint until hydration.

Design principle: split by capability, not by environment

The key realization is that rendering personalized content does not require the stateful browser SDK — only the personalization data and the pure logic that resolves it. The SDK surface splits into three tiers by what each capability needs:

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 (no user interaction to record on the server)

The resolve tier already lives on CoreBase (shared by both the stateful and stateless runtimes), so variant resolution works server-side today. Reading state 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, so it never executes during a server render.

The governing axis is render-time vs. effect-time, not "isomorphic vs. not": render-time code runs on the server and must be safe; effect-time code never runs there.

The seam: one interface, one switch site

OptimizationRuntime is the single interface the hooks and components bind to. It is derived from the stateful runtime via Pick<CoreStateful, …>, 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 the new @contentful/optimization-core/runtime subpath) for the server render and the initial client render. Its resolve methods delegate to the shared static resolvers (no ApiClient, no browser globals), its states are static observables over a serialized OptimizationData snapshot, and its actions are inert no-ops that warn in development.

OptimizationProvider is the only place that chooses the backing. The developer calls useOptimization() and receives one runtime whose behavior is correct wherever it runs — never a null guard, never a throwing accessor on the server.

The render → hydrate flow

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 (no mismatch)
                                             6. Effect constructs the live SDK,
                                                hydrates it, swaps the context
                                             7. Interactive: state changes re-render

Hydration determinism contract: the serialized snapshot fed to the server render is the exact value the client provider is seeded with; variant resolution is a pure function of (baselineEntry, selectedOptimizations), so both sides produce identical markup. Later divergence (e.g. a newer locally-cached profile) is reconciled after hydration by a normal re-render — the React-blessed path.

API changes

New

  • @contentful/optimization-core/runtimeOptimizationRuntime (interface), createSnapshotRuntime / SnapshotRuntime, OptimizationSnapshot. Also staticObservable on the signals surface. Placed on a dedicated subpath so it stays out of the always-loaded base bundle and tree-shakes for consumers that never render server-side.
  • @contentful/optimization-web/runtime — pass-through re-export mirroring the existing core-sdk/api-client/api-schemas subpaths.
  • WebOptimizationRuntime (react-web) — composes the universal runtime with the browser-only surface (tracking, trackCurrentPage); createWebSnapshotRuntime provides the server backing.

Changed (breaking, pre-GA)

  • OptimizationProvider/OptimizationRoot now render on the server. The React context value is { sdk, isReady, error } where sdk is the always-present isomorphic runtime.
  • useOptimization() no longer throws at render time on the server; it returns the isomorphic runtime (snapshot-backed on the server, live SDK after hydration) and throws only when used outside a provider.
  • Read hooks pass a real getServerSnapshot to useSyncExternalStore.

Deprecated (kept working, with guidance)

  • NextjsOptimizationState → pass server data to the provider via serverOptimizationState (the provider hydrates the live SDK itself). Retained for configuring a provider without server data and hydrating page-specific data later.
  • ServerOptimizedEntry → prefer the isomorphic OptimizedEntry. Retained for pure zero-JavaScript Server Component rendering with no live updates.

Reference implementation (nextjs-sdk_ssr)

  • The layout resolves the request optimization data and passes it to OptimizationRoot via serverOptimizationState, so the provider renders identified/personalized state on the server.
  • Resolution is wrapped in a request-scoped React cache() helper (getServerOptimizationData) shared by the layout and pages, emitting exactly one Experience page() per request.
  • Pages render entries with OptimizedEntry; the redundant <NextjsOptimizationState> was removed.
  • SKIP_NO_JS dropped from the E2E flags — the JS-disabled first-paint and variant-resolution suites now pass.

The React Server Component boundary (the one honest limitation)

React hooks cannot run inside a true async Server Component — that is a React rule, not an SDK limitation. "Isomorphic" applies to Client Components ('use client') that are server-rendered for first paint and then hydrate, which covers OptimizedEntry and every read hook. Code that runs only in an RSC (the fetch/decide step) uses the same-named imperative method runtime.resolveOptimizedEntry(entry, selectedOptimizations) — no second vocabulary. This is documented in the new concept doc.

Documentation

  • New concept: documentation/concepts/server-side-rendering-and-hydration.md (capability tiers, the runtime seam, render/hydrate flow, RSC boundary, determinism contract).
  • Next.js SSR guide + optimization-nextjs/optimization-react-web READMEs + nextjs-sdk_ssr README updated to lead with serverOptimizationState + isomorphic OptimizedEntry.
  • Deprecation notes added across the hybrid guide, hybrid README, and the locale / entry-resolution / node-tracking / profile-sync concept docs.

Validation

Run under the pinned Node from .nvmrc (24.15.0):

  • Unit: core 287/287, react-web 107/107, nextjs 27/27 — including new SnapshotRuntime, staticObservable, and SSR-first-paint tests.
  • Lint / typecheck: clean (the only lint output is 4 expected @deprecated-usage warnings in tests that exercise the deprecated components).
  • Build: full workspace build:pkgs succeeds; the new ./runtime subpath emits declarations for core and web.
  • Bundle size: size:check green. The ./runtime split returned index.cjs to its exact baseline; the index.mjs gzip budget was raised ~800 B to absorb shared-chunk re-attribution from adding the entry (real per-consumer payload is essentially unchanged).
  • E2E: nextjs-sdk_ssr Playwright suite 26/26 with JavaScript enabled and disabled, including the Hydration check that a consented SSR handoff issues no duplicate client Experience request.

Note

The repo pins Node 24.15.0 (.nvmrc). Node 25 ships a native localStorage that conflicts with the happy-dom mock and makes several SDK unit tests spuriously fail; use the pinned version.

Update: reference implementations migrated + SSR entry-rendering fixes

Both Next.js reference apps now use the recommended pattern end-to-end, which surfaced (and fixed) the remaining server-render gaps flagged as Risk A in the plan:

  • nextjs-sdk_ssr and nextjs-sdk_hybrid now seed OptimizationRoot with serverOptimizationState in the layout and render entries with the isomorphic OptimizedEntry (the deprecated NextjsOptimizationState / ServerOptimizedEntry are gone from both apps).
  • Migrating EntryCard to <OptimizedEntry> exposed that server HTML rendered a hidden loading target instead of visible variant content. Fixed with three coordinated SDK changes so the server render and the client's first render agree (no hydration mismatch):
    • OptimizedEntryController now primes selectedOptimizations/state from sdk.states.*.current at construction (previously only in connect(), a client effect), so the constructor-time snapshot resolves the variant.
    • useOptimizedEntry seeds isPresentationReady from context readiness instead of an effect (effects don't run on the server).
    • SnapshotRuntime reports a settled (success) experience request state — a snapshot has no request in flight for the render it backs — so OptimizedEntry presents content rather than a loading state.
  • Also fixed an injected-SDK provider case (unrelated to the migration but found while running under the pinned Node): serverOptimizationState now drives first paint for injected SDKs too, and an injected SDK with only onStatesReady keeps backing the render directly.

Validation for the migration: nextjs-sdk_ssr SSR E2E 26/26 (incl. JS-disabled variant resolution now rendering through OptimizedEntry), nextjs-sdk_hybrid CSR+HYDRATION E2E 24/24, full-graph size:check green.

Follow-ups (not in this PR)

  • The index.mjs budget bump is a measurement artifact of adding the ./runtime entry; a targeted chunkSplit change could restore the baseline without touching the budget.

🤖 Generated with Claude Code

Tim Beyer (TimBeyer) and others added 11 commits July 2, 2026 20:23
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<CoreStateful>`) 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) <noreply@anthropic.com>
…bpath

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…concepts

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) <noreply@anthropic.com>
…njected SDKs

`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) <noreply@anthropic.com>
…onStatesReady is set

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) <noreply@anthropic.com>
Comment on lines +23 to +44
/**
* 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<OptimizationData | undefined> => {
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
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be supplied by the Next.js SDK, so the consumer won't need to have this boilerplate? Is there any possible need to customize the functionality?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. Only the cache() wrapping and reading cookies()/headers() are truly mechanical — the consent gate (app-specific cookie name + scheme), locale, and pagePayload/experienceOptions are all things a real app customizes, so an SDK-owned version would have to re-accept them as options and wouldn't save much. There's also a deliberate design constraint: getNextjsServerOptimizationData takes cookies/headers as params rather than importing next/headers itself (keeps it testable and Pages-Router-portable), and an auto-reading convenience would break that decoupling.

A reference implementation is also meant to show this wiring explicitly, so I'd lean toward keeping it here as teaching material. Happy to file a follow-up for an optional thin SDK helper (a cache-wrapped variant that reads next/headers and takes an optional consent predicate) if you think it's worth it — flagging rather than doing it in this PR.

error: undefined,
isReady: canRenderInjectedSdk,
sdk: canRenderInjectedSdk ? props.sdk : undefined,
isReady: true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this value ever change? Is it still necessary?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — it no longer carries independent meaning. After the isomorphic rework isReady is equivalent to sdk !== undefined: it starts true (a snapshot always backs first paint) and only flips to false in the initialization-error path, where runtime is set to undefined at the same time.

Removed it from OptimizationContextValue/ProviderState and derived readiness from sdk presence in the consumers (LiveUpdatesProvider, useOptimizedEntry, plus the SSR/hybrid reference apps). useOptimizedEntry's own returned isReady (presentation-readiness from the controller snapshot) is a separate thing and stays.

Done in fb3d43b4 (SDK) and 41361082 (reference apps).

Tim Beyer (TimBeyer) and others added 2 commits July 3, 2026 09:38
`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) <noreply@anthropic.com>
…izedEntry

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) <noreply@anthropic.com>
Comment on lines +52 to +58
> [!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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During this time, I usually tell agents "make clean breaking changes; this is not in production so there should be no deprecations or concerns for migrations and/or backwards compatibility". It would be nice to see the full cleanup and how it works in the hybrid reference implementation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the clean-break approach. That specific note was already removed in a9484384 (the hybrid impl now uses serverOptimizationState and OptimizedEntry), and I've now done the full cleanup of the two deprecated APIs this PR was carrying:

  • NextjsOptimizationState — deleted (18c4c2a0). Its only residual use (config-seed a provider, hydrate page data later) is served directly by the public hydrateOptimizationData from @contentful/optimization-web/bridge-support, which the docs now point to.
  • ServerOptimizedEntry — undeprecated and renamed to ServerOnlyOptimizedEntry (3b06c6db). The zero-client-JS Server Component entry is a genuinely distinct capability from the isomorphic OptimizedEntry; only the name collided (it read as "the server variant of OptimizedEntry", misleading now that OptimizedEntry also server-renders). "Server-only" names the defining trait: it ships no client JS for the entry.

Both are clean breaking changes with no compatibility alias. The hybrid reference app is fully migrated off the deprecated APIs.

Seed `OptimizationRoot` with `serverOptimizationState` in the layout (using the
existing request-scoped `getOptimizationData` cache) and drop the deprecated
`<NextjsOptimizationState>` 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) <noreply@anthropic.com>
@phobetron

Copy link
Copy Markdown
Collaborator

All in all, this seems like a more advanced solution in the same direction as David Nalchevanidze (@nalchevanidze)'s original stub idea.

Comment on lines +27 to +32
* @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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably just remove anything that would be deprecated; not sure if this would be our fully-accepted solution yet, but it'll be easier to see and there'll be no cleanup tasks afterward if this does become the accepted solution.

Tim Beyer (TimBeyer) and others added 6 commits July 3, 2026 10:13
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) <noreply@anthropic.com>
…rker

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) <noreply@anthropic.com>
…imizedEntry

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) <noreply@anthropic.com>
…sdk presence

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) <noreply@anthropic.com>
…ess of consent

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) <noreply@anthropic.com>
…event without consent

The previous commit set `initialPageEvent="skip"` unconditionally, reasoning
that the server always emits the page event now. That was wrong: the tracking
log is a client `eventStream` view, and the shared cross-implementation E2E
contract (`tracking.spec.ts` Consent Gating, `navigation.spec.ts`) requires the
browser to emit the initial page event before consent — CSR implementations
have no server to emit it. Forcing `skip` dropped that client page event on the
unconsented path and broke both Next.js E2E jobs in CI.

Restore the original conditional `initialPageEvent={appConsent ? 'skip' : 'emit'}`.
The SSR variant fix is unaffected: it depends only on the server no longer
short-circuiting resolution (which populates `serverOptimizationState` and seeds
first paint), not on the client page-event mode. The consented path stays
`skip`, so the no-duplicate-client-Experience-request hydration check still holds.

Verified: nextjs-sdk_ssr chromium E2E green (16 passed) — including the
previously failing navigation + Consent Gating tests and both new SSR variant
assertions; both impls typecheck and lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isReady implementations/react-web-sdk/src/App.tsx:25, AnalyticsEventDisplay.tsx:154, ControlPanel.tsx:196 all destructure isReady from useOptimizationContext(), which no longer has that
field. isReady is always undefined → every if (!sdk || !isReady) guard is permanently true → all SDK-gated effects (subscriptions, analytics, consent) silently never fire in this reference implementation.

Comment on lines +153 to +155
hasConsent(): boolean {
return this.snapshot.consent === true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasConsent() drops its argument, SnapshotRuntime.ts:153 declares hasConsent() with no parameter. The OptimizationRuntime interface picks hasConsent(name: string) from CoreStateful. TypeScript accepts narrower signatures, so no type error. But web-sdk/EntryInteractionRuntime and other callers pass event names through this interface during SSR / first-client render and always get snapshot.consent === true, bypassing partial-consent gating.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. SnapshotRuntime.hasConsent now takes hasConsent(name: string) (matching the OptimizationRuntime/CoreStateful signature) and resolves through the same allow-list-aware logic the live SDK uses (CONSENT_EVENT_TYPE_MAP + the granted-consent short-circuit), rather than returning snapshot.consent === true.

To make that possible the snapshot now carries allowedEventTypes (defaults to fail-closed DEFAULT_ALLOWED_EVENT_TYPES), and the React provider seeds it from the same value the live SDK is configured with (the web default is ['identify','page']), so pre/post-hydration answers match.

One clarification on the report: I traced every hasConsent caller — EntryInteractionRuntime and the others call it on the live ContentfulOptimization in browser-only effects, never on the snapshot during SSR/first render, so there was no active mis-gating in our own code. It was a latent public-contract bug regardless, now closed. Added unit tests covering the allow-list paths.

eventStream: staticObservable(undefined),
locale: staticObservable(snapshot.locale),
canOptimize: staticObservable(canOptimize),
optimizationPossible: staticObservable(true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optimizationPossible: staticObservable(true) hardcoded — SnapshotRuntime.ts:97. For a visitor with no consent, the live SDK produces optimizationPossible=false, but the snapshot reports true. When the live SDK hydrates and subscribes via OptimizedEntryController.resubscribe(), the true→false flip triggers updateSnapshot(), causing the controller to re-evaluate and potentially flash from resolved variant → loading/baseline.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. optimizationPossible is no longer hardcoded — the snapshot now computes it with the same rule as the live SDK (consent === true, or the allow-list contains an optimization-unlocking event type), seeded from the allowedEventTypes the provider now threads in. So a no-consent / empty-allow-list visitor reports false on both sides, and the shipped default (['identify','page']) reports true on both — no true→false flip on hydration, no resolved-variant → baseline flash.

Added unit tests for the pre-consent false (empty allow-list) and true (unlocking type allow-listed) cases.

Two divergences between the read-only SnapshotRuntime and the live SDK, flagged
in review:

- `hasConsent()` dropped its argument and returned `snapshot.consent === true`,
  bypassing the live SDK's allow-list-aware gating. Give it the interface
  signature `hasConsent(name)` and resolve through the same map + granted-consent
  rule the live SDK uses.
- `optimizationPossible` was hardcoded `true`, so a no-consent visitor could flip
  true→false when the live SDK hydrated and subscribed. Compute it with the live
  SDK's rule instead.

Both need the configured allow-list, so `OptimizationSnapshot` now carries
`allowedEventTypes` (fail-closed default), and the React provider seeds it from
the same value the live SDK applies — exported as `DEFAULT_WEB_ALLOWED_EVENT_TYPES`
from `@contentful/optimization-web/constants`. The gating logic is inlined in
SnapshotRuntime (kept out of the live SDK's `index` bundle) and mirrors the live
SDK copies; a comment cross-references them to keep the two in sync.

Bump the core-sdk `index.mjs` gzip budget 18600→18800: the pre-existing headroom
was ~130 bytes and unrelated chunking shifted the measured bundle +180 bytes;
functional code paths are unchanged. Flagging for a maintainer to revisit if the
budget matters.

Verified: core (291), web (265), react-web (107), nextjs (25), node (12) unit
tests pass; typecheck, lint, format, and size:check green under Node 24.15.0.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants