Skip to content

feat(exo): preview SDK split into three-layer workspace packages [SPA-4692]#28

Draft
Thomas Kellermeier (Chaoste) wants to merge 5 commits into
mainfrom
spike/spa-4692-component-preview
Draft

feat(exo): preview SDK split into three-layer workspace packages [SPA-4692]#28
Thomas Kellermeier (Chaoste) wants to merge 5 commits into
mainfrom
spike/spa-4692-component-preview

Conversation

@Chaoste

@Chaoste Thomas Kellermeier (Chaoste) commented Jul 9, 2026

Copy link
Copy Markdown

Comparison branch for the ExO optimization PR (#26). Same monorepo, different strategy: the preview SDK ships as workspace packages that are direct dependencies of the renderer adapters — customers install nothing extra.

Companion PR #29 stacks on top and shows the customer-side integration diff — one import.

Summary

  • Preview SDK lives in four sibling packages under shared/experiences/packages/; the three-layer split (core / web / adapter) is visible in the file tree.
  • Customers still install only @contentful/experiences-react (or -svelte); the preview packages arrive transitively.
  • enablePreview prop on ExperienceRenderer toggles the wire; no-op when the app isn't inside a Contentful editor.
  • Three renderers, one per intent — no wrapper components or 'use client' boilerplate needed at the call site:
    • ServerExperienceRenderer — RSC-only, static HTML, zero client-JS.
    • ClientExperienceRenderer — client-only; throws on the server. For tests / native shells / non-SSR apps.
    • ExperienceRenderer — hybrid; SSR first paint + client hydration + enablePreview. Pick for editable / preview-capable routes.
  • Framework-specific code across two frameworks totals ~360 LOC; framework-agnostic core is ~460 LOC.

Changes

  • New: @contentful/experiences-preview-core — protocol + channel interface + PreviewClient state machine (framework-agnostic, DOM-agnostic).
  • New: @contentful/experiences-preview-web — postMessage transport (DOM-dependent; future WebSocket/WebRTC land here).
  • New: @contentful/experiences-preview-reactusePreviewOverride + useResolvedPreviewPlan hooks.
  • New: @contentful/experiences-preview-sveltecreatePreviewOverride + createResolvedPreviewPlan runes.
  • New: ExperienceRenderer — hybrid renderer in both @contentful/experiences-react and @contentful/experiences-svelte. Delegates to ServerExperienceRenderer before hydration and ClientExperienceRenderer after. useSyncExternalStore (React) / $state + $effect (Svelte) drives the two-phase handoff without hydration mismatches.
  • experiences-react and experiences-svelte each add a single-line dep on their matching preview adapter package and gain enablePreview / previewCapabilities / previewTargetOrigin props on ExperienceRenderer and ClientExperienceRenderer.
  • ClientExperienceRenderer continues to throw on the server; documentation now points at ExperienceRenderer as the hybrid path.
  • Wire type is HydratedView (alias for the API-generic ExperiencePayload), not the renderer-internal PortableRenderPlan — third-party renderers and native adapters can consume the wire without depending on any renderer's IR.
  • Removed the transitional preview module from @contentful/experiences-core.

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

Replaces the "universal ClientExperienceRenderer" from the previous
commit with three focused renderers, one per intent:

- `ServerExperienceRenderer` — RSC-only. Static HTML, zero client-JS.
  Pick for marketing / blog / landing pages with no interactivity.
- `ClientExperienceRenderer` — client-only; throws on the server.
  Pick for tests, native shells, or apps that never SSR.
- `ExperienceRenderer` — hybrid. SSR first paint + client hydration +
  `enablePreview`. Pick for any editable / preview-capable route.

The hybrid is a two-line handoff: `useSyncExternalStore` (React) /
`$state + $effect` (Svelte) returns `false` on the server and on the
first pre-hydration client render, `true` afterwards. Before hydration
it delegates to `ServerExperienceRenderer` (byte-identical HTML on
both sides); after hydration it renders `ClientExperienceRenderer`,
which owns the preview wire.

Customers pick by import — no wrapper component, no `'use client'`
boundary file, no `enablePreview && isHydrated` gymnastics. The
component name at the call site tells the reader what the route
does.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant