Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ export default [
Element: 'readonly',
MediaQueryList: 'readonly',
MediaQueryListEvent: 'readonly',
MessageEvent: 'readonly',
ErrorEvent: 'readonly',
PromiseRejectionEvent: 'readonly',
URL: 'readonly',
crypto: 'readonly',
globalThis: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
// Svelte 5 runes — read by the language server as compiler-time
// magic; the ESLint parser doesn't know about them.
$state: 'readonly',
$effect: 'readonly',
$derived: 'readonly',
$props: 'readonly',
},
},
plugins: {
Expand Down
70 changes: 68 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/adapter-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
},
"dependencies": {
"@contentful/experiences-core": "*",
"@contentful/experiences-design": "*"
"@contentful/experiences-design": "*",
"@contentful/experiences-preview-react": "*"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand Down
94 changes: 85 additions & 9 deletions packages/adapter-react/src/client-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
/*
* Client-side Experience renderer. Uses `useActiveViewport` to react to
* window.matchMedia changes.
* `window.matchMedia` changes; throws on the server so pair it with
* `ServerExperienceRenderer` for SSR, or use `ExperienceRenderer` for the
* hybrid case (SSR first paint + client hydration + preview wire).
*
* Throws if rendered on the server — pair with `ServerExperienceRenderer`
* for SSR. Use `initialViewportId` (typically derived from User-Agent on the
* Use `initialViewportId` (typically derived from User-Agent on the
* server) to seed the first render, matching what the server emitted; the
* hook then takes over via media queries to switch viewports as the window
* resizes.
* hook then takes over via media queries to switch viewports as the
* window resizes.
*
* When `enablePreview` is set, the renderer additionally connects to the
* parent editor via postMessage on mount. Before `init` arrives — or when
* the app is not embedded in a known editor origin — rendering falls back
* to the `experience` prop. Once `init` arrives, the editor-delivered
* plan is rendered instead and every subsequent `viewUpdate` from the
* editor replaces it in place. Safe to leave on in production: the
* preview SDK's `ancestorOrigins` check refuses to connect when no editor
* is above the iframe.
*/

'use client';

import type { ReactNode } from 'react';
import { useMemo, type ReactNode } from 'react';

import type {
ExperienceContext,
PortableRenderPlan,
ViewportDef,
} from '@contentful/experiences-core';
import type { PreviewCapabilities } from '@contentful/experiences-preview-react';
import {
usePreviewOverride,
useResolvedPreviewPlan,
} from '@contentful/experiences-preview-react';

import { MissingComponent } from './missing-component';
import { NodesRenderer, WrapWithTemplate, type RenderUnknown } from './nodes-renderer';
Expand Down Expand Up @@ -49,6 +64,37 @@ export interface ClientExperienceRendererProps {
initialViewportId?: string;
context?: Partial<ExperienceContext>;
renderUnknown?: RenderUnknown;

/**
* Opt in to Contentful editor preview.
*
* When enabled, the renderer connects to the parent editor via
* postMessage on mount. Before `init` arrives — or when the app is not
* embedded in a known editor origin — rendering falls back to the
* `experience` prop. Once `init` arrives, the editor's plan is rendered
* instead and every subsequent `viewUpdate` from the editor replaces it
* in place.
*
* Safe to leave on in production: the SDK checks `ancestorOrigins`
* against a hardcoded allow-list of Contentful editor origins and is a
* no-op when no match is found.
*/
enablePreview?: boolean;

/**
* Preview capabilities advertised to the editor. Defaults to fully
* reactive (`liveUpdate: true`). Set `liveUpdate: false` to opt out of
* `viewUpdate` — the editor will reload the iframe on save instead of
* pushing incremental updates. Ignored when `enablePreview` is false.
*/
previewCapabilities?: Partial<PreviewCapabilities>;

/**
* Optional origin override for the editor postMessage target. Accepts a
* single origin or an array. Useful for self-hosted proxies, staging
* setups, or tests. Ignored when `enablePreview` is false.
*/
previewTargetOrigin?: string | string[];
}

export function ClientExperienceRenderer({
Expand All @@ -57,16 +103,46 @@ export function ClientExperienceRenderer({
initialViewportId,
context,
renderUnknown = MissingComponent,
enablePreview = false,
previewCapabilities,
previewTargetOrigin,
}: ClientExperienceRendererProps): ReactNode {
if (typeof window === 'undefined') {
throw new Error(
'ClientExperienceRenderer cannot be used on the server. Use ServerExperienceRenderer for SSR.'
'ClientExperienceRenderer cannot be used on the server. Use ServerExperienceRenderer for SSR-only routes, or ExperienceRenderer for the hybrid case.'
);
}
if (!experience) return null;

// Set of component-type ids the customer registered — used by the
// preview hook to detect missing components in the incoming view and
// report `partial` render status back to the editor.
const knownComponentTypeIds = useMemo(
() => new Set(Object.keys(config.components)),
[config.components]
);

const preview = usePreviewOverride(
{
enabled: enablePreview,
capabilities: previewCapabilities,
targetOrigin: previewTargetOrigin,
},
enablePreview ? knownComponentTypeIds : null
);

// Convert the API-generic HydratedView from the wire into the renderer's
// internal `PortableRenderPlan` via the same `resolveExperience` path
// customers use for their fetched payloads.
const previewPlan = useResolvedPreviewPlan(preview.view, config);

// postMessage view wins once it arrives (and finishes resolving);
// otherwise the prop is authoritative.
const activeExperience = previewPlan ?? experience;
if (!activeExperience) return null;

return (
<ClientExperienceRendererInner
experience={experience}
experience={activeExperience}
config={config}
initialViewportId={initialViewportId}
context={context}
Expand Down
73 changes: 73 additions & 0 deletions packages/adapter-react/src/experience-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Hybrid Experience renderer — the one to pick for any route that needs
* both SSR (for first-paint HTML, SEO, LCP) and client-side interactivity
* (`enablePreview`, or any other feature exclusive to the client
* renderer).
*
* Behavior in one sentence: on the server, and on the very first client
* render (before hydration), emits exactly what `ServerExperienceRenderer`
* would; on every render after hydration, delegates to
* `ClientExperienceRenderer`. React's `useSyncExternalStore` with a
* `false` server snapshot gives us the two-phase behavior without a
* hydration mismatch.
*
* The three renderers form a clear split:
* - `ServerExperienceRenderer` — RSC-only. No hydration, no state.
* - `ClientExperienceRenderer` — client-only. Throws on the server.
* - `ExperienceRenderer` (this file) — universal. SSR first paint +
* client hydration + `enablePreview` in one component.
*
* Customers pick by intent: pure marketing routes take
* `ServerExperienceRenderer`; preview-capable and interactive routes take
* `ExperienceRenderer`; advanced client-only setups (tests, native
* shells) take `ClientExperienceRenderer`.
*/

'use client';

import { useSyncExternalStore, type ReactNode } from 'react';

import {
ClientExperienceRenderer,
type ClientExperienceRendererProps,
} from './client-renderer';
import { ServerExperienceRenderer } from './server-renderer';

// Stable references so `useSyncExternalStore` doesn't refire on every
// render. `NOOP_SUBSCRIBE` never invokes its listener; the store's value
// is derived purely from the server-vs-client distinction.
const NOOP_SUBSCRIBE = (): (() => void) => () => {};
const IS_HYDRATED_TRUE = (): boolean => true;
const IS_HYDRATED_FALSE = (): boolean => false;

export type ExperienceRendererProps = ClientExperienceRendererProps;

export function ExperienceRenderer(props: ExperienceRendererProps): ReactNode {
// Server snapshot returns `false`; first client render before hydration
// also returns `false` (matches SSR HTML); after hydration flips to
// `true` and re-renders as the client variant.
const isHydrated = useSyncExternalStore(
NOOP_SUBSCRIBE,
IS_HYDRATED_TRUE,
IS_HYDRATED_FALSE
);

if (!isHydrated) {
// Server-render and pre-hydration client-render take the same code
// path — byte-identical HTML on both sides means hydration doesn't
// warn. Preview-specific props are dropped here on purpose; the
// server renderer doesn't consume them and the client renderer picks
// them up on the very next render after hydration.
return (
<ServerExperienceRenderer
experience={props.experience}
config={props.config}
initialViewportId={props.initialViewportId}
context={props.context}
renderUnknown={props.renderUnknown}
/>
);
}

return <ClientExperienceRenderer {...props} />;
}
Loading
Loading