diff --git a/eslint.config.mjs b/eslint.config.mjs index f31a2e6..dafdd42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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: { diff --git a/package-lock.json b/package-lock.json index a0f80ea..8aa35b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2153,6 +2153,22 @@ "resolved": "examples/sveltekit", "link": true }, + "node_modules/@contentful/experiences-preview-core": { + "resolved": "packages/preview-core", + "link": true + }, + "node_modules/@contentful/experiences-preview-react": { + "resolved": "packages/preview-adapter-react", + "link": true + }, + "node_modules/@contentful/experiences-preview-svelte": { + "resolved": "packages/preview-adapter-svelte", + "link": true + }, + "node_modules/@contentful/experiences-preview-web": { + "resolved": "packages/preview-web", + "link": true + }, "node_modules/@contentful/experiences-react": { "resolved": "packages/adapter-react", "link": true @@ -13061,7 +13077,8 @@ "license": "MIT", "dependencies": { "@contentful/experiences-core": "*", - "@contentful/experiences-design": "*" + "@contentful/experiences-design": "*", + "@contentful/experiences-preview-react": "*" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -13079,7 +13096,8 @@ "license": "MIT", "dependencies": { "@contentful/experiences-core": "*", - "@contentful/experiences-design": "*" + "@contentful/experiences-design": "*", + "@contentful/experiences-preview-svelte": "*" }, "devDependencies": { "@sveltejs/package": "^2.3.0", @@ -13107,6 +13125,54 @@ "dependencies": { "@contentful/experiences-core": "*" } + }, + "packages/preview-adapter-react": { + "name": "@contentful/experiences-preview-react", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@contentful/experiences-core": "*", + "@contentful/experiences-preview-core": "*", + "@contentful/experiences-preview-web": "*" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "packages/preview-adapter-svelte": { + "name": "@contentful/experiences-preview-svelte", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@contentful/experiences-core": "*", + "@contentful/experiences-preview-core": "*", + "@contentful/experiences-preview-web": "*" + }, + "devDependencies": { + "@sveltejs/package": "^2.3.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "publint": "^0.2.0", + "svelte": "^5.0.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "packages/preview-core": { + "name": "@contentful/experiences-preview-core", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@contentful/experiences-core": "*" + } + }, + "packages/preview-web": { + "name": "@contentful/experiences-preview-web", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@contentful/experiences-preview-core": "*" + } } } } diff --git a/packages/adapter-react/package.json b/packages/adapter-react/package.json index 7b58185..0546418 100644 --- a/packages/adapter-react/package.json +++ b/packages/adapter-react/package.json @@ -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", diff --git a/packages/adapter-react/src/client-renderer.tsx b/packages/adapter-react/src/client-renderer.tsx index 97cd3f8..0101e88 100644 --- a/packages/adapter-react/src/client-renderer.tsx +++ b/packages/adapter-react/src/client-renderer.tsx @@ -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'; @@ -49,6 +64,37 @@ export interface ClientExperienceRendererProps { initialViewportId?: string; context?: Partial; 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; + + /** + * 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({ @@ -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 ( 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 ( + + ); + } + + return ; +} diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index 54a2e81..1aed571 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -8,16 +8,19 @@ * `@contentful/experiences-design` packages are workspace-only implementation * details; they are not part of the public API. * - * `ExperienceRenderer` is an alias for `ClientExperienceRenderer`; SSR - * consumers explicitly import `ServerExperienceRenderer`. + * Three renderers, one per intent: + * - `ServerExperienceRenderer` — RSC-only, static HTML, zero client-JS. + * - `ClientExperienceRenderer` — client-only; throws on the server. + * Use for tests, native shells, or apps that never SSR. + * - `ExperienceRenderer` — hybrid; SSR first paint + client hydration + + * `enablePreview`. Pick this for any route that needs both. */ // ─── Renderers ───────────────────────────────────────────────────────────── -export { - ClientExperienceRenderer as ExperienceRenderer, - ClientExperienceRenderer, -} from './client-renderer'; -export type { ClientExperienceRendererProps as ExperienceRendererProps } from './client-renderer'; +export { ExperienceRenderer } from './experience-renderer'; +export type { ExperienceRendererProps } from './experience-renderer'; + +export { ClientExperienceRenderer } from './client-renderer'; export type { ClientExperienceRendererProps } from './client-renderer'; export { ServerExperienceRenderer } from './server-renderer'; @@ -31,6 +34,39 @@ export type { UseActiveViewportResult } from './use-active-viewport'; export type { RenderUnknown } from './nodes-renderer'; +// ─── Preview (editor integration) ───────────────────────────────────────── +// The React preview adapter is a sibling package; everything below is +// re-exported so consumers of `@contentful/experiences-react` never need +// to install or import from the preview packages directly. +export { usePreviewOverride, useResolvedPreviewPlan } from '@contentful/experiences-preview-react'; +export type { + UsePreviewOverrideOptions, + UsePreviewOverrideResult, +} from '@contentful/experiences-preview-react'; + +// Advanced-use re-exports for customers building custom preview flows on +// top of the primitives (custom transports, non-renderer consumers). +export { + MESSAGE as PREVIEW_MESSAGE, + PROTOCOL_VERSION as PREVIEW_PROTOCOL_VERSION, + SOURCE as PREVIEW_SOURCE, + PreviewClient, + createPostMessageChannel, + isEnvelope as isPreviewEnvelope, + isMessage as isPreviewMessage, +} from '@contentful/experiences-preview-react'; +export type { + CreatePostMessageChannelOptions, + HandshakeStatus as PreviewHandshakeStatus, + HydratedView, + MessageHandler as PreviewMessageHandler, + PreviewCapabilities, + PreviewChannel, + PreviewClientOptions, + PreviewSnapshot, + RenderStatus as PreviewRenderStatus, +} from '@contentful/experiences-preview-react'; + // ─── Authoring helpers + Config types ───────────────────────────────────── export { defineComponent, defineTemplate } from './types'; export type { diff --git a/packages/adapter-react/src/server-renderer.tsx b/packages/adapter-react/src/server-renderer.tsx index c830c2f..d9ec6b5 100644 --- a/packages/adapter-react/src/server-renderer.tsx +++ b/packages/adapter-react/src/server-renderer.tsx @@ -1,12 +1,15 @@ /* - * Server-safe Experience renderer. Resolves the active viewport once from + * Server-only Experience renderer. Resolves the active viewport once from * `initialViewportId` (typically derived from User-Agent on the request) - * and renders without any reactive subscription. RSC-friendly. + * and renders without any reactive subscription. RSC-friendly; produces + * static HTML with zero client-JS overhead. * - * SSR + interactive editor mode are mutually exclusive — the message-event - * preview client requires window listeners and lives only in the client - * renderer. For editor mode, dynamically import the client variant behind - * a `"use client"` boundary. + * Pick this when the route has no client-side interactivity — pure + * marketing pages, blog posts, landing pages. For routes that also need + * `enablePreview` (or any other client-side behavior once hydrated), use + * `ExperienceRenderer` — it emits the same SSR HTML this component would + * for first paint, then hands off to `ClientExperienceRenderer` on + * hydration. */ import type { ReactNode } from 'react'; diff --git a/packages/adapter-svelte/package.json b/packages/adapter-svelte/package.json index b0187a4..4ac1fbf 100644 --- a/packages/adapter-svelte/package.json +++ b/packages/adapter-svelte/package.json @@ -36,7 +36,8 @@ }, "dependencies": { "@contentful/experiences-core": "*", - "@contentful/experiences-design": "*" + "@contentful/experiences-design": "*", + "@contentful/experiences-preview-svelte": "*" }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte index 3505aaa..3bfcc0a 100644 --- a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte @@ -1,15 +1,28 @@ -{#if experience && renderContext} - +{#if activeExperience && renderContext} + {#snippet children()} + + +{#if isHydrated} + +{:else} + +{/if} diff --git a/packages/adapter-svelte/src/ServerExperienceRenderer.svelte b/packages/adapter-svelte/src/ServerExperienceRenderer.svelte index 84f2270..5691b16 100644 --- a/packages/adapter-svelte/src/ServerExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ServerExperienceRenderer.svelte @@ -1,11 +1,15 @@