From f3102c8258cfc2b9c99459ee2610c43210d908b0 Mon Sep 17 00:00:00 2001 From: Thomas Kellermeier Date: Tue, 7 Jul 2026 23:43:24 +0200 Subject: [PATCH 1/5] feat(preview): add editor preview SDK behind enablePreview prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the postMessage-based editor↔preview protocol as an internal module of experiences-core, plus an opt-in usePreviewOverride hook and matching enablePreview / previewCapabilities / previewTargetOrigin props on ClientExperienceRenderer. Ships as part of the existing packages — no separate npm artefact. The flag is a no-op in production because origin resolution is gated on ancestorOrigins matching a Contentful editor allow-list, so leaving it on outside the editor costs nothing. Co-Authored-By: Claude Opus 4.7 --- eslint.config.mjs | 4 + .../adapter-react/src/client-renderer.tsx | 70 +++++- packages/adapter-react/src/index.ts | 29 +++ .../adapter-react/src/use-preview-override.ts | 205 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/preview/channel.ts | 20 ++ packages/core/src/preview/client.ts | 187 ++++++++++++++++ packages/core/src/preview/index.ts | 43 ++++ .../core/src/preview/postMessageChannel.ts | 139 ++++++++++++ packages/core/src/preview/protocol.ts | 145 +++++++++++++ 10 files changed, 840 insertions(+), 3 deletions(-) create mode 100644 packages/adapter-react/src/use-preview-override.ts create mode 100644 packages/core/src/preview/channel.ts create mode 100644 packages/core/src/preview/client.ts create mode 100644 packages/core/src/preview/index.ts create mode 100644 packages/core/src/preview/postMessageChannel.ts create mode 100644 packages/core/src/preview/protocol.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index f31a2e6..7dd3e43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,10 @@ export default [ Element: 'readonly', MediaQueryList: 'readonly', MediaQueryListEvent: 'readonly', + MessageEvent: 'readonly', + ErrorEvent: 'readonly', + PromiseRejectionEvent: 'readonly', + URL: 'readonly', crypto: 'readonly', globalThis: 'readonly', setTimeout: 'readonly', diff --git a/packages/adapter-react/src/client-renderer.tsx b/packages/adapter-react/src/client-renderer.tsx index 97cd3f8..f8c4d58 100644 --- a/packages/adapter-react/src/client-renderer.tsx +++ b/packages/adapter-react/src/client-renderer.tsx @@ -7,15 +7,24 @@ * 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. + * + * When `enablePreview` is set, the renderer also runs the preview client + * against the parent editor. Until `init` arrives (or forever, when the app + * is not embedded in an editor), it renders the `experience` prop as usual. + * Once `init` arrives, the editor-delivered plan takes precedence and any + * subsequent `viewUpdate` messages replace it. The flag is safe to leave on + * in production: no matching editor parent → no override arrives → identical + * behavior to today. */ 'use client'; -import type { ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import type { ExperienceContext, PortableRenderPlan, + PreviewCapabilities, ViewportDef, } from '@contentful/experiences-core'; @@ -23,6 +32,7 @@ import { MissingComponent } from './missing-component'; import { NodesRenderer, WrapWithTemplate, type RenderUnknown } from './nodes-renderer'; import type { Config, RenderContext } from './types'; import { useActiveViewport } from './use-active-viewport'; +import { usePreviewOverride } from './use-preview-override'; const DEFAULT_CONTEXT: ExperienceContext = { isPreview: false, @@ -49,6 +59,36 @@ 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 +97,40 @@ 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.' ); } - 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 + ); + + // postMessage view wins once it arrives; otherwise the prop is authoritative. + const activeExperience = preview.view ?? experience; + if (!activeExperience) return null; + return ( ; + + // Optional feature list to negotiate against the editor. + supportedFeatures?: string[]; + + // Origin override — pass when the editor is not on a default allow-listed + // origin (self-hosted proxies, staging setups, tests). + targetOrigin?: CreatePostMessageChannelOptions['targetOrigin']; +} + +export interface UsePreviewOverrideResult { + // The view to render, or `undefined` when preview is off / not yet + // received. Callers fall back to their own `experience` prop. + view: PortableRenderPlan | undefined; + + handshakeStatus: PreviewSnapshot['handshakeStatus']; + renderStatus: RenderStatus; + missingComponents: string[]; + isReactive: boolean; + sessionId: string | undefined; +} + +const DEFAULT_CAPABILITIES: PreviewCapabilities = { + liveUpdate: true, + alreadyRendered: false, + nodeGeometry: false, +}; + +const DEFAULT_SUPPORTED_FEATURES = ['viewUpdate']; + +const INERT_SNAPSHOT: PreviewSnapshot = { + handshakeStatus: 'idle', + renderStatus: 'pending', + sessionId: undefined, + view: undefined, + context: undefined, + missingComponents: [], + error: undefined, +}; + +export function usePreviewOverride( + options: UsePreviewOverrideOptions, + // Optional set of component-type ids the caller knows how to render. + // When provided, the hook reports `partial` with the missing ids so the + // editor can surface them; when omitted, the hook reports `ok`. + knownComponentTypeIds?: ReadonlySet | null +): UsePreviewOverrideResult { + const { enabled } = options; + + // Keep option inputs in refs so callers passing inline objects don't + // tear the client down on every render. + const capabilitiesRef = useRef({ + ...DEFAULT_CAPABILITIES, + ...(options.capabilities ?? {}), + }); + const supportedFeaturesRef = useRef( + options.supportedFeatures ?? DEFAULT_SUPPORTED_FEATURES + ); + const targetOriginRef = useRef(options.targetOrigin); + targetOriginRef.current = options.targetOrigin; + + const clientRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + const channel = createPostMessageChannel({ + targetOrigin: targetOriginRef.current, + }); + if (!channel) { + // Not embedded in a recognised editor. Renderer falls back to prop. + return; + } + + const client = new PreviewClient({ + channel, + supportedFeatures: supportedFeaturesRef.current, + capabilities: capabilitiesRef.current, + }); + clientRef.current = client; + client.start(); + + const onError = (event: ErrorEvent) => { + client.reportError({ message: event.message, stack: event.error?.stack }); + }; + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + const message = + reason instanceof Error ? reason.message : String(reason ?? 'unhandledrejection'); + const stack = reason instanceof Error ? reason.stack : undefined; + client.reportError({ message, stack }); + }; + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onUnhandledRejection); + + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onUnhandledRejection); + client.close(); + clientRef.current = null; + }; + }, [enabled]); + + const snapshot = useSyncExternalStore( + (listener) => { + if (!clientRef.current) return () => {}; + return clientRef.current.subscribe(listener); + }, + () => clientRef.current?.getSnapshot() ?? INERT_SNAPSHOT, + () => INERT_SNAPSHOT + ); + + // After each init/viewUpdate, walk the view and report render status + // by comparing component-type ids against the known set. + useEffect(() => { + if (snapshot.handshakeStatus !== 'initialized') return; + if (!snapshot.view) return; + if (snapshot.renderStatus !== 'pending') return; + + if (!knownComponentTypeIds) { + clientRef.current?.reportRendered({ status: 'ok', missingComponents: [] }); + return; + } + + const missing = new Set(); + collectMissing(snapshot.view.nodes, knownComponentTypeIds, missing); + + if (missing.size === 0) { + clientRef.current?.reportRendered({ status: 'ok', missingComponents: [] }); + } else { + clientRef.current?.reportRendered({ + status: 'partial', + missingComponents: Array.from(missing), + }); + } + }, [ + snapshot.handshakeStatus, + snapshot.view, + snapshot.renderStatus, + knownComponentTypeIds, + ]); + + return { + view: snapshot.view, + handshakeStatus: snapshot.handshakeStatus, + renderStatus: snapshot.renderStatus, + missingComponents: snapshot.missingComponents, + isReactive: capabilitiesRef.current.liveUpdate, + sessionId: snapshot.sessionId, + }; +} + +function collectMissing( + nodes: PortableRenderNode[] | undefined, + known: ReadonlySet, + out: Set +): void { + if (!nodes) return; + for (const node of nodes) { + const id = node.registration.componentTypeId; + if (!known.has(id)) out.add(id); + if (node.slots) { + for (const children of Object.values(node.slots)) { + collectMissing(children, known, out); + } + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 561d0d8..b7e8410 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from './types'; export { resolveExperience } from './resolve-experience'; export type { ResolverConfig, ResolveExperienceOptions } from './resolve-experience'; +export * from './preview'; diff --git a/packages/core/src/preview/channel.ts b/packages/core/src/preview/channel.ts new file mode 100644 index 0000000..35f0a1f --- /dev/null +++ b/packages/core/src/preview/channel.ts @@ -0,0 +1,20 @@ +/* + * Transport abstraction. Any duplex message pipe can implement this + * interface — the client and adapter layers above never touch the wire + * directly. The postMessage variant ships alongside; WebSocket / WebRTC + * variants would sit next to it under this module. + */ + +import type { AnyMessage, MessageType } from './protocol'; + +export type MessageHandler = (msg: M) => void; + +export interface PreviewChannel { + send(msg: AnyMessage): void; + + // Register a handler for messages of the given type. Returns an + // unsubscribe function. + on(type: T, handler: MessageHandler): () => void; + + close(): void; +} diff --git a/packages/core/src/preview/client.ts b/packages/core/src/preview/client.ts new file mode 100644 index 0000000..2aae421 --- /dev/null +++ b/packages/core/src/preview/client.ts @@ -0,0 +1,187 @@ +/* + * PreviewClient — state machine that runs on the preview (guest) side. + * Owns the `ready → init → rendered` handshake, sessionId adoption, and a + * snapshot subscription API suitable for React's useSyncExternalStore or + * an equivalent adapter in any other framework. + */ + +import type { PortableRenderPlan } from '../types'; +import type { PreviewChannel } from './channel'; +import { + MESSAGE, + PROTOCOL_VERSION, + SOURCE, + isMessage, + type InitContext, + type InitMessage, + type PreviewCapabilities, + type RenderedPayload, + type ViewUpdateMessage, +} from './protocol'; + +export type HandshakeStatus = + | 'idle' + | 'ready-sent' + | 'initialized' + | 'closed'; + +export type RenderStatus = RenderedPayload['status'] | 'pending'; + +export interface PreviewSnapshot { + handshakeStatus: HandshakeStatus; + renderStatus: RenderStatus; + sessionId: string | undefined; + view: PortableRenderPlan | undefined; + context: InitContext | undefined; + missingComponents: string[]; + error: { message: string; stack?: string } | undefined; +} + +export interface PreviewClientOptions { + channel: PreviewChannel; + supportedFeatures: string[]; + capabilities: PreviewCapabilities; +} + +export type PreviewClientListener = () => void; + +const EMPTY_SNAPSHOT: PreviewSnapshot = { + handshakeStatus: 'idle', + renderStatus: 'pending', + sessionId: undefined, + view: undefined, + context: undefined, + missingComponents: [], + error: undefined, +}; + +export class PreviewClient { + private readonly channel: PreviewChannel; + private readonly supportedFeatures: string[]; + private readonly capabilities: PreviewCapabilities; + + private snapshot: PreviewSnapshot = EMPTY_SNAPSHOT; + private readonly listeners = new Set(); + private readonly cleanups: Array<() => void> = []; + + constructor({ channel, supportedFeatures, capabilities }: PreviewClientOptions) { + this.channel = channel; + this.supportedFeatures = supportedFeatures; + this.capabilities = capabilities; + } + + getSnapshot = (): PreviewSnapshot => this.snapshot; + + subscribe = (listener: PreviewClientListener): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + start(): void { + // Idempotent: repeated calls are a no-op after the first. + if (this.snapshot.handshakeStatus !== 'idle') return; + + // Register listeners before sending `ready`. A fast editor can reply + // before addEventListener returns; race would drop `init`. + this.cleanups.push( + this.channel.on(MESSAGE.init, (msg) => this.handleInit(msg as InitMessage)) + ); + this.cleanups.push( + this.channel.on(MESSAGE.viewUpdate, (msg) => + this.handleViewUpdate(msg as ViewUpdateMessage) + ) + ); + + this.channel.send({ + type: MESSAGE.ready, + source: SOURCE.preview, + protocolVersion: PROTOCOL_VERSION, + payload: { + supportedFeatures: this.supportedFeatures, + capabilities: this.capabilities, + }, + }); + + this.update({ handshakeStatus: 'ready-sent' }); + } + + close(): void { + for (const cleanup of this.cleanups) cleanup(); + this.cleanups.length = 0; + this.channel.close(); + this.update({ handshakeStatus: 'closed' }); + } + + reportRendered(payload: { + status: RenderStatus; + missingComponents?: string[]; + error?: { message: string; stack?: string }; + }): void { + if (this.snapshot.handshakeStatus !== 'initialized') return; + if (!this.snapshot.sessionId) return; + if (payload.status === 'pending') return; + + this.channel.send({ + type: MESSAGE.rendered, + source: SOURCE.preview, + protocolVersion: PROTOCOL_VERSION, + sessionId: this.snapshot.sessionId, + payload: { + status: payload.status, + missingComponents: payload.missingComponents ?? [], + error: payload.error, + }, + }); + + this.update({ + renderStatus: payload.status, + missingComponents: payload.missingComponents ?? [], + error: payload.error, + }); + } + + reportError(error: { message: string; stack?: string }): void { + if (!this.snapshot.sessionId) return; + this.channel.send({ + type: MESSAGE.error, + source: SOURCE.preview, + protocolVersion: PROTOCOL_VERSION, + sessionId: this.snapshot.sessionId, + payload: { error }, + }); + this.update({ error, renderStatus: 'error' }); + } + + private handleInit(msg: InitMessage): void { + if (!isMessage(msg, MESSAGE.init)) return; + if (this.snapshot.handshakeStatus !== 'ready-sent') return; + if (!msg.sessionId) return; + + this.update({ + handshakeStatus: 'initialized', + sessionId: msg.sessionId, + view: msg.payload.initialView, + context: msg.payload.context, + }); + } + + private handleViewUpdate(msg: ViewUpdateMessage): void { + if (!isMessage(msg, MESSAGE.viewUpdate)) return; + if (this.snapshot.handshakeStatus !== 'initialized') return; + if (msg.sessionId !== this.snapshot.sessionId) return; + + this.update({ + view: msg.payload.view, + renderStatus: 'pending', + missingComponents: [], + error: undefined, + }); + } + + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) listener(); + } +} diff --git a/packages/core/src/preview/index.ts b/packages/core/src/preview/index.ts new file mode 100644 index 0000000..efacca6 --- /dev/null +++ b/packages/core/src/preview/index.ts @@ -0,0 +1,43 @@ +export { + MESSAGE, + PROTOCOL_VERSION, + SOURCE, + isEnvelope, + isMessage, +} from './protocol'; +export type { + AnyMessage, + EditorMessage, + Envelope, + ErrorMessage, + ErrorPayload, + GeometryMessage, + GeometryPayload, + InitContext, + InitMessage, + InitPayload, + MessageType, + PreviewCapabilities, + PreviewMessage, + ReadyMessage, + ReadyPayload, + RenderedMessage, + RenderedPayload, + Source, + ViewUpdateMessage, + ViewUpdatePayload, +} from './protocol'; + +export type { MessageHandler, PreviewChannel } from './channel'; + +export { PreviewClient } from './client'; +export type { + HandshakeStatus, + PreviewClientListener, + PreviewClientOptions, + PreviewSnapshot, + RenderStatus, +} from './client'; + +export { createPostMessageChannel } from './postMessageChannel'; +export type { CreatePostMessageChannelOptions } from './postMessageChannel'; diff --git a/packages/core/src/preview/postMessageChannel.ts b/packages/core/src/preview/postMessageChannel.ts new file mode 100644 index 0000000..b2d1c8f --- /dev/null +++ b/packages/core/src/preview/postMessageChannel.ts @@ -0,0 +1,139 @@ +/* + * postMessage transport for the preview side (inside the iframe). + * + * Origin resolution follows `@contentful/live-preview` conventions: + * 1. Resolve editor origin from window.location.ancestorOrigins + * (fallback: document.referrer for Firefox). + * 2. Match against a hardcoded allow-list — production + localhost. + * 3. Nothing matches and no `targetOrigin` override supplied → return + * `undefined` from the factory; the caller decides whether to throw + * (dev) or silently skip (production without editor). + * + * Receive-side filtering is by payload `source` token, not by + * `event.origin` — matches Live Preview's convention and lets a browser + * extension or test harness inject messages without spoofing the origin. + */ + +import type { MessageHandler, PreviewChannel } from './channel'; +import { SOURCE, isEnvelope, type AnyMessage, type MessageType } from './protocol'; + +const LOCALHOST_PREFIX = 'http://localhost:'; + +// Default allow-list: production + a localhost wildcard so dev servers on +// any port work without an explicit override. Staging is excluded on +// purpose — apps embedding against staging must supply `targetOrigin`. +const DEFAULT_ALLOWED_ORIGINS = [ + 'https://app.contentful.com', + 'https://app.eu.contentful.com', + `${LOCALHOST_PREFIX}*`, +] as const; + +export interface CreatePostMessageChannelOptions { + // Override the default editor-origin allow-list. Accepts one or many. + // Useful for tests, self-hosted proxies, or unusual embedding setups. + targetOrigin?: string | string[]; +} + +export function createPostMessageChannel( + options: CreatePostMessageChannelOptions = {} +): PreviewChannel | undefined { + if (typeof window === 'undefined') return undefined; + + const allowedOrigins = normalizeOrigins(options.targetOrigin); + const resolvedTargetOrigin = resolveTargetOrigin(allowedOrigins); + if (!resolvedTargetOrigin) return undefined; + + const parentWindow = window.parent; + if (!parentWindow || parentWindow === window) return undefined; + + const handlers = new Map>(); + + const listener = (event: MessageEvent) => { + const data = event.data; + if (!isEnvelope(data)) return; + // We are the preview app; only accept editor-source messages. + if (data.source !== SOURCE.editor) return; + + const forType = handlers.get(data.type as MessageType); + if (!forType) return; + + for (const handler of forType) { + try { + handler(data as AnyMessage); + } catch (err) { + // A misbehaving handler must not tear down the transport. + console.error('[experiences preview] handler threw:', err); + } + } + }; + + window.addEventListener('message', listener); + + return { + send(msg) { + parentWindow.postMessage(msg, resolvedTargetOrigin); + }, + on(type, handler) { + let set = handlers.get(type); + if (!set) { + set = new Set(); + handlers.set(type, set); + } + set.add(handler); + return () => { + set!.delete(handler); + if (set!.size === 0) handlers.delete(type); + }; + }, + close() { + window.removeEventListener('message', listener); + handlers.clear(); + }, + }; +} + +function normalizeOrigins(override: string | string[] | undefined): string[] { + if (override) return Array.isArray(override) ? override : [override]; + return [...DEFAULT_ALLOWED_ORIGINS]; +} + +function resolveTargetOrigin(allowedOrigins: string[]): string | undefined { + const ancestorOrigins: readonly string[] | undefined = + typeof window !== 'undefined' && + typeof window.location !== 'undefined' && + 'ancestorOrigins' in window.location + ? Array.from(window.location.ancestorOrigins) + : undefined; + + if (ancestorOrigins) { + for (const origin of ancestorOrigins) { + if (isAllowed(origin, allowedOrigins)) return origin; + } + } + + // Firefox fallback: derive an origin from document.referrer. + const referrer = typeof document !== 'undefined' ? document.referrer : ''; + if (referrer) { + try { + const referrerOrigin = new URL(referrer).origin; + if (isAllowed(referrerOrigin, allowedOrigins)) return referrerOrigin; + } catch { + // fall through + } + } + + return undefined; +} + +function isAllowed(origin: string, allowedOrigins: string[]): boolean { + for (const allowed of allowedOrigins) { + if (allowed === origin) return true; + if ( + (allowed === `${LOCALHOST_PREFIX}*` || allowed === 'http://localhost') && + origin.startsWith(LOCALHOST_PREFIX) + ) { + return true; + } + } + return false; +} diff --git a/packages/core/src/preview/protocol.ts b/packages/core/src/preview/protocol.ts new file mode 100644 index 0000000..ef046ab --- /dev/null +++ b/packages/core/src/preview/protocol.ts @@ -0,0 +1,145 @@ +/* + * Preview protocol — the wire between an ExO editor (host) and a + * customer app running inside its iframe (guest). + * + * The protocol is transport-independent — postMessage is what ships first, + * but the same message names, payloads, and handshake apply over a + * WebSocket, WebRTC data channel, or any other duplex pipe. The transport + * is isolated in the `PreviewChannel` interface. + * + * The `view` on the wire is a `PortableRenderPlan` — the same interpreted + * shape a renderer consumes today. Editor and renderer agree on this type + * directly; no separate "hydrated view" indirection. + */ + +import type { PortableRenderPlan } from '../types'; + +// -- Protocol constants -------------------------------------------------- + +export const PROTOCOL_VERSION = 1; + +export const SOURCE = { + editor: 'experiences:editor', + preview: 'experiences:preview', +} as const; +export type Source = (typeof SOURCE)[keyof typeof SOURCE]; + +export const MESSAGE = { + ready: 'experiences:preview:ready', + init: 'experiences:editor:init', + rendered: 'experiences:preview:rendered', + viewUpdate: 'experiences:editor:viewUpdate', + error: 'experiences:preview:error', + geometry: 'experiences:preview:geometry', +} as const; +export type MessageType = (typeof MESSAGE)[keyof typeof MESSAGE]; + +// -- Common envelope ----------------------------------------------------- + +// Every message carries `sessionId` except the first `ready` — the editor +// mints the sessionId in response and delivers it in `init`. +export interface Envelope { + type: T; + source: Source; + protocolVersion: typeof PROTOCOL_VERSION; + sessionId?: string; + payload: P; +} + +// -- Capabilities -------------------------------------------------------- + +export interface PreviewCapabilities { + // Preview app hydrates on the client and can consume `viewUpdate` to + // re-render in place. When false, the editor treats the preview as + // static and reloads the iframe on save. + liveUpdate: boolean; + + // Preview has already painted its own content (SSR/RSC/static HTML). + // Reserved for a later epic driving optional initialView. + alreadyRendered: boolean; + + // Preview can emit `experiences:preview:geometry` messages. + nodeGeometry: boolean; +} + +// -- Payloads ------------------------------------------------------------ + +export interface ReadyPayload { + supportedFeatures: string[]; + capabilities: PreviewCapabilities; +} + +export interface InitContext { + entity: { + sys: { + type: 'ComponentType' | 'Template'; + id: string; + space: { sys: { type: 'Link'; linkType: 'Space'; id: string } }; + environment: { sys: { type: 'Link'; linkType: 'Environment'; id: string } }; + }; + }; +} + +export interface InitPayload { + supportedFeatures: string[]; + context: InitContext; + initialView: PortableRenderPlan; +} + +export interface RenderedPayload { + status: 'ok' | 'partial' | 'error'; + missingComponents: string[]; + error?: { message: string; stack?: string }; +} + +export interface ViewUpdatePayload { + view: PortableRenderPlan; +} + +export interface ErrorPayload { + error: { message: string; stack?: string }; +} + +// Geometry — reserved. Shape mirrors Studio's canvasGeometryUpdated event. +export interface GeometryPayload { + size: { width: number; height: number }; + nodes: Record< + string, + { coordinates: { x: number; y: number; width: number; height: number } } + >; + sourceEvent: 'resize' | 'mutation' | 'mediaResize'; +} + +// -- Concrete message types --------------------------------------------- + +export type ReadyMessage = Envelope; +export type InitMessage = Envelope; +export type RenderedMessage = Envelope; +export type ViewUpdateMessage = Envelope; +export type ErrorMessage = Envelope; +export type GeometryMessage = Envelope; + +export type EditorMessage = InitMessage | ViewUpdateMessage; +export type PreviewMessage = ReadyMessage | RenderedMessage | ErrorMessage | GeometryMessage; +export type AnyMessage = EditorMessage | PreviewMessage; + +// -- Type guards --------------------------------------------------------- + +export function isEnvelope(value: unknown): value is Envelope { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + typeof v.type === 'string' && + (v.source === SOURCE.editor || v.source === SOURCE.preview) && + v.protocolVersion === PROTOCOL_VERSION && + typeof v.payload === 'object' && + v.payload !== null + ); +} + +export function isMessage( + value: unknown, + type: T +): value is Envelope { + return isEnvelope(value) && value.type === type; +} From 1ac294d759d7374ab64c89e8ed386f21cd27db81 Mon Sep 17 00:00:00 2001 From: Thomas Kellermeier Date: Wed, 8 Jul 2026 12:00:22 +0200 Subject: [PATCH 2/5] refactor(preview): send API-generic HydratedView on the wire Swap the protocol payload from the renderer's internal PortableRenderPlan back to the API-generic ExperiencePayload shape (aliased as HydratedView). Third-party renderers, native adapters, and test harnesses can now consume the wire without depending on any renderer's internal IR. ClientExperienceRenderer converts at the boundary: an incoming HydratedView is resolved into a PortableRenderPlan via the same resolveExperience path customers already use for fetched payloads. Co-Authored-By: Claude Opus 4.7 --- .../adapter-react/src/client-renderer.tsx | 11 ++++- packages/adapter-react/src/index.ts | 1 + .../adapter-react/src/use-preview-override.ts | 31 ++++++++---- .../src/use-resolved-preview-plan.ts | 47 +++++++++++++++++++ packages/core/src/preview/client.ts | 4 +- packages/core/src/preview/index.ts | 1 + packages/core/src/preview/protocol.ts | 22 ++++++--- 7 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 packages/adapter-react/src/use-resolved-preview-plan.ts diff --git a/packages/adapter-react/src/client-renderer.tsx b/packages/adapter-react/src/client-renderer.tsx index f8c4d58..24e4f04 100644 --- a/packages/adapter-react/src/client-renderer.tsx +++ b/packages/adapter-react/src/client-renderer.tsx @@ -33,6 +33,7 @@ import { NodesRenderer, WrapWithTemplate, type RenderUnknown } from './nodes-ren import type { Config, RenderContext } from './types'; import { useActiveViewport } from './use-active-viewport'; import { usePreviewOverride } from './use-preview-override'; +import { useResolvedPreviewPlan } from './use-resolved-preview-plan'; const DEFAULT_CONTEXT: ExperienceContext = { isPreview: false, @@ -124,8 +125,14 @@ export function ClientExperienceRenderer({ enablePreview ? knownComponentTypeIds : null ); - // postMessage view wins once it arrives; otherwise the prop is authoritative. - const activeExperience = preview.view ?? experience; + // 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 ( diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index 4a1df17..386f374 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -52,6 +52,7 @@ export { export type { CreatePostMessageChannelOptions, HandshakeStatus as PreviewHandshakeStatus, + HydratedView, MessageHandler as PreviewMessageHandler, PreviewCapabilities, PreviewChannel, diff --git a/packages/adapter-react/src/use-preview-override.ts b/packages/adapter-react/src/use-preview-override.ts index 1eb0879..ca71fd9 100644 --- a/packages/adapter-react/src/use-preview-override.ts +++ b/packages/adapter-react/src/use-preview-override.ts @@ -11,13 +11,18 @@ * the hook returns `undefined` — the renderer falls back to the `experience` * prop. Once `init` arrives, the returned view takes precedence and * subsequent `viewUpdate` messages replace it. + * + * The `view` in the returned result is the API-generic `HydratedView` + * (structurally an `ExperiencePayload`). Callers convert it into a + * `PortableRenderPlan` via `resolveExperience` before handing it to a + * renderer. */ import { useEffect, useRef, useSyncExternalStore } from 'react'; import type { - PortableRenderNode, - PortableRenderPlan, + ExperienceNode, + HydratedView, PreviewCapabilities, PreviewSnapshot, RenderStatus, @@ -48,7 +53,7 @@ export interface UsePreviewOverrideOptions { export interface UsePreviewOverrideResult { // The view to render, or `undefined` when preview is off / not yet // received. Callers fall back to their own `experience` prop. - view: PortableRenderPlan | undefined; + view: HydratedView | undefined; handshakeStatus: PreviewSnapshot['handshakeStatus']; renderStatus: RenderStatus; @@ -147,8 +152,9 @@ export function usePreviewOverride( () => INERT_SNAPSHOT ); - // After each init/viewUpdate, walk the view and report render status - // by comparing component-type ids against the known set. + // After each init/viewUpdate, walk the view and report render status by + // extracting component-type ids from URNs and comparing against the + // known set. useEffect(() => { if (snapshot.handshakeStatus !== 'initialized') return; if (!snapshot.view) return; @@ -187,15 +193,24 @@ export function usePreviewOverride( }; } +// Same URN convention resolveExperience uses: the id is the last non-empty +// path segment. Template nodes are skipped (out of v1 renderer scope). +function extractIdFromUrn(urn: string): string { + const segments = urn.split('/').filter((s) => s.length > 0); + return segments[segments.length - 1] ?? urn; +} + function collectMissing( - nodes: PortableRenderNode[] | undefined, + nodes: ExperienceNode[] | undefined, known: ReadonlySet, out: Set ): void { if (!nodes) return; for (const node of nodes) { - const id = node.registration.componentTypeId; - if (!known.has(id)) out.add(id); + if ('componentType' in node) { + const id = extractIdFromUrn(node.componentType.sys.urn); + if (!known.has(id)) out.add(id); + } if (node.slots) { for (const children of Object.values(node.slots)) { collectMissing(children, known, out); diff --git a/packages/adapter-react/src/use-resolved-preview-plan.ts b/packages/adapter-react/src/use-resolved-preview-plan.ts new file mode 100644 index 0000000..be6de41 --- /dev/null +++ b/packages/adapter-react/src/use-resolved-preview-plan.ts @@ -0,0 +1,47 @@ +'use client'; + +/* + * useResolvedPreviewPlan — bridges the API-generic `HydratedView` coming + * over the preview wire into the internal `PortableRenderPlan` that the + * renderer consumes. + * + * `resolveExperience` is async because customer components can declare a + * `resolveData` hook. The hook re-resolves whenever the view changes, + * discarding results from a superseded resolve so late completions can't + * clobber a newer plan. + * + * Returns `undefined` while resolution is in flight for the very first + * view; on subsequent updates it retains the previous plan until the new + * one finishes, avoiding a flash of empty content between updates. + */ + +import { useEffect, useState } from 'react'; + +import type { HydratedView, PortableRenderPlan } from '@contentful/experiences-core'; +import { resolveExperience } from '@contentful/experiences-core'; + +import type { Config } from './types'; + +export function useResolvedPreviewPlan( + view: HydratedView | undefined, + config: Config +): PortableRenderPlan | undefined { + const [plan, setPlan] = useState(undefined); + + useEffect(() => { + if (!view) { + setPlan(undefined); + return; + } + let cancelled = false; + resolveExperience(view, config).then((resolved) => { + if (cancelled) return; + setPlan(resolved); + }); + return () => { + cancelled = true; + }; + }, [view, config]); + + return plan; +} diff --git a/packages/core/src/preview/client.ts b/packages/core/src/preview/client.ts index 2aae421..3ce65b3 100644 --- a/packages/core/src/preview/client.ts +++ b/packages/core/src/preview/client.ts @@ -5,13 +5,13 @@ * an equivalent adapter in any other framework. */ -import type { PortableRenderPlan } from '../types'; import type { PreviewChannel } from './channel'; import { MESSAGE, PROTOCOL_VERSION, SOURCE, isMessage, + type HydratedView, type InitContext, type InitMessage, type PreviewCapabilities, @@ -31,7 +31,7 @@ export interface PreviewSnapshot { handshakeStatus: HandshakeStatus; renderStatus: RenderStatus; sessionId: string | undefined; - view: PortableRenderPlan | undefined; + view: HydratedView | undefined; context: InitContext | undefined; missingComponents: string[]; error: { message: string; stack?: string } | undefined; diff --git a/packages/core/src/preview/index.ts b/packages/core/src/preview/index.ts index efacca6..4965f9c 100644 --- a/packages/core/src/preview/index.ts +++ b/packages/core/src/preview/index.ts @@ -13,6 +13,7 @@ export type { ErrorPayload, GeometryMessage, GeometryPayload, + HydratedView, InitContext, InitMessage, InitPayload, diff --git a/packages/core/src/preview/protocol.ts b/packages/core/src/preview/protocol.ts index ef046ab..c910438 100644 --- a/packages/core/src/preview/protocol.ts +++ b/packages/core/src/preview/protocol.ts @@ -7,12 +7,22 @@ * WebSocket, WebRTC data channel, or any other duplex pipe. The transport * is isolated in the `PreviewChannel` interface. * - * The `view` on the wire is a `PortableRenderPlan` — the same interpreted - * shape a renderer consumes today. Editor and renderer agree on this type - * directly; no separate "hydrated view" indirection. + * The `view` on the wire is a `HydratedView` — the same API-generic + * payload the Experience Delivery API delivers today. Renderers convert it + * to their internal IR (a `PortableRenderPlan`) via `resolveExperience` + * at the boundary. The wire deliberately stays API-shaped so third-party + * renderers, native adapters, and test harnesses can consume it without + * depending on any renderer's internal IR. */ -import type { PortableRenderPlan } from '../types'; +import type { ExperiencePayload } from '../types'; + +/** + * The API-generic view payload — structurally the response body of + * `GetExperienceViewResponse` from the Experience Delivery API. Alias + * only; the protocol never redefines the shape. + */ +export type HydratedView = ExperiencePayload; // -- Protocol constants -------------------------------------------------- @@ -83,7 +93,7 @@ export interface InitContext { export interface InitPayload { supportedFeatures: string[]; context: InitContext; - initialView: PortableRenderPlan; + initialView: HydratedView; } export interface RenderedPayload { @@ -93,7 +103,7 @@ export interface RenderedPayload { } export interface ViewUpdatePayload { - view: PortableRenderPlan; + view: HydratedView; } export interface ErrorPayload { From 0a9c679fa530bd580ec318bc14b6900604cb30b2 Mon Sep 17 00:00:00 2001 From: Thomas Kellermeier Date: Thu, 9 Jul 2026 18:16:56 +0200 Subject: [PATCH 3/5] refactor(preview): split preview SDK into four workspace packages Introduces the three-layer split for the preview SDK, plus adapters for React and Svelte, so the "framework-specific code is thin" claim is observable in the file tree: - @contentful/experiences-preview-core protocol + channel interface + PreviewClient state machine (framework-agnostic, DOM-agnostic) - @contentful/experiences-preview-web postMessage transport (DOM-dependent; future WebSocket/WebRTC transports land here) - @contentful/experiences-preview-react usePreviewOverride + useResolvedPreviewPlan hooks - @contentful/experiences-preview-svelte createPreviewOverride + createResolvedPreviewPlan runes Customers still install only @contentful/experiences-react (or -svelte); the preview packages are transitive direct dependencies. enablePreview prop stays; internal wiring now imports from the sibling preview-adapter-* package instead of from local files. Co-Authored-By: Claude Opus 4.7 --- eslint.config.mjs | 6 + package-lock.json | 70 ++++++- packages/adapter-react/package.json | 3 +- .../adapter-react/src/client-renderer.tsx | 8 +- packages/adapter-react/src/index.ts | 15 +- packages/adapter-svelte/package.json | 3 +- .../src/ClientExperienceRenderer.svelte | 51 ++++- .../adapter-svelte/src/component-props.ts | 15 +- packages/adapter-svelte/src/index.ts | 36 ++++ packages/core/src/index.ts | 1 - packages/preview-adapter-react/package.json | 40 ++++ packages/preview-adapter-react/project.json | 19 ++ packages/preview-adapter-react/src/index.ts | 44 ++++ .../src/use-preview-override.ts | 8 +- .../src/use-resolved-preview-plan.ts | 7 +- packages/preview-adapter-react/tsconfig.json | 9 + .../preview-adapter-react/tsconfig.lib.json | 18 ++ packages/preview-adapter-react/tsup.config.ts | 14 ++ packages/preview-adapter-svelte/package.json | 49 +++++ packages/preview-adapter-svelte/project.json | 19 ++ packages/preview-adapter-svelte/src/index.ts | 45 ++++ .../src/preview-override.svelte.ts | 197 ++++++++++++++++++ .../src/resolved-preview-plan.svelte.ts | 57 +++++ .../preview-adapter-svelte/svelte.config.js | 8 + packages/preview-adapter-svelte/tsconfig.json | 10 + packages/preview-core/package.json | 35 ++++ packages/preview-core/project.json | 19 ++ .../preview => preview-core/src}/channel.ts | 0 .../preview => preview-core/src}/client.ts | 0 .../src/preview => preview-core/src}/index.ts | 3 - .../preview => preview-core/src}/protocol.ts | 2 +- packages/preview-core/tsconfig.json | 8 + packages/preview-core/tsconfig.lib.json | 15 ++ packages/preview-core/tsup.config.ts | 14 ++ packages/preview-web/package.json | 35 ++++ packages/preview-web/project.json | 19 ++ packages/preview-web/src/index.ts | 2 + .../src}/postMessageChannel.ts | 9 +- packages/preview-web/tsconfig.json | 8 + packages/preview-web/tsconfig.lib.json | 15 ++ packages/preview-web/tsup.config.ts | 14 ++ tsconfig.json | 3 +- 42 files changed, 916 insertions(+), 37 deletions(-) create mode 100644 packages/preview-adapter-react/package.json create mode 100644 packages/preview-adapter-react/project.json create mode 100644 packages/preview-adapter-react/src/index.ts rename packages/{adapter-react => preview-adapter-react}/src/use-preview-override.ts (96%) rename packages/{adapter-react => preview-adapter-react}/src/use-resolved-preview-plan.ts (87%) create mode 100644 packages/preview-adapter-react/tsconfig.json create mode 100644 packages/preview-adapter-react/tsconfig.lib.json create mode 100644 packages/preview-adapter-react/tsup.config.ts create mode 100644 packages/preview-adapter-svelte/package.json create mode 100644 packages/preview-adapter-svelte/project.json create mode 100644 packages/preview-adapter-svelte/src/index.ts create mode 100644 packages/preview-adapter-svelte/src/preview-override.svelte.ts create mode 100644 packages/preview-adapter-svelte/src/resolved-preview-plan.svelte.ts create mode 100644 packages/preview-adapter-svelte/svelte.config.js create mode 100644 packages/preview-adapter-svelte/tsconfig.json create mode 100644 packages/preview-core/package.json create mode 100644 packages/preview-core/project.json rename packages/{core/src/preview => preview-core/src}/channel.ts (100%) rename packages/{core/src/preview => preview-core/src}/client.ts (100%) rename packages/{core/src/preview => preview-core/src}/index.ts (83%) rename packages/{core/src/preview => preview-core/src}/protocol.ts (98%) create mode 100644 packages/preview-core/tsconfig.json create mode 100644 packages/preview-core/tsconfig.lib.json create mode 100644 packages/preview-core/tsup.config.ts create mode 100644 packages/preview-web/package.json create mode 100644 packages/preview-web/project.json create mode 100644 packages/preview-web/src/index.ts rename packages/{core/src/preview => preview-web/src}/postMessageChannel.ts (95%) create mode 100644 packages/preview-web/tsconfig.json create mode 100644 packages/preview-web/tsconfig.lib.json create mode 100644 packages/preview-web/tsup.config.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dd3e43..dafdd42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,6 +38,12 @@ export default [ 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 24e4f04..8851c8f 100644 --- a/packages/adapter-react/src/client-renderer.tsx +++ b/packages/adapter-react/src/client-renderer.tsx @@ -24,16 +24,18 @@ import { useMemo, type ReactNode } from 'react'; import type { ExperienceContext, PortableRenderPlan, - PreviewCapabilities, 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'; import type { Config, RenderContext } from './types'; import { useActiveViewport } from './use-active-viewport'; -import { usePreviewOverride } from './use-preview-override'; -import { useResolvedPreviewPlan } from './use-resolved-preview-plan'; const DEFAULT_CONTEXT: ExperienceContext = { isPreview: false, diff --git a/packages/adapter-react/src/index.ts b/packages/adapter-react/src/index.ts index 386f374..a906ab5 100644 --- a/packages/adapter-react/src/index.ts +++ b/packages/adapter-react/src/index.ts @@ -32,14 +32,17 @@ export type { UseActiveViewportResult } from './use-active-viewport'; export type { RenderUnknown } from './nodes-renderer'; // ─── Preview (editor integration) ───────────────────────────────────────── -export { usePreviewOverride } from './use-preview-override'; +// 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 './use-preview-override'; +} from '@contentful/experiences-preview-react'; -// Advanced-use re-exports from core for customers building custom preview -// flows on top of the primitives (custom transports, non-renderer consumers). +// 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, @@ -48,7 +51,7 @@ export { createPostMessageChannel, isEnvelope as isPreviewEnvelope, isMessage as isPreviewMessage, -} from '@contentful/experiences-core'; +} from '@contentful/experiences-preview-react'; export type { CreatePostMessageChannelOptions, HandshakeStatus as PreviewHandshakeStatus, @@ -59,7 +62,7 @@ export type { PreviewClientOptions, PreviewSnapshot, RenderStatus as PreviewRenderStatus, -} from '@contentful/experiences-core'; +} from '@contentful/experiences-preview-react'; // ─── Authoring helpers + Config types ───────────────────────────────────── export { defineComponent, defineTemplate } from './types'; 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..c3cedde 100644 --- a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte @@ -7,9 +7,19 @@ * the server) to seed the first render, matching what the server emitted; * the rune then takes over via media queries to switch viewports as the * window resizes. + * + * When `enablePreview` is set, 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 subsequent `viewUpdate` messages replace it. --> -{#if experience && renderContext} - +{#if activeExperience && renderContext} + {#snippet children()} ; + + /** Editor origin override for the postMessage target. Ignored when `enablePreview` is false. */ + previewTargetOrigin?: string | string[]; +} export interface MissingComponentProps { componentTypeId: string; diff --git a/packages/adapter-svelte/src/index.ts b/packages/adapter-svelte/src/index.ts index dfaf0d3..12ead59 100644 --- a/packages/adapter-svelte/src/index.ts +++ b/packages/adapter-svelte/src/index.ts @@ -21,6 +21,42 @@ export { default as MissingComponent } from './MissingComponent.svelte'; export { useActiveViewport } from './use-active-viewport.svelte.js'; export type { UseActiveViewportResult } from './use-active-viewport.svelte.js'; +// ─── Preview (editor integration) ───────────────────────────────────────── +// The Svelte preview adapter is a sibling package; everything below is +// re-exported so consumers of `@contentful/experiences-svelte` never need +// to install or import from the preview packages directly. +export { + createPreviewOverride, + createResolvedPreviewPlan, +} from '@contentful/experiences-preview-svelte'; +export type { + CreatePreviewOverrideOptions, + PreviewOverride, + ResolvedPreviewPlan, +} from '@contentful/experiences-preview-svelte'; + +// Advanced-use re-exports for customers building custom preview flows. +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-svelte'; +export type { + CreatePostMessageChannelOptions, + HandshakeStatus as PreviewHandshakeStatus, + HydratedView, + MessageHandler as PreviewMessageHandler, + PreviewCapabilities, + PreviewChannel, + PreviewClientOptions, + PreviewSnapshot, + RenderStatus as PreviewRenderStatus, +} from '@contentful/experiences-preview-svelte'; + // Component prop shapes live in component-props.ts (not .svelte module // blocks) so `tsc --noEmit` can see them without the Svelte language server. export type { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b7e8410..561d0d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ export * from './types'; export { resolveExperience } from './resolve-experience'; export type { ResolverConfig, ResolveExperienceOptions } from './resolve-experience'; -export * from './preview'; diff --git a/packages/preview-adapter-react/package.json b/packages/preview-adapter-react/package.json new file mode 100644 index 0000000..3040a04 --- /dev/null +++ b/packages/preview-adapter-react/package.json @@ -0,0 +1,40 @@ +{ + "name": "@contentful/experiences-preview-react", + "version": "0.1.0", + "description": "React adapter for the Contentful Experiences preview protocol", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/contentful/experiences.git", + "directory": "packages/preview-adapter-react" + }, + "dependencies": { + "@contentful/experiences-core": "*", + "@contentful/experiences-preview-core": "*", + "@contentful/experiences-preview-web": "*" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/preview-adapter-react/project.json b/packages/preview-adapter-react/project.json new file mode 100644 index 0000000..2514f3a --- /dev/null +++ b/packages/preview-adapter-react/project.json @@ -0,0 +1,19 @@ +{ + "name": "preview-adapter-react", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/preview-adapter-react/src", + "projectType": "library", + "tags": ["scope:preview", "runtime:react"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "outputs": ["{projectRoot}/dist"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/preview-adapter-react", + "command": "tsup" + } + } + } +} diff --git a/packages/preview-adapter-react/src/index.ts b/packages/preview-adapter-react/src/index.ts new file mode 100644 index 0000000..89e2462 --- /dev/null +++ b/packages/preview-adapter-react/src/index.ts @@ -0,0 +1,44 @@ +/* + * Public API surface for `@contentful/experiences-preview-react`. + * + * Consumed by `@contentful/experiences-react` internally when a customer + * sets `enablePreview` on `ClientExperienceRenderer`. Advanced customers + * building their own renderer flow can import from this package directly. + */ + +export { usePreviewOverride } from './use-preview-override'; +export type { + UsePreviewOverrideOptions, + UsePreviewOverrideResult, +} from './use-preview-override'; + +export { useResolvedPreviewPlan } from './use-resolved-preview-plan'; + +// Re-export the framework-agnostic surface so consumers who install only +// this package have a single import path for everything preview-related. +export { + MESSAGE, + PROTOCOL_VERSION, + SOURCE, + PreviewClient, + isEnvelope, + isMessage, +} from '@contentful/experiences-preview-core'; +export type { + AnyMessage, + Envelope, + HandshakeStatus, + HydratedView, + InitContext, + MessageHandler, + MessageType, + PreviewCapabilities, + PreviewChannel, + PreviewClientOptions, + PreviewSnapshot, + ReadyPayload, + RenderedPayload, + RenderStatus, +} from '@contentful/experiences-preview-core'; +export { createPostMessageChannel } from '@contentful/experiences-preview-web'; +export type { CreatePostMessageChannelOptions } from '@contentful/experiences-preview-web'; diff --git a/packages/adapter-react/src/use-preview-override.ts b/packages/preview-adapter-react/src/use-preview-override.ts similarity index 96% rename from packages/adapter-react/src/use-preview-override.ts rename to packages/preview-adapter-react/src/use-preview-override.ts index ca71fd9..6a90380 100644 --- a/packages/adapter-react/src/use-preview-override.ts +++ b/packages/preview-adapter-react/src/use-preview-override.ts @@ -20,18 +20,18 @@ import { useEffect, useRef, useSyncExternalStore } from 'react'; +import type { ExperienceNode } from '@contentful/experiences-core'; import type { - ExperienceNode, HydratedView, PreviewCapabilities, PreviewSnapshot, RenderStatus, -} from '@contentful/experiences-core'; +} from '@contentful/experiences-preview-core'; +import { PreviewClient } from '@contentful/experiences-preview-core'; import { - PreviewClient, createPostMessageChannel, type CreatePostMessageChannelOptions, -} from '@contentful/experiences-core'; +} from '@contentful/experiences-preview-web'; export interface UsePreviewOverrideOptions { // Master gate. When false, the hook returns an inert snapshot and diff --git a/packages/adapter-react/src/use-resolved-preview-plan.ts b/packages/preview-adapter-react/src/use-resolved-preview-plan.ts similarity index 87% rename from packages/adapter-react/src/use-resolved-preview-plan.ts rename to packages/preview-adapter-react/src/use-resolved-preview-plan.ts index be6de41..584c77b 100644 --- a/packages/adapter-react/src/use-resolved-preview-plan.ts +++ b/packages/preview-adapter-react/src/use-resolved-preview-plan.ts @@ -17,14 +17,13 @@ import { useEffect, useState } from 'react'; -import type { HydratedView, PortableRenderPlan } from '@contentful/experiences-core'; +import type { PortableRenderPlan, ResolverConfig } from '@contentful/experiences-core'; import { resolveExperience } from '@contentful/experiences-core'; - -import type { Config } from './types'; +import type { HydratedView } from '@contentful/experiences-preview-core'; export function useResolvedPreviewPlan( view: HydratedView | undefined, - config: Config + config: ResolverConfig ): PortableRenderPlan | undefined { const [plan, setPlan] = useState(undefined); diff --git a/packages/preview-adapter-react/tsconfig.json b/packages/preview-adapter-react/tsconfig.json new file mode 100644 index 0000000..60c1e78 --- /dev/null +++ b/packages/preview-adapter-react/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tsup.config.ts"] +} diff --git a/packages/preview-adapter-react/tsconfig.lib.json b/packages/preview-adapter-react/tsconfig.lib.json new file mode 100644 index 0000000..7866cc9 --- /dev/null +++ b/packages/preview-adapter-react/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "tsup.config.ts" + ] +} diff --git a/packages/preview-adapter-react/tsup.config.ts b/packages/preview-adapter-react/tsup.config.ts new file mode 100644 index 0000000..d030667 --- /dev/null +++ b/packages/preview-adapter-react/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/**/*.ts', 'src/**/*.tsx', '!src/**/*.test.ts', '!src/**/*.test.tsx'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + outDir: 'dist', + tsconfig: 'tsconfig.lib.json', + bundle: false, + external: ['react', 'react-dom', /^@contentful\//], +}); diff --git a/packages/preview-adapter-svelte/package.json b/packages/preview-adapter-svelte/package.json new file mode 100644 index 0000000..185efa7 --- /dev/null +++ b/packages/preview-adapter-svelte/package.json @@ -0,0 +1,49 @@ +{ + "name": "@contentful/experiences-preview-svelte", + "version": "0.1.0", + "description": "Svelte 5 adapter for the Contentful Experiences preview protocol", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/contentful/experiences.git", + "directory": "packages/preview-adapter-svelte" + }, + "scripts": { + "build": "svelte-package -i src -o dist && publint" + }, + "dependencies": { + "@contentful/experiences-core": "*", + "@contentful/experiences-preview-core": "*", + "@contentful/experiences-preview-web": "*" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/package": "^2.3.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "publint": "^0.2.0", + "svelte": "^5.0.0" + } +} diff --git a/packages/preview-adapter-svelte/project.json b/packages/preview-adapter-svelte/project.json new file mode 100644 index 0000000..6a5b938 --- /dev/null +++ b/packages/preview-adapter-svelte/project.json @@ -0,0 +1,19 @@ +{ + "name": "preview-adapter-svelte", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/preview-adapter-svelte/src", + "projectType": "library", + "tags": ["scope:preview", "runtime:svelte"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "outputs": ["{projectRoot}/dist"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/preview-adapter-svelte", + "command": "svelte-package -i src -o dist" + } + } + } +} diff --git a/packages/preview-adapter-svelte/src/index.ts b/packages/preview-adapter-svelte/src/index.ts new file mode 100644 index 0000000..87a58d0 --- /dev/null +++ b/packages/preview-adapter-svelte/src/index.ts @@ -0,0 +1,45 @@ +/* + * Public API surface for `@contentful/experiences-preview-svelte`. + * + * Consumed by `@contentful/experiences-svelte` internally when a customer + * sets `enablePreview` on `ClientExperienceRenderer`. Advanced customers + * building their own renderer flow can import from this package directly. + */ + +export { createPreviewOverride } from './preview-override.svelte.js'; +export type { + CreatePreviewOverrideOptions, + PreviewOverride, +} from './preview-override.svelte.js'; + +export { createResolvedPreviewPlan } from './resolved-preview-plan.svelte.js'; +export type { ResolvedPreviewPlan } from './resolved-preview-plan.svelte.js'; + +// Re-export the framework-agnostic surface so consumers who install only +// this package have a single import path for everything preview-related. +export { + MESSAGE, + PROTOCOL_VERSION, + SOURCE, + PreviewClient, + isEnvelope, + isMessage, +} from '@contentful/experiences-preview-core'; +export type { + AnyMessage, + Envelope, + HandshakeStatus, + HydratedView, + InitContext, + MessageHandler, + MessageType, + PreviewCapabilities, + PreviewChannel, + PreviewClientOptions, + PreviewSnapshot, + ReadyPayload, + RenderedPayload, + RenderStatus, +} from '@contentful/experiences-preview-core'; +export { createPostMessageChannel } from '@contentful/experiences-preview-web'; +export type { CreatePostMessageChannelOptions } from '@contentful/experiences-preview-web'; diff --git a/packages/preview-adapter-svelte/src/preview-override.svelte.ts b/packages/preview-adapter-svelte/src/preview-override.svelte.ts new file mode 100644 index 0000000..2a23e83 --- /dev/null +++ b/packages/preview-adapter-svelte/src/preview-override.svelte.ts @@ -0,0 +1,197 @@ +/* + * createPreviewOverride — Svelte 5 rune-based counterpart to the React + * `usePreviewOverride`. Wires a `PreviewClient` and exposes its snapshot + * as reactive getters (`view`, `handshakeStatus`, ...). + * + * When `enabled` is false, when the app is not embedded in a Contentful + * editor (no matching ancestor origin), or before the handshake completes, + * `view` is `undefined` — the renderer falls back to the `experience` + * prop. Once `init` arrives, `view` takes precedence and subsequent + * `viewUpdate` messages replace it. + * + * The `view` field is the API-generic `HydratedView`. Callers convert + * to a `PortableRenderPlan` via `createResolvedPreviewPlan` (from + * `resolved-preview-plan.svelte.ts`) or `resolveExperience` directly. + */ + +import type { ExperienceNode } from '@contentful/experiences-core'; +import type { + HydratedView, + PreviewCapabilities, + PreviewSnapshot, + RenderStatus, +} from '@contentful/experiences-preview-core'; +import { PreviewClient } from '@contentful/experiences-preview-core'; +import { + createPostMessageChannel, + type CreatePostMessageChannelOptions, +} from '@contentful/experiences-preview-web'; + +export interface CreatePreviewOverrideOptions { + // Master gate. When false, the returned view is always undefined and + // no listeners are installed. + enabled: boolean; + + capabilities?: Partial; + supportedFeatures?: string[]; + targetOrigin?: CreatePostMessageChannelOptions['targetOrigin']; +} + +export interface PreviewOverride { + readonly view: HydratedView | undefined; + readonly handshakeStatus: PreviewSnapshot['handshakeStatus']; + readonly renderStatus: RenderStatus; + readonly missingComponents: string[]; + readonly isReactive: boolean; + readonly sessionId: string | undefined; +} + +const DEFAULT_CAPABILITIES: PreviewCapabilities = { + liveUpdate: true, + alreadyRendered: false, + nodeGeometry: false, +}; + +const DEFAULT_SUPPORTED_FEATURES = ['viewUpdate']; + +const INERT_SNAPSHOT: PreviewSnapshot = { + handshakeStatus: 'idle', + renderStatus: 'pending', + sessionId: undefined, + view: undefined, + context: undefined, + missingComponents: [], + error: undefined, +}; + +/** + * Must be called at the top level of a `.svelte` component (or during + * component setup) — internally uses `$effect` for lifecycle management. + * + * Pass the set of component-type ids the renderer knows about so the + * hook can report `partial` render status with the missing ids. Pass + * `null` (or omit) to always report `ok` once render completes. + */ +export function createPreviewOverride( + options: CreatePreviewOverrideOptions, + knownComponentTypeIds?: ReadonlySet | null +): PreviewOverride { + const capabilities: PreviewCapabilities = { + ...DEFAULT_CAPABILITIES, + ...(options.capabilities ?? {}), + }; + const supportedFeatures = options.supportedFeatures ?? DEFAULT_SUPPORTED_FEATURES; + const isReactive = capabilities.liveUpdate; + + let snapshot = $state(INERT_SNAPSHOT); + let client: PreviewClient | null = null; + + $effect(() => { + if (!options.enabled) return; + + const channel = createPostMessageChannel({ targetOrigin: options.targetOrigin }); + if (!channel) return; + + const c = new PreviewClient({ channel, supportedFeatures, capabilities }); + client = c; + + const unsubscribe = c.subscribe(() => { + snapshot = c.getSnapshot(); + }); + c.start(); + + const onError = (event: ErrorEvent) => { + c.reportError({ message: event.message, stack: event.error?.stack }); + }; + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + const message = + reason instanceof Error ? reason.message : String(reason ?? 'unhandledrejection'); + const stack = reason instanceof Error ? reason.stack : undefined; + c.reportError({ message, stack }); + }; + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onUnhandledRejection); + + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onUnhandledRejection); + unsubscribe(); + c.close(); + client = null; + }; + }); + + // After each init/viewUpdate, walk the incoming view and report render + // status by extracting component-type ids from URNs and comparing + // against the known set. + $effect(() => { + if (snapshot.handshakeStatus !== 'initialized') return; + if (!snapshot.view) return; + if (snapshot.renderStatus !== 'pending') return; + + if (!knownComponentTypeIds) { + client?.reportRendered({ status: 'ok', missingComponents: [] }); + return; + } + + const missing = new Set(); + collectMissing(snapshot.view.nodes, knownComponentTypeIds, missing); + + if (missing.size === 0) { + client?.reportRendered({ status: 'ok', missingComponents: [] }); + } else { + client?.reportRendered({ + status: 'partial', + missingComponents: Array.from(missing), + }); + } + }); + + return { + get view() { + return snapshot.view; + }, + get handshakeStatus() { + return snapshot.handshakeStatus; + }, + get renderStatus() { + return snapshot.renderStatus; + }, + get missingComponents() { + return snapshot.missingComponents; + }, + get isReactive() { + return isReactive; + }, + get sessionId() { + return snapshot.sessionId; + }, + }; +} + +// Same URN convention resolveExperience uses: id is the last non-empty +// path segment. Template nodes are skipped (out of v1 renderer scope). +function extractIdFromUrn(urn: string): string { + const segments = urn.split('/').filter((s) => s.length > 0); + return segments[segments.length - 1] ?? urn; +} + +function collectMissing( + nodes: ExperienceNode[] | undefined, + known: ReadonlySet, + out: Set +): void { + if (!nodes) return; + for (const node of nodes) { + if ('componentType' in node) { + const id = extractIdFromUrn(node.componentType.sys.urn); + if (!known.has(id)) out.add(id); + } + if (node.slots) { + for (const children of Object.values(node.slots)) { + collectMissing(children, known, out); + } + } + } +} diff --git a/packages/preview-adapter-svelte/src/resolved-preview-plan.svelte.ts b/packages/preview-adapter-svelte/src/resolved-preview-plan.svelte.ts new file mode 100644 index 0000000..b9ce3b3 --- /dev/null +++ b/packages/preview-adapter-svelte/src/resolved-preview-plan.svelte.ts @@ -0,0 +1,57 @@ +/* + * createResolvedPreviewPlan — Svelte 5 rune-based counterpart to the + * React `useResolvedPreviewPlan`. Bridges the API-generic `HydratedView` + * coming over the preview wire into the internal `PortableRenderPlan` + * that the renderer consumes. + * + * `resolveExperience` is async because customer components can declare a + * `resolveData` hook. The rune re-resolves whenever the passed view + * changes, discarding results from a superseded resolve so late + * completions can't clobber a newer plan. + * + * On subsequent updates the previous plan is retained until the new one + * finishes, avoiding a flash of empty content between updates. + */ + +import type { PortableRenderPlan, ResolverConfig } from '@contentful/experiences-core'; +import { resolveExperience } from '@contentful/experiences-core'; +import type { HydratedView } from '@contentful/experiences-preview-core'; + +export interface ResolvedPreviewPlan { + readonly plan: PortableRenderPlan | undefined; +} + +/** + * Must be called at the top level of a `.svelte` component. Pass a getter + * for the view so the rune can react when it changes — the caller + * typically reads `previewOverride.view`, so the getter is a natural + * shape. + */ +export function createResolvedPreviewPlan( + getView: () => HydratedView | undefined, + config: ResolverConfig +): ResolvedPreviewPlan { + let plan = $state(undefined); + + $effect(() => { + const view = getView(); + if (!view) { + plan = undefined; + return; + } + let cancelled = false; + resolveExperience(view, config).then((resolved) => { + if (cancelled) return; + plan = resolved; + }); + return () => { + cancelled = true; + }; + }); + + return { + get plan() { + return plan; + }, + }; +} diff --git a/packages/preview-adapter-svelte/svelte.config.js b/packages/preview-adapter-svelte/svelte.config.js new file mode 100644 index 0000000..6005ca8 --- /dev/null +++ b/packages/preview-adapter-svelte/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +}; diff --git a/packages/preview-adapter-svelte/tsconfig.json b/packages/preview-adapter-svelte/tsconfig.json new file mode 100644 index 0000000..59ff795 --- /dev/null +++ b/packages/preview-adapter-svelte/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "allowJs": true, + "checkJs": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte.ts", "svelte.config.js"] +} diff --git a/packages/preview-core/package.json b/packages/preview-core/package.json new file mode 100644 index 0000000..c0bfdc1 --- /dev/null +++ b/packages/preview-core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@contentful/experiences-preview-core", + "version": "0.1.0", + "description": "Runtime-neutral preview protocol + state machine for Contentful Experiences", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/contentful/experiences.git", + "directory": "packages/preview-core" + }, + "dependencies": { + "@contentful/experiences-core": "*" + } +} diff --git a/packages/preview-core/project.json b/packages/preview-core/project.json new file mode 100644 index 0000000..0bbb263 --- /dev/null +++ b/packages/preview-core/project.json @@ -0,0 +1,19 @@ +{ + "name": "preview-core", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/preview-core/src", + "projectType": "library", + "tags": ["scope:preview", "layer:core"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "outputs": ["{projectRoot}/dist"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/preview-core", + "command": "tsup" + } + } + } +} diff --git a/packages/core/src/preview/channel.ts b/packages/preview-core/src/channel.ts similarity index 100% rename from packages/core/src/preview/channel.ts rename to packages/preview-core/src/channel.ts diff --git a/packages/core/src/preview/client.ts b/packages/preview-core/src/client.ts similarity index 100% rename from packages/core/src/preview/client.ts rename to packages/preview-core/src/client.ts diff --git a/packages/core/src/preview/index.ts b/packages/preview-core/src/index.ts similarity index 83% rename from packages/core/src/preview/index.ts rename to packages/preview-core/src/index.ts index 4965f9c..c741003 100644 --- a/packages/core/src/preview/index.ts +++ b/packages/preview-core/src/index.ts @@ -39,6 +39,3 @@ export type { PreviewSnapshot, RenderStatus, } from './client'; - -export { createPostMessageChannel } from './postMessageChannel'; -export type { CreatePostMessageChannelOptions } from './postMessageChannel'; diff --git a/packages/core/src/preview/protocol.ts b/packages/preview-core/src/protocol.ts similarity index 98% rename from packages/core/src/preview/protocol.ts rename to packages/preview-core/src/protocol.ts index c910438..88e9645 100644 --- a/packages/core/src/preview/protocol.ts +++ b/packages/preview-core/src/protocol.ts @@ -15,7 +15,7 @@ * depending on any renderer's internal IR. */ -import type { ExperiencePayload } from '../types'; +import type { ExperiencePayload } from '@contentful/experiences-core'; /** * The API-generic view payload — structurally the response body of diff --git a/packages/preview-core/tsconfig.json b/packages/preview-core/tsconfig.json new file mode 100644 index 0000000..b3f3c00 --- /dev/null +++ b/packages/preview-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/preview-core/tsconfig.lib.json b/packages/preview-core/tsconfig.lib.json new file mode 100644 index 0000000..06e7fbb --- /dev/null +++ b/packages/preview-core/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "tsup.config.ts" + ] +} diff --git a/packages/preview-core/tsup.config.ts b/packages/preview-core/tsup.config.ts new file mode 100644 index 0000000..e59a519 --- /dev/null +++ b/packages/preview-core/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/**/*.ts', '!src/**/*.test.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + outDir: 'dist', + tsconfig: 'tsconfig.lib.json', + bundle: false, + external: [/^@contentful\//], +}); diff --git a/packages/preview-web/package.json b/packages/preview-web/package.json new file mode 100644 index 0000000..4bea3ef --- /dev/null +++ b/packages/preview-web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@contentful/experiences-preview-web", + "version": "0.1.0", + "description": "postMessage (and future WebSocket/WebRTC) transports for the Contentful Experiences preview protocol", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/contentful/experiences.git", + "directory": "packages/preview-web" + }, + "dependencies": { + "@contentful/experiences-preview-core": "*" + } +} diff --git a/packages/preview-web/project.json b/packages/preview-web/project.json new file mode 100644 index 0000000..c850a93 --- /dev/null +++ b/packages/preview-web/project.json @@ -0,0 +1,19 @@ +{ + "name": "preview-web", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/preview-web/src", + "projectType": "library", + "tags": ["scope:preview", "layer:web"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "outputs": ["{projectRoot}/dist"], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/preview-web", + "command": "tsup" + } + } + } +} diff --git a/packages/preview-web/src/index.ts b/packages/preview-web/src/index.ts new file mode 100644 index 0000000..6260bf4 --- /dev/null +++ b/packages/preview-web/src/index.ts @@ -0,0 +1,2 @@ +export { createPostMessageChannel } from './postMessageChannel'; +export type { CreatePostMessageChannelOptions } from './postMessageChannel'; diff --git a/packages/core/src/preview/postMessageChannel.ts b/packages/preview-web/src/postMessageChannel.ts similarity index 95% rename from packages/core/src/preview/postMessageChannel.ts rename to packages/preview-web/src/postMessageChannel.ts index b2d1c8f..9dc3af8 100644 --- a/packages/core/src/preview/postMessageChannel.ts +++ b/packages/preview-web/src/postMessageChannel.ts @@ -14,8 +14,13 @@ * extension or test harness inject messages without spoofing the origin. */ -import type { MessageHandler, PreviewChannel } from './channel'; -import { SOURCE, isEnvelope, type AnyMessage, type MessageType } from './protocol'; +import type { MessageHandler, PreviewChannel } from '@contentful/experiences-preview-core'; +import { + SOURCE, + isEnvelope, + type AnyMessage, + type MessageType, +} from '@contentful/experiences-preview-core'; const LOCALHOST_PREFIX = 'http://localhost:'; diff --git a/packages/preview-web/tsconfig.json b/packages/preview-web/tsconfig.json new file mode 100644 index 0000000..b3f3c00 --- /dev/null +++ b/packages/preview-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tsup.config.ts"] +} diff --git a/packages/preview-web/tsconfig.lib.json b/packages/preview-web/tsconfig.lib.json new file mode 100644 index 0000000..06e7fbb --- /dev/null +++ b/packages/preview-web/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "tsup.config.ts" + ] +} diff --git a/packages/preview-web/tsup.config.ts b/packages/preview-web/tsup.config.ts new file mode 100644 index 0000000..e59a519 --- /dev/null +++ b/packages/preview-web/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/**/*.ts', '!src/**/*.test.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + outDir: 'dist', + tsconfig: 'tsconfig.lib.json', + bundle: false, + external: [/^@contentful\//], +}); diff --git a/tsconfig.json b/tsconfig.json index 996191b..3453c4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "./tsconfig.base.json", "files": [], - "include": [] + "include": [], + "references": [] } From ec171d05af5708894600be88db2f4783fcb49270 Mon Sep 17 00:00:00 2001 From: Thomas Kellermeier Date: Fri, 10 Jul 2026 15:49:06 +0200 Subject: [PATCH 4/5] feat(adapters): make ClientExperienceRenderer SSR-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React and Svelte `ClientExperienceRenderer` no longer throw when rendered on the server. Server-side render emits the same output as `ServerExperienceRenderer` (viewport seeded from `initialViewportId`, matchMedia listeners are gated on `typeof window`); client-side render takes over after hydration. For React, `enablePreview` is additionally gated on an `isHydrated` flag sourced via `useSyncExternalStore` with a `false` server snapshot, so the preview override only wakes up after the tree has hydrated — matching what would happen with a `'use client'` boundary but without the extra file. For Svelte, `$effect` already runs client-only, so removing the throw is sufficient. Consumers can now render `ClientExperienceRenderer` directly from a server component / server load and get SSR HTML for first paint plus editor-driven live updates once hydrated. No client-boundary shell required. Co-Authored-By: Claude Opus 4.7 --- .../adapter-react/src/client-renderer.tsx | 78 ++++++++++++------- .../src/ClientExperienceRenderer.svelte | 34 ++++---- 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/packages/adapter-react/src/client-renderer.tsx b/packages/adapter-react/src/client-renderer.tsx index 8851c8f..b667687 100644 --- a/packages/adapter-react/src/client-renderer.tsx +++ b/packages/adapter-react/src/client-renderer.tsx @@ -1,25 +1,27 @@ /* - * Client-side Experience renderer. Uses `useActiveViewport` to react to - * window.matchMedia changes. + * Universal Experience renderer — safe to render on the server AND on the + * client. Emits the same output as `ServerExperienceRenderer` for first + * paint (SSR / RSC); after hydration, `useActiveViewport` takes over via + * `window.matchMedia` and re-renders as viewports change. * - * Throws if rendered on the server — pair with `ServerExperienceRenderer` - * for SSR. 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. + * Use `initialViewportId` (typically derived from User-Agent on the + * server) to seed the first render, matching what the server emitted. + * `useActiveViewport` registers no listeners when `typeof window === + * 'undefined'`, so SSR output is deterministic. * - * When `enablePreview` is set, the renderer also runs the preview client - * against the parent editor. Until `init` arrives (or forever, when the app - * is not embedded in an editor), it renders the `experience` prop as usual. - * Once `init` arrives, the editor-delivered plan takes precedence and any - * subsequent `viewUpdate` messages replace it. The flag is safe to leave on - * in production: no matching editor parent → no override arrives → identical - * behavior to today. + * When `enablePreview` is set, the renderer additionally connects to the + * parent editor via postMessage — but only after hydration on the + * client. 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 { useMemo, type ReactNode } from 'react'; +import { useMemo, useSyncExternalStore, type ReactNode } from 'react'; import type { ExperienceContext, @@ -37,6 +39,13 @@ import { NodesRenderer, WrapWithTemplate, type RenderUnknown } from './nodes-ren import type { Config, RenderContext } from './types'; import { useActiveViewport } from './use-active-viewport'; +// Constants for the useSyncExternalStore-based hydration check. Kept at +// module scope so their identity is stable across renders — passing a +// fresh subscribe/snapshot each render would refire the store. +const NOOP_SUBSCRIBE = (): (() => void) => () => {}; +const IS_HYDRATED_TRUE = (): boolean => true; +const IS_HYDRATED_FALSE = (): boolean => false; + const DEFAULT_CONTEXT: ExperienceContext = { isPreview: false, metadata: {}, @@ -66,15 +75,17 @@ export interface ClientExperienceRendererProps { /** * 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. + * SSR remains untouched — the flag only activates after hydration on the + * client, so first paint still comes from the server-rendered HTML. + * Once hydrated, the renderer connects to the parent editor via + * postMessage. Before `init` arrives — or when the app is not embedded + * in a known editor origin — rendering stays with 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. + * 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; @@ -104,11 +115,17 @@ export function ClientExperienceRenderer({ previewCapabilities, previewTargetOrigin, }: ClientExperienceRendererProps): ReactNode { - if (typeof window === 'undefined') { - throw new Error( - 'ClientExperienceRenderer cannot be used on the server. Use ServerExperienceRenderer for SSR.' - ); - } + // Detect hydration state without diverging server + client output. + // `useSyncExternalStore` with a getServerSnapshot returning `false` + // guarantees SSR sees `isHydrated: false`; the first client render + // after hydration flips it to `true`. Server output matches + // ServerExperienceRenderer exactly; the preview wire only activates + // once the tree has been hydrated on the client. + const isHydrated = useSyncExternalStore( + NOOP_SUBSCRIBE, + IS_HYDRATED_TRUE, + IS_HYDRATED_FALSE + ); // Set of component-type ids the customer registered — used by the // preview hook to detect missing components in the incoming view and @@ -120,7 +137,10 @@ export function ClientExperienceRenderer({ const preview = usePreviewOverride( { - enabled: enablePreview, + // Only arm the preview wire after hydration. Before that, the + // hook returns an inert snapshot and installs nothing, so SSR + // output is unaffected. + enabled: enablePreview && isHydrated, capabilities: previewCapabilities, targetOrigin: previewTargetOrigin, }, diff --git a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte index c3cedde..b20d421 100644 --- a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte @@ -1,18 +1,22 @@ + +{#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 @@