From 198fa9428bbb8964ebcf581e9940c4667df08294 Mon Sep 17 00:00:00 2001 From: Max Toball Date: Wed, 1 Jul 2026 11:43:16 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat!:=20idiomatic=20adapters=20=E2=80=94?= =?UTF-8?q?=20bare=20components=20+=20context=20hooks=20(breaking)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Registrations accept a bare component (`button: Button`) OR the config shape when defaults / resolveData are needed. `defineComponent` stays as an opt-in generic-narrowing helper. * Runtime context (`experience`, `contentful`) moves off injected props onto context hooks — `useExperience`, `useContentfulComponent`, `useContentfulTemplate` (React); `getExperience`, `getContentfulComponent`, `getContentfulTemplate` (Svelte). Customer components stay plain and only receive props they declare. * Svelte: `children` is a first-class Snippet prop; the `slot('name')` dispatcher is gone. Additional named slots reachable via `getContentfulComponent().slots` + the exported ``. * ClientExperienceRenderer no longer throws on the server — SSR path matches Server variant byte-for-byte, matchMedia takes over after hydration. BREAKING CHANGE: components no longer receive `experience` / `contentful` props; use the corresponding hook / getter. Svelte adapter no longer passes a `slot: Snippet<[string]>` dispatcher; use named `children`. Co-Authored-By: Claude Opus 4.7 --- .../nextjs/lib/experience-config-advanced.tsx | 65 ++-- examples/nextjs/lib/experience-config.tsx | 84 ++---- .../sveltekit/src/lib/experience-config.ts | 67 ++--- .../adapter-react/src/client-renderer.tsx | 70 ++--- packages/adapter-react/src/context.tsx | 64 ++++ packages/adapter-react/src/index.ts | 14 +- .../adapter-react/src/missing-component.tsx | 7 +- packages/adapter-react/src/nodes-renderer.tsx | 81 +++-- .../src/server-renderer.test.tsx | 284 +++++++++--------- .../adapter-react/src/server-renderer.tsx | 24 +- packages/adapter-react/src/types.ts | 234 ++++++--------- .../src/ClientExperienceRenderer.svelte | 59 ++-- .../src/MissingComponent.svelte | 4 +- .../adapter-svelte/src/NodeRenderer.svelte | 70 +++++ .../adapter-svelte/src/NodesRenderer.svelte | 83 ++--- .../src/ServerExperienceRenderer.svelte | 25 +- .../src/WrapWithTemplate.svelte | 38 ++- .../adapter-svelte/src/component-props.ts | 3 +- packages/adapter-svelte/src/context.ts | 60 ++++ packages/adapter-svelte/src/index.ts | 22 +- .../src/server-renderer.test.ts | 137 +++++---- .../test-fixtures/CapturingComponent.svelte | 29 +- .../src/test-fixtures/ContainerFixture.svelte | 6 +- .../src/test-fixtures/capture-sink.ts | 15 + packages/adapter-svelte/src/types.ts | 197 +++++------- packages/core/src/resolve-experience.ts | 48 +-- 26 files changed, 899 insertions(+), 891 deletions(-) create mode 100644 packages/adapter-react/src/context.tsx create mode 100644 packages/adapter-svelte/src/NodeRenderer.svelte create mode 100644 packages/adapter-svelte/src/context.ts create mode 100644 packages/adapter-svelte/src/test-fixtures/capture-sink.ts diff --git a/examples/nextjs/lib/experience-config-advanced.tsx b/examples/nextjs/lib/experience-config-advanced.tsx index c8c26d0..3fb00e4 100644 --- a/examples/nextjs/lib/experience-config-advanced.tsx +++ b/examples/nextjs/lib/experience-config-advanced.tsx @@ -1,6 +1,6 @@ /** - * Advanced integration config — shows the configuration knobs you reach for - * when the simple config (`./experience-config.tsx`) isn't enough. + * Advanced integration config — shows the knobs you reach for when the + * simple config (`./experience-config.tsx`) isn't enough. * * Differences from the simple config: * - **Async `resolveData`** on `button` — a deliberately slow fake fetch, @@ -8,76 +8,53 @@ * Demonstrates that resolvers run **in parallel across nodes** before * rendering, and that they have access to `experience.metadata` (which the * advanced page passes in via `resolveExperience`'s third argument). - * - **Reads `experience.metadata`** to build a per-page label — proves the + * - **Reads `experience.metadata`** to build a per-page URL — proves the * metadata is threaded through every resolver. - * - * Templates and the rest of the components are identical to the simple - * config. Only the `button` is enriched. + * - **DebugPanel** — a generic wrapper component using `useContentfulComponent` + * to render a `
` block in preview mode. Demonstrates the runtime + * escape hatch: customer code reaches the raw payload without the SDK + * spreading it onto every component. */ -import { - defineComponent, - defineTemplate, - type Components, - type Config, - type Templates, -} from '@contentful/experiences-react'; +import { defineComponent, type Components, type Config, type Templates } from '@contentful/experiences-react'; import { Button, type ButtonProps } from '@/components/Button'; -import { Header, type HeaderProps } from '@/components/Header'; -import { Page, type PageProps } from '@/components/Page'; -import { Text, type TextProps } from '@/components/Text'; +import { Header } from '@/components/Header'; +import { Page } from '@/components/Page'; +import { Text } from '@/components/Text'; -// Pretend this fetches enrichment from a catalog or pricing API. The point -// is just that it's async and takes non-trivial time — the SDK fans these -// out across all nodes in parallel via Promise.all when resolveExperience is called. +// Pretend this fetches enrichment from a catalog or pricing API. The point is +// just that it's async and takes non-trivial time — the SDK fans these out +// across all nodes in parallel via Promise.all in resolveExperience. async function fetchButtonEnrichment(text: string): Promise<{ formattedText: string }> { await new Promise((resolve) => setTimeout(resolve, 50)); return { formattedText: text.toUpperCase() }; } const components: Components = { + // defineComponent narrows the resolveData ctx + return type to ButtonProps. button: defineComponent({ defaults: { type: 'primary' }, resolveData: async ({ content, experience }) => { const rawText = (content.text as string) ?? 'Button'; const { formattedText } = await fetchButtonEnrichment(rawText); - - // experience.metadata is passed in by the page via - // resolveExperience(payload, config, { experience: { metadata: {...} } }) const locale = (experience.metadata.locale as string) ?? 'en-US'; const slug = (experience.metadata.slug as string) ?? ''; - return { text: formattedText, url: `/${locale}/${slug}`, }; }, - render: Button, - }), - - header: defineComponent({ - defaults: { variant: 'h2', text: 'Hello World' }, - render: Header, + component: Button, }), - text: defineComponent({ - render: Text, - }), + header: { component: Header, defaults: { variant: 'h2', text: 'Hello World' } }, + text: Text, }; const templates: Templates = { - hi: defineTemplate({ - defaults: { title: 'Welcome (advanced)' }, - render: Page, - }), - hero: defineTemplate({ - defaults: { title: 'Featured (advanced)' }, - render: Page, - }), + hi: { component: Page, defaults: { title: 'Welcome (advanced)' } }, + hero: { component: Page, defaults: { title: 'Featured (advanced)' } }, }; -export const advancedExperienceConfig: Config = { - components, - templates, -}; +export const advancedExperienceConfig: Config = { components, templates }; diff --git a/examples/nextjs/lib/experience-config.tsx b/examples/nextjs/lib/experience-config.tsx index 3c8f0bd..38a976a 100644 --- a/examples/nextjs/lib/experience-config.tsx +++ b/examples/nextjs/lib/experience-config.tsx @@ -2,77 +2,37 @@ * The integration layer between Contentful's Experience payload shape and * the customer's design system. * - * The design-system components in `../components/` stay free of any - * `@contentful/*` imports — they remain portable and don't know they're - * being driven by Contentful. This file is where SDK-shaped concerns - * (defaults, async resolvers, slot binding, prop reshaping) live. + * Design-system components live in `../components/` and stay free of any + * `@contentful/*` imports — they remain portable. When a component needs + * SDK runtime context or the raw Contentful payload, it opts in via + * `useExperience()` / `useContentfulComponent()` rather than receiving + * SDK-shaped props it didn't declare. * - * - `components` — `componentTypeId` → `defineComponent(...)`. Keys match - * the segment after the last slash in `componentType.sys.urn`. - * - `templates` — `templateId` → `defineTemplate(...)`. Keys match the - * segment after the last slash in `payload.sys.template.sys.urn`. - * - `experienceConfig` — the composed `Config` object handed to - * `resolveExperience` and ``. + * - `components` — `componentTypeId` → bare component OR config object. + * Keys match the segment after the last slash in `componentType.sys.urn`. + * - `templates` — `templateId` → bare component OR config object. Keys + * match the segment after the last slash in `payload.sys.template.sys.urn`. */ -import type { ReactNode } from 'react'; +import { type Components, type Config, type Templates } from '@contentful/experiences-react'; -import { - defineComponent, - defineTemplate, - type Components, - type Config, - type Templates, -} from '@contentful/experiences-react'; - -import { Button, type ButtonProps } from '@/components/Button'; -import { Header, type HeaderProps } from '@/components/Header'; -import { Page, type PageProps } from '@/components/Page'; -import { Text, type TextProps } from '@/components/Text'; +import { Button } from '@/components/Button'; +import { Header } from '@/components/Header'; +import { Page } from '@/components/Page'; +import { Text } from '@/components/Text'; const components: Components = { - button: defineComponent({ - defaults: { type: 'primary' }, - resolveData: async ({ content, design, experience }) => { - // server side rendering for the contentful design system - // console.log("design", design); - - // add expensive thing here and an async call - return { - text: content.text as string, - children: content.testSlot as ReactNode, - design: design, - experience: experience, - }; - }, - render: (props) => { - // client side rendering for the contentful design system - return +); + const config: Config = { components: { - 'contentful-container': { - render: (props) => { - const { children, cfPadding } = props as { children?: ReactNode; cfPadding?: string }; - return
{children}
; - }, - }, - 'contentful-heading': { - render: (props) => { - const { text, cfFontSize } = props as { text?: string; cfFontSize?: string }; - return

{text}

; - }, - }, - 'contentful-button': { - render: (props) => { - const { label, cfBackgroundColor } = props as { - label?: string; - cfBackgroundColor?: string; - }; - return ( - - ); - }, - }, + 'contentful-container': Container, + 'contentful-heading': Heading, + 'contentful-button': SimpleButton, }, }; @@ -133,7 +126,6 @@ describe('ServerExperienceRenderer', () => { }); it('cascades design values when the active viewport has none', async () => { - // tablet has no cfFontSize defined → cascades to desktop const plan = await resolveExperience(payload, config); const html = renderToStaticMarkup( @@ -152,18 +144,13 @@ describe('ServerExperienceRenderer', () => { ).toBe(''); }); - it('injects experience context with isPreview false by default', async () => { + it('exposes experience context via useExperience() with isPreview false by default', async () => { const seen: Array> = []; - const captureConfig: Config = { - components: { - capture: { - render: ({ experience }) => { - seen.push(experience as Record); - return null; - }, - }, - }, + const Capture = () => { + seen.push(useExperience() as unknown as Record); + return null; }; + const captureConfig: Config = { components: { capture: Capture } }; const plan = await resolveExperience( { viewports: VIEWPORTS, nodes: [componentNode('capture')] }, captureConfig @@ -183,16 +170,11 @@ describe('ServerExperienceRenderer', () => { it('exposes the active viewport on render context (defaults to viewport[0])', async () => { let seen: Record | null = null; - const captureConfig: Config = { - components: { - capture: { - render: ({ experience }) => { - seen = experience as unknown as Record; - return null; - }, - }, - }, + const Capture = () => { + seen = useExperience() as unknown as Record; + return null; }; + const captureConfig: Config = { components: { capture: Capture } }; const plan = await resolveExperience( { viewports: VIEWPORTS, nodes: [componentNode('capture')] }, captureConfig @@ -207,16 +189,11 @@ describe('ServerExperienceRenderer', () => { it('honors initialViewportId when computing the active viewport', async () => { let seen: Record | null = null; - const captureConfig: Config = { - components: { - capture: { - render: ({ experience }) => { - seen = experience as unknown as Record; - return null; - }, - }, - }, + const Capture = () => { + seen = useExperience() as unknown as Record; + return null; }; + const captureConfig: Config = { components: { capture: Capture } }; const plan = await resolveExperience( { viewports: VIEWPORTS, nodes: [componentNode('capture')] }, captureConfig @@ -237,9 +214,7 @@ describe('ServerExperienceRenderer', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const justContainer: Config = { components: { - 'contentful-container': { - render: ({ children }) =>
{children as ReactNode}
, - }, + 'contentful-container': ({ children }: { children?: ReactNode }) =>
{children}
, }, }; const planWithMissing = { @@ -282,18 +257,12 @@ describe('ServerExperienceRenderer', () => { }); it('merges defaults beneath content (content wins)', async () => { - const config: Config = { + const Item = ({ variant, priority }: { variant: string; priority: string }) => ( + + ); + const itemConfig: Config = { components: { - item: { - defaults: { variant: 'fallback', priority: 'low' }, - render: (props) => { - const { variant, priority } = props as { - variant: string; - priority: string; - }; - return ; - }, - }, + item: { component: Item, defaults: { variant: 'fallback', priority: 'low' } }, }, }; const plan = await resolveExperience( @@ -306,25 +275,20 @@ describe('ServerExperienceRenderer', () => { }), ], }, - config + itemConfig ); const html = renderToStaticMarkup( - + ); expect(html).toContain('data-variant="fromContent"'); expect(html).toContain('data-priority="low"'); }); - it('respects merge precedence: defaults < content < design < resolved < slots < experience', () => { - const config: Config = { + it('respects merge precedence: defaults < content < design < resolved < slots', () => { + const Item = ({ value }: { value: string }) => ; + const cfg: Config = { components: { - item: { - defaults: { value: 'fromDefault' }, - render: (props) => { - const { value } = props as { value: string }; - return ; - }, - }, + item: { component: Item, defaults: { value: 'fromDefault' } }, }, }; // Simulate a plan that already went through resolveExperience. @@ -344,31 +308,23 @@ describe('ServerExperienceRenderer', () => { ], }; const html = renderToStaticMarkup( - + ); - // resolved wins over content, content wins over default expect(html).toContain('data-value="fromResolveData"'); }); it('wraps rendered nodes with the registered template', async () => { - const config: Config = { - components: { - item: { - render: ({ value }) => {value as string}, - }, - }, - templates: { - page: { - defaults: { title: 'Default Title' }, - render: ({ title, children }) => ( -
- {children} -
- ), - }, - }, + const Item = ({ value }: { value?: string }) => {value}; + const Template = ({ title, children }: { title?: string; children?: ReactNode }) => ( +
+ {children} +
+ ); + const cfg: Config = { + components: { item: Item }, + templates: { page: { component: Template, defaults: { title: 'Default Title' } } }, }; - const payload: ExperiencePayload = { + const tplPayload: ExperiencePayload = { sys: { template: { sys: { @@ -381,9 +337,9 @@ describe('ServerExperienceRenderer', () => { viewports: VIEWPORTS, nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'inside' } })], }; - const plan = await resolveExperience(payload, config); + const plan = await resolveExperience(tplPayload, cfg); const html = renderToStaticMarkup( - + ); expect(html).toContain('data-template="page"'); expect(html).toContain('data-title="Default Title"'); @@ -392,13 +348,9 @@ describe('ServerExperienceRenderer', () => { it('renders nodes unwrapped + warns when the template is not registered', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const config: Config = { - components: { - item: { render: ({ value }) => {value as string} }, - }, - // No templates registered — the experience references one that's missing. - }; - const payload: ExperiencePayload = { + const Item = ({ value }: { value?: string }) => {value}; + const cfg: Config = { components: { item: Item } }; + const tplPayload: ExperiencePayload = { sys: { template: { sys: { @@ -411,9 +363,9 @@ describe('ServerExperienceRenderer', () => { viewports: VIEWPORTS, nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'unwrapped' } })], }; - const plan = await resolveExperience(payload, config); + const plan = await resolveExperience(tplPayload, cfg); const html = renderToStaticMarkup( - + ); expect(html).toBe('unwrapped'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('missing-template')); @@ -421,19 +373,73 @@ describe('ServerExperienceRenderer', () => { }); }); -describe('ServerExperienceRenderer — contentful prop', () => { - it('passes the raw Contentful payload to components on the contentful prop', async () => { - let captured: Record | null = null; - const cfg: Config = { - components: { - button: { - render: (props) => { - captured = props as unknown as Record; - return null; +describe('ServerExperienceRenderer — bare-component registrations', () => { + it('accepts a bare function component as a registry entry', async () => { + const Bare = ({ text }: { text?: string }) =>

{text}

; + const cfg: Config = { components: { bare: Bare } }; + const plan = await resolveExperience( + { + viewports: VIEWPORTS, + nodes: [componentNode('bare', { id: 'b', contentProperties: { text: 'hi' } })], + }, + cfg + ); + const html = renderToStaticMarkup(); + expect(html).toBe('

hi

'); + }); + + it('accepts a bare component for a template', async () => { + const Item = ({ value }: { value?: string }) => {value}; + const Tpl = ({ children }: { children?: ReactNode }) =>
{children}
; + const cfg: Config = { components: { item: Item }, templates: { page: Tpl } }; + const tplPayload: ExperiencePayload = { + sys: { + template: { + sys: { + type: 'ResourceLink', + linkType: 'Contentful:Template', + urn: 'crn:contentful:::experience:spaces/$self/environments/$self/templates/page', }, }, }, + viewports: VIEWPORTS, + nodes: [componentNode('item', { id: 'i', contentProperties: { value: 'inside' } })], + }; + const plan = await resolveExperience(tplPayload, cfg); + const html = renderToStaticMarkup(); + expect(html).toContain('data-tpl'); + expect(html).toContain('inside'); + }); + + it('does NOT spread experience/contentful as props onto bare components', async () => { + let receivedKeys: string[] = []; + const Probe = (props: Record) => { + receivedKeys = Object.keys(props); + return null; }; + const cfg: Config = { components: { probe: Probe } }; + const plan = await resolveExperience( + { + viewports: VIEWPORTS, + nodes: [componentNode('probe', { id: 'p', contentProperties: { text: 'hi' } })], + }, + cfg + ); + renderToStaticMarkup(); + expect(receivedKeys).toContain('text'); + expect(receivedKeys).not.toContain('experience'); + expect(receivedKeys).not.toContain('contentful'); + }); +}); + +describe('ServerExperienceRenderer — useContentfulComponent / useContentfulTemplate', () => { + it('exposes the raw Contentful payload via useContentfulComponent()', async () => { + let captured: Record | null = null; + const Capture = () => { + captured = useContentfulComponent() as unknown as Record; + return null; + }; + const cfg: Config = { components: { button: Capture } }; const plan = await resolveExperience( { viewports: VIEWPORTS, @@ -449,29 +455,24 @@ describe('ServerExperienceRenderer — contentful prop', () => { ); renderToStaticMarkup(); - expect(captured).not.toBeNull(); - expect(captured!.contentful).toEqual({ + expect(captured).toEqual({ componentTypeId: 'button', nodeId: 'btn-1', content: { label: 'Buy now' }, design: { cfPadding: vbv({ desktop: m('40px') }) }, // raw envelope, NOT scalar resolved: undefined, }); - // Top-level scalar still reflects the viewport-resolved value - expect(captured!.cfPadding).toBe('40px'); }); it('contentful.resolved carries the resolveData return value', async () => { let captured: Record | null = null; + const Capture = () => { + captured = useContentfulComponent() as unknown as Record; + return null; + }; const cfg: Config = { components: { - item: { - resolveData: () => ({ enriched: 'yes' }), - render: (props) => { - captured = props as unknown as Record; - return null; - }, - }, + item: { component: Capture, resolveData: () => ({ enriched: 'yes' }) }, }, }; const plan = await resolveExperience( @@ -480,24 +481,19 @@ describe('ServerExperienceRenderer — contentful prop', () => { ); renderToStaticMarkup(); - expect((captured!.contentful as Record).resolved).toEqual({ enriched: 'yes' }); + expect((captured as Record).resolved).toEqual({ enriched: 'yes' }); }); - it('passes contentful prop to templates with templateId and content/design/resolved', async () => { + it('exposes templateId/content/design/resolved via useContentfulTemplate()', async () => { let captured: Record | null = null; + const CaptureTpl = ({ children }: { children?: ReactNode }) => { + captured = useContentfulTemplate() as unknown as Record; + return
{children}
; + }; + const Item = () => null; const cfg: Config = { - components: { - item: { render: () => null }, - }, - templates: { - page: { - defaults: { title: 'Default' }, - render: (props) => { - captured = props as unknown as Record; - return
; - }, - }, - }, + components: { item: Item }, + templates: { page: { component: CaptureTpl, defaults: { title: 'Default' } } }, }; const plan = await resolveExperience( { @@ -517,7 +513,7 @@ describe('ServerExperienceRenderer — contentful prop', () => { ); renderToStaticMarkup(); - expect(captured!.contentful).toEqual({ + expect(captured).toEqual({ templateId: 'page', content: {}, design: {}, diff --git a/packages/adapter-react/src/server-renderer.tsx b/packages/adapter-react/src/server-renderer.tsx index c830c2f..2d2b62e 100644 --- a/packages/adapter-react/src/server-renderer.tsx +++ b/packages/adapter-react/src/server-renderer.tsx @@ -18,6 +18,7 @@ import type { } from '@contentful/experiences-core'; import { getViewportIndex } from '@contentful/experiences-design'; +import { ExperienceProvider } from './context'; import { MissingComponent } from './missing-component'; import { NodesRenderer, WrapWithTemplate, type RenderUnknown } from './nodes-renderer'; import type { Config, RenderContext } from './types'; @@ -40,11 +41,6 @@ const FALLBACK_VIEWPORT: ViewportDef = { }; export interface ServerExperienceRendererProps { - /** - * The resolved experience returned by `resolveExperience`. The prop is - * named `experience` because it's what customers think of as "the - * experience to render"; `PortableRenderPlan` is the internal IR name. - */ experience: PortableRenderPlan | null | undefined; config: Config; /** Initial viewport seed (e.g. derived from User-Agent). Defaults to viewport[0]. */ @@ -77,13 +73,15 @@ export function ServerExperienceRenderer({ }; return ( - - - + + + + + ); } diff --git a/packages/adapter-react/src/types.ts b/packages/adapter-react/src/types.ts index 95315f0..8adf56a 100644 --- a/packages/adapter-react/src/types.ts +++ b/packages/adapter-react/src/types.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ComponentType, ReactNode } from 'react'; import type { DesignPropValue, @@ -14,8 +14,9 @@ import type { export type { ResolveContext }; /** - * The full Contentful-side payload for a single component instance, surfaced - * on the `contentful` prop of every customer component. Useful for: + * The full Contentful-side payload for a single component instance, exposed + * via `useContentfulComponent()` to any descendant of a rendered Experience + * node. Useful for: * * - Custom design-property resolution outside the SDK's default cascade * - Branching by `componentTypeId` in a generic wrapper component @@ -25,28 +26,22 @@ export type { ResolveContext }; * Design properties stay in their **raw envelope form** here (the same shape * `ctx.design` carries inside `resolveData`). The flat scalars merged into * top-level props are what `resolveDesignProperties` produced after viewport - * cascade. The `contentful` prop is the unprocessed input; the spread props - * are the processed output. + * cascade. This is the unprocessed input; the spread props are the processed + * output. */ export interface ContentfulComponent { - /** The component-type id from the URN's last slash-segment (e.g. `button`). */ componentTypeId: string; - /** Optional. Pass-through of `node.id` from the payload when the editor supplied one. */ nodeId?: string; - /** Editorial values exactly as the payload delivered them. */ content: Record; - /** Design-property envelopes (NOT viewport-resolved). Same shape `ctx.design` carries. */ design: Record; - /** Return value of the component's `resolveData` hook, if any. Undefined when no hook is registered. */ resolved?: Record; } /** * Same shape as `ContentfulComponent`, but for the page-level template. - * Surfaced on the `contentful` prop of the template's render fn. + * Exposed via `useContentfulTemplate()` inside a template's component tree. */ export interface ContentfulTemplate { - /** The template id from the URN's last slash-segment (e.g. `page`). */ templateId: string; content: Record; design: Record; @@ -59,124 +54,82 @@ export interface ContentfulTemplate { * `resolveData` time (resolvers run once before viewport resolution; viewport * changes on the client should not re-trigger resolvers). * - * This type is React-adapter-specific. Other adapters (SwiftUI, Compose, - * Angular, …) will define their own equivalents using the platform's idiomatic - * primitive for "current viewport"; the SDK's runtime-neutral core deliberately - * does not carry an `activeViewport` because each framework computes it - * differently (`matchMedia`, `@Environment`, `LocalConfiguration`, etc.). - * - * Common uses: - * - Branching renders by viewport (`if (experience.activeViewport.id === 'mobile') ...`) - * - Reading viewport metadata (e.g. `displayName`, `previewSize`) for analytics - * - Disabling features on small screens - * - * Customers do NOT need this for design-prop resolution — those are already - * pre-resolved to plain scalars by the renderer before reaching `render`. + * Exposed via `useExperience()` to any descendant of the renderer. */ export interface RenderContext extends ExperienceContext { - /** The currently active viewport — last-matching media query / device trait. */ activeViewport: ViewportDef; - /** Index of `activeViewport` in `viewports`. */ activeViewportIndex: number; } /** - * Props that flow into every customer component. Composed from seven sources, - * spread last-wins by `nodes-renderer`: - * - * 1. defaults (componentConfig.defaults) - * 2. content properties (editorial values from XDA) - * 3. resolved design properties (viewport-cascade unwrapped to scalars) - * 4. resolved data (return value of componentConfig.resolveData) - * 5. slot props (each named slot becomes a pre-rendered subtree) - * 6. `experience` (RenderContext — runtime context + active viewport) - * 7. `contentful` (the raw Contentful-side payload — see `ContentfulComponent`) - * - * Last-wins precedence means a slot named `text` would shadow a content - * property named `text` would shadow a `resolveData` return field named - * `text`. Keep the names you author distinct. - */ -export type ComponentProps = Props & { - experience: RenderContext; - contentful: ContentfulComponent; -}; - -/** - * Customer-supplied configuration for a single component type. Author with - * `defineComponent(...)` for full type inference inside `render` and - * `resolveData`. + * Customer-supplied configuration for a single component type. The `component` + * is a plain React component — it receives only the props you'd expect from + * the payload (content + design + resolveData merged, plus any slot subtrees). + * The Experience runtime context and the raw Contentful payload are NOT + * injected as props; reach for them via `useExperience()` and + * `useContentfulComponent()` when you need them. */ export interface ComponentConfig> { /** * Lowest-precedence prop bag. Merged in before content / design / resolveData / - * slots / experience. Useful for variant fallbacks and similar fixed defaults - * the editorial layer doesn't always supply. + * slots. Useful for variant fallbacks the editorial layer doesn't always supply. */ defaults?: Partial; /** * Optional sync-or-async hook that derives final props from the raw - * Experience inputs. Returns a partial prop bag merged in **after** content - * and design, **before** slots and `experience`. Useful for: - * - * - Reshaping editorial fields (e.g. uppercase, format) - * - Pulling in external data tied to a content field (e.g. price by SKU) - * - Localizing URLs or strings using `experience.metadata` - * - Renaming or dropping fields the editor produces - * - * v1: `slots` are NOT exposed here — they're framework-side, pre-rendered - * during the React pass. + * Experience inputs. Runs once during `resolveExperience`, before render — + * does NOT re-fire on viewport changes. */ resolveData?: (ctx: ResolveContext) => Partial | Promise>; /** - * Required. Pure render of the final composed props. + * The React component to render. Receives the merged prop bag with no + * SDK-shaped extras spread in. */ - render: (props: ComponentProps) => ReactNode; + component: ComponentType; } /** - * Props passed to a template's render fn — its declared `Props` plus a - * fixed `children` (the rendered experience nodes) and the `experience` - * runtime context (RenderContext, with active viewport). Templates wrap - * the experience; their `render` decides how the page-level chrome - * surrounds the rendered tree. + * Registry value. Customers can register a bare React component for the + * common case, or the full `ComponentConfig` shape when they need defaults + * or a `resolveData` hook. + * + * button: Button, // bare + * header: { component: Header, defaults: {...} }, // with defaults + * card: defineComponent({ component: Card, resolveData: ... }), */ -export type TemplateProps = Props & { - children: ReactNode; - experience: RenderContext; - contentful: ContentfulTemplate; -}; +export type Registration> = + | ComponentType + | ComponentConfig; /** - * Customer-supplied configuration for a single page-level template. Author - * with `defineTemplate(...)`. Templates carry the same defaults + - * resolveData shape as components — the only structural difference is that - * a template's render fn always receives a `children: ReactNode` (the - * rendered experience nodes). + * Customer-supplied configuration for a page-level template. The template's + * component receives the rendered Experience nodes as `children`. */ export interface TemplateConfig> { defaults?: Partial; resolveData?: (ctx: ResolveContext) => Partial | Promise>; - render: (props: TemplateProps) => ReactNode; + component: ComponentType; } /** - * Identity helper — returns the template config as-is, but narrows the - * `render` and `resolveData` parameter types to your declared `Props`. - * - * @example - * interface PageTemplateProps { - * title?: string; - * } - * - * export const Page = defineTemplate({ - * defaults: { title: 'Untitled' }, - * render: ({ title, children }) => ( - *
- *

{title}

- * {children} - *
- * ), - * }); + * Registry value for templates. Same dual-shape as component registrations. + */ +export type TemplateRegistration> = + | ComponentType + | TemplateConfig; + +/** + * Identity helper — returns the config as-is, but narrows the `resolveData` + * and `component` parameter types to your declared `Props`. + */ +export function defineComponent>( + config: ComponentConfig +): ComponentConfig { + return config; +} + +/** + * Identity helper — returns the template config as-is, with `Props` narrowing. */ export function defineTemplate>( config: TemplateConfig @@ -185,59 +138,60 @@ export function defineTemplate>( } /** - * Component registry shape — keyed by `componentTypeId` (the segment after - * the last slash in `componentType.sys.urn`). Use this type to annotate a - * standalone `components` const before composing it into `Config`. - * - * Per-component prop narrowing happens at `defineComponent(...)` - * authoring time, not at dispatch time, since the renderer looks up by - * string key — that's why the value type erases per-component props. + * Component registry — keyed by `componentTypeId` (last slash-segment of + * `componentType.sys.urn`). Per-entry prop narrowing happens at the + * `defineComponent` call site (or implicitly when the bare component + * shorthand is used). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineComponent authoring time. -export type Components = Record>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineComponent / component-type author time. +export type Components = Record>; /** - * Template registry shape — keyed by `templateId` (the segment after the - * last slash in `payload.sys.template.sys.urn`). Use this type to annotate - * a standalone `templates` const before composing it into `Config`. + * Template registry — keyed by `templateId` (last slash-segment of + * `payload.sys.template.sys.urn`). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineTemplate authoring time. -export type Templates = Record>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineTemplate / template author time. +export type Templates = Record>; -/** - * The customer-supplied experience config. Compose `Components` + - * `Templates` into a single `Config` and hand it to `resolveExperience` and - * ``. - */ export interface Config { components: Components; templates?: Templates; } /** - * Identity helper — returns the config object as-is, but narrows the - * `render` and `resolveData` parameter types to your declared `Props` so - * destructuring inside those functions is fully typed without manual casts. + * Normalize a registry entry — bare component OR config object — into the + * common `ComponentConfig` shape used by the renderer. Exported for tests + * and for adapters that want to introspect the registry. * - * @example - * interface ButtonProps { - * text?: string; - * url?: string; - * type?: 'primary' | 'secondary'; - * } - * - * export const Button = defineComponent({ - * defaults: { type: 'primary' }, - * resolveData: ({ content, experience }) => ({ - * url: localize(content.url, experience.metadata.locale), - * }), - * render: ({ text, url, type }) => ( - * {text} - * ), - * }); + * React function components are functions; `React.memo` / `React.forwardRef` + * yield objects with a `$$typeof` symbol. Both qualify as "bare component" + * for our purposes — the discriminator is the presence of a literal + * `component` field with no `$$typeof`. */ -export function defineComponent>( - config: ComponentConfig -): ComponentConfig { - return config; +export function normalizeComponentRegistration

( + reg: Registration

+): ComponentConfig

{ + if ( + typeof reg === 'object' && + reg !== null && + !('$$typeof' in reg) && + 'component' in reg + ) { + return reg as ComponentConfig

; + } + return { component: reg as ComponentType

}; +} + +export function normalizeTemplateRegistration

( + reg: TemplateRegistration

+): TemplateConfig

{ + if ( + typeof reg === 'object' && + reg !== null && + !('$$typeof' in reg) && + 'component' in reg + ) { + return reg as TemplateConfig

; + } + return { component: reg as ComponentType

}; } diff --git a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte index 3505aaa..36c0025 100644 --- a/packages/adapter-svelte/src/ClientExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ClientExperienceRenderer.svelte @@ -1,12 +1,12 @@ -{#if experience && renderContext} - +{#if experience} + {#snippet children()} {/snippet} diff --git a/packages/adapter-svelte/src/MissingComponent.svelte b/packages/adapter-svelte/src/MissingComponent.svelte index c1e1f61..59a11c7 100644 --- a/packages/adapter-svelte/src/MissingComponent.svelte +++ b/packages/adapter-svelte/src/MissingComponent.svelte @@ -7,8 +7,10 @@ --> + +{#if componentConfig && composed} + {@const Cmp = componentConfig.component} + +{:else} + {@const Unknown = renderUnknown} + +{/if} diff --git a/packages/adapter-svelte/src/NodesRenderer.svelte b/packages/adapter-svelte/src/NodesRenderer.svelte index 3760562..caca2cb 100644 --- a/packages/adapter-svelte/src/NodesRenderer.svelte +++ b/packages/adapter-svelte/src/NodesRenderer.svelte @@ -1,33 +1,38 @@ {#each nodes as node, index (node.nodeId ?? index)} - {@const componentConfig = config.components[node.registration.componentTypeId]} - {#if componentConfig} - {@const Cmp = componentConfig.component} - {#snippet slot(slotName: string)} - {@const children = node.slots[slotName] ?? []} - - {/snippet} - - {:else} - {@const Unknown = renderUnknown} - - {/if} + {#snippet children()} + {@const childrenNodes = (node.slots.children ?? []) as PortableRenderNode[]} + + {/snippet} + {/each} diff --git a/packages/adapter-svelte/src/ServerExperienceRenderer.svelte b/packages/adapter-svelte/src/ServerExperienceRenderer.svelte index 84f2270..a873e50 100644 --- a/packages/adapter-svelte/src/ServerExperienceRenderer.svelte +++ b/packages/adapter-svelte/src/ServerExperienceRenderer.svelte @@ -15,6 +15,7 @@ import NodesRenderer from './NodesRenderer.svelte'; import WrapWithTemplate from './WrapWithTemplate.svelte'; import type { ServerExperienceRendererProps } from './component-props.js'; + import { setExperience } from './context.js'; import type { RenderContext } from './types.js'; const DEFAULT_CONTEXT: ExperienceContext = { @@ -23,10 +24,6 @@ viewports: [], }; - // Fallback used when an experience payload arrives with no declared viewports. - // `getValueForViewport` will treat any design value as `undefined` because - // the cascade list is empty, so this only exists to keep `activeViewport` - // non-null in the render context's type. const FALLBACK_VIEWPORT: ViewportDef = { id: '_', query: '*', @@ -42,22 +39,24 @@ renderUnknown = MissingComponent, }: ServerExperienceRendererProps = $props(); - const renderContext = $derived.by((): RenderContext | null => { - if (!experience) return null; - const activeViewportIndex = getViewportIndex(experience.viewports, initialViewportId); - const activeViewport = experience.viewports[activeViewportIndex] ?? FALLBACK_VIEWPORT; + function buildContext(): RenderContext { + const viewports = experience?.viewports ?? []; + const idx = experience ? getViewportIndex(experience.viewports, initialViewportId) : 0; return { ...DEFAULT_CONTEXT, ...context, metadata: { ...DEFAULT_CONTEXT.metadata, ...(context?.metadata ?? {}) }, - viewports: experience.viewports, - activeViewport, - activeViewportIndex, + viewports, + activeViewport: experience?.viewports[idx] ?? FALLBACK_VIEWPORT, + activeViewportIndex: idx, }; - }); + } + + const renderContext = buildContext(); + setExperience(renderContext); -{#if experience && renderContext} +{#if experience} {#snippet children()} { if (template && !templateConfig && typeof console !== 'undefined') { @@ -42,21 +58,13 @@ experience.viewports, experience.activeViewportIndex )} - {@const contentful = { - templateId: template.templateId, - content: template.props.content, - design: template.props.design, - resolved: template.props.resolved, - } satisfies ContentfulTemplate} - {@const composedProps = { + {@const composed = { ...templateConfig.defaults, ...template.props.content, ...resolvedDesign, ...template.props.resolved, - experience, - contentful, }} - + {:else} {@render children()} {/if} diff --git a/packages/adapter-svelte/src/component-props.ts b/packages/adapter-svelte/src/component-props.ts index b655154..8fa7e0e 100644 --- a/packages/adapter-svelte/src/component-props.ts +++ b/packages/adapter-svelte/src/component-props.ts @@ -15,7 +15,7 @@ import type { Component } from 'svelte'; import type { ExperienceContext, PortableRenderPlan } from '@contentful/experiences-core'; -import type { Config, RenderContext } from './types.js'; +import type { Config } from './types.js'; export interface ServerExperienceRendererProps { experience: PortableRenderPlan | null | undefined; @@ -31,7 +31,6 @@ export interface MissingComponentProps { componentTypeId: string; /** Optional — only present when the payload supplied an id for this node. */ nodeId?: string; - experience: RenderContext; } export type RenderUnknown = Component; diff --git a/packages/adapter-svelte/src/context.ts b/packages/adapter-svelte/src/context.ts new file mode 100644 index 0000000..7aba45f --- /dev/null +++ b/packages/adapter-svelte/src/context.ts @@ -0,0 +1,60 @@ +/* + * Svelte context keys + helpers for runtime escape hatches. + * + * The renderer no longer injects `experience` / `contentful` as props onto + * customer components. Components stay plain Svelte with their own prop + * type and receive only the merged content / design / resolveData / slot + * Snippet prop bag. + * + * When a component needs the runtime context or the raw Contentful payload, + * it calls the helper explicitly. This makes the coupling visible at the + * call site and keeps unused-context components free of extra props. + * + * Reactivity: the renderer stores a `$state` proxy in context, so reads + * through the returned object (`exp.activeViewport`, in template or in a + * `$derived`) stay reactive across client viewport changes. Calling + * `getExperience()` once and destructuring loses that reactivity — same + * rule as Svelte 5 `$props()`. + * + * NOTE: `getContext` / `setContext` must be called during synchronous + * component initialization (top of the ` diff --git a/packages/adapter-svelte/src/test-fixtures/ContainerFixture.svelte b/packages/adapter-svelte/src/test-fixtures/ContainerFixture.svelte index 404313a..ff3b2d4 100644 --- a/packages/adapter-svelte/src/test-fixtures/ContainerFixture.svelte +++ b/packages/adapter-svelte/src/test-fixtures/ContainerFixture.svelte @@ -2,13 +2,13 @@ import type { Snippet } from 'svelte'; let { cfPadding, - slot, + children, }: { cfPadding?: string; - slot?: Snippet<[string]>; + children?: Snippet; } = $props();

- {#if slot}{@render slot('children')}{/if} + {#if children}{@render children()}{/if}
diff --git a/packages/adapter-svelte/src/test-fixtures/capture-sink.ts b/packages/adapter-svelte/src/test-fixtures/capture-sink.ts new file mode 100644 index 0000000..3ae370a --- /dev/null +++ b/packages/adapter-svelte/src/test-fixtures/capture-sink.ts @@ -0,0 +1,15 @@ +/* + * Shared mutable sink for the CapturingComponent test fixture. Tests + * `.splice(0)` between runs to reset. + */ + +import type { ContentfulComponent } from '../types.js'; +import type { RenderContext } from '../types.js'; + +export interface Capture { + props: Record; + experience: RenderContext; + contentful: ContentfulComponent | undefined; +} + +export const captureSink: Capture[] = []; diff --git a/packages/adapter-svelte/src/types.ts b/packages/adapter-svelte/src/types.ts index 7156466..d3801b6 100644 --- a/packages/adapter-svelte/src/types.ts +++ b/packages/adapter-svelte/src/types.ts @@ -1,4 +1,4 @@ -import type { Component, Snippet } from 'svelte'; +import type { Component } from 'svelte'; import type { DesignPropValue, @@ -10,39 +10,41 @@ import type { export type { ResolveContext }; /** - * The full Contentful-side payload for a single component instance, surfaced - * on the `contentful` prop of every customer component. Useful for: + * The full Contentful-side payload for a single component instance, exposed + * via `getContentfulComponent()` to any descendant of a rendered Experience + * node. Useful for: * * - Custom design-property resolution outside the SDK's default cascade * - Branching by `componentTypeId` in a generic wrapper component * - Attaching analytics to specific node ids * - Debugging — rendering a `
` block with the raw payload in preview + * - Rendering non-`children` slots through `` directly * * Design properties stay in their **raw envelope form** here (the same shape * `ctx.design` carries inside `resolveData`). The flat scalars merged into * top-level props are what `resolveDesignProperties` produced after viewport - * cascade. The `contentful` prop is the unprocessed input; the spread props - * are the processed output. + * cascade. */ export interface ContentfulComponent { - /** The component-type id from the URN's last slash-segment (e.g. `button`). */ componentTypeId: string; - /** Optional. Pass-through of `node.id` from the payload when the editor supplied one. */ nodeId?: string; - /** Editorial values exactly as the payload delivered them. */ content: Record; - /** Design-property envelopes (NOT viewport-resolved). Same shape `ctx.design` carries. */ design: Record; - /** Return value of the component's `resolveData` hook, if any. Undefined when no hook is registered. */ resolved?: Record; + /** + * Raw per-slot node arrays from the payload. The default slot named + * `children` is rendered automatically and passed as a Snippet prop; + * additional slots (if any) are reachable here and can be rendered with + * ``. + */ + slots: Record; } /** * Same shape as `ContentfulComponent`, but for the page-level template. - * Surfaced on the `contentful` prop of the template's component. + * Exposed via `getContentfulTemplate()` inside a template's component tree. */ export interface ContentfulTemplate { - /** The template id from the URN's last slash-segment (e.g. `page`). */ templateId: string; content: Record; design: Record; @@ -52,160 +54,101 @@ export interface ContentfulTemplate { /** * Render-time experience context. Extends the core `ExperienceContext` with * the active viewport — info that only exists at render time, not at - * `resolveData` time (resolvers run once before viewport resolution; viewport - * changes on the client should not re-trigger resolvers). - * - * This type is Svelte-adapter-specific. Other adapters (React, SwiftUI, - * Compose, Angular, …) define their own equivalents using the platform's - * idiomatic primitive for "current viewport"; the SDK's runtime-neutral core - * deliberately does not carry an `activeViewport` because each framework - * computes it differently (`matchMedia`, `@Environment`, `LocalConfiguration`, - * etc.). - * - * Common uses: - * - Branching renders by viewport (`if (experience.activeViewport.id === 'mobile') ...`) - * - Reading viewport metadata (e.g. `displayName`, `previewSize`) for analytics - * - Disabling features on small screens - * - * Customers do NOT need this for design-prop resolution — those are already - * pre-resolved to plain scalars by the renderer before reaching the component. + * `resolveData` time. Exposed via `getExperience()`. */ export interface RenderContext extends ExperienceContext { - /** The currently active viewport — last-matching media query / device trait. */ activeViewport: ViewportDef; - /** Index of `activeViewport` in `viewports`. */ activeViewportIndex: number; } /** - * Props that flow into every customer component. Composed from five sources, - * spread last-wins by `NodesRenderer`: - * - * 1. defaults (componentConfig.defaults) - * 2. content properties (editorial values from XDA) - * 3. resolved design properties (viewport-cascade unwrapped to scalars) - * 4. resolved data (return value of componentConfig.resolveData) - * 5. `experience` (RenderContext — runtime context + active viewport) - * 6. `slot` (Snippet dispatcher — see below) - * - * Slots arrive as a single `slot: Snippet<[string]>` dispatcher. Render any - * named slot with `{@render slot('children')}`, `{@render slot('header')}`, - * etc. Unknown slot names render nothing. - * - * Why one dispatcher and not one named prop per slot like React? Svelte 5 - * Snippets are compile-time entities; `Record` synthesized - * from runtime payload data can't be spread as named props. The single - * dispatcher is the idiomatic Svelte workaround. - */ -export type ComponentProps = Props & { - experience: RenderContext; - slot: Snippet<[string]>; - contentful: ContentfulComponent; -}; - -/** - * Customer-supplied configuration for a single component type. Author with - * `defineComponent(...)` for full type inference inside `resolveData`. + * Customer-supplied configuration for a single component type. The `component` + * is a Svelte 5 Component — it receives only the merged prop bag (defaults + + * content + design + resolveData + slot Snippets). The Experience runtime + * context and the raw Contentful payload are NOT injected as props; reach for + * them via `getExperience()` / `getContentfulComponent()`. * - * The `component` field is the default export of a `.svelte` file (a Svelte 5 - * Component constructor). The renderer instantiates it with composed props. + * Slots are spread as named Snippet props. Customer writes + * `let { children, header }: { children?: Snippet; header?: Snippet } = $props()` + * and renders with `{@render children?.()}`. */ export interface ComponentConfig> { - /** - * Lowest-precedence prop bag. Merged in before content / design / resolveData / - * slots / experience. Useful for variant fallbacks and similar fixed defaults - * the editorial layer doesn't always supply. - */ defaults?: Partial; - /** - * Optional sync-or-async hook that derives final props from the raw - * Experience inputs. Returns a partial prop bag merged in **after** content - * and design, **before** slots and `experience`. Useful for: - * - * - Reshaping editorial fields (e.g. uppercase, format) - * - Pulling in external data tied to a content field (e.g. price by SKU) - * - Localizing URLs or strings using `experience.metadata` - * - Renaming or dropping fields the editor produces - * - * v1: `slots` are NOT exposed here — they're framework-side, pre-rendered - * during the Svelte pass. - */ resolveData?: (ctx: ResolveContext) => Partial | Promise>; - /** - * Required. The default export of a `.svelte` file — a Svelte 5 Component. - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Svelte's Component type uses internal generics that don't narrow ergonomically here. component: Component; } /** - * Props passed to a template's component — its declared `Props` plus a - * fixed `children` Snippet (the rendered experience nodes) and the - * `experience` runtime context (RenderContext, with active viewport). + * Registry value. Customers can register a bare Svelte component for the + * common case, or the full `ComponentConfig` shape when they need defaults + * or a `resolveData` hook. * - * Render the children Snippet with `{@render children()}`. + * button: Button, // bare + * header: { component: Header, defaults: {...} }, // with defaults + * card: defineComponent({ component: Card, resolveData: ... }), */ -export type TemplateProps = Props & { - children: Snippet; - experience: RenderContext; - contentful: ContentfulTemplate; -}; +export type Registration> = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Component + | ComponentConfig; -/** - * Customer-supplied configuration for a single page-level template. Author - * with `defineTemplate(...)`. Templates carry the same defaults + - * resolveData shape as components — the only structural difference is that - * a template's component receives a `children: Snippet` (the rendered - * experience nodes). - */ export interface TemplateConfig> { defaults?: Partial; resolveData?: (ctx: ResolveContext) => Partial | Promise>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see ComponentConfig.component note + // eslint-disable-next-line @typescript-eslint/no-explicit-any component: Component; } -/** - * Identity helper — returns the template config as-is, but narrows the - * `resolveData` parameter types to your declared `Props`. - */ +export type TemplateRegistration> = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Component + | TemplateConfig; + export function defineTemplate>( config: TemplateConfig ): TemplateConfig { return config; } -/** - * Component registry shape — keyed by `componentTypeId` (the segment after - * the last slash in `componentType.sys.urn`). Use this type to annotate a - * standalone `components` const before composing it into `Config`. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineComponent authoring time. -export type Components = Record>; +export function defineComponent>( + config: ComponentConfig +): ComponentConfig { + return config; +} -/** - * Template registry shape — keyed by `templateId` (the segment after the - * last slash in `payload.sys.template.sys.urn`). - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- per-entry narrowing happens at defineTemplate authoring time. -export type Templates = Record>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Components = Record>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Templates = Record>; -/** - * The customer-supplied experience config. Compose `Components` + - * `Templates` into a single `Config` and hand it to `resolveExperience` and - * ``. - */ export interface Config { components: Components; templates?: Templates; } /** - * Identity helper — returns the config object as-is, but narrows the - * `resolveData` parameter types to your declared `Props`. + * Normalize a registry entry — bare Svelte component OR config object — + * into the common `ComponentConfig` shape used by the renderer. Svelte 5 + * Components are functions (callable); config objects have a `component` + * field. We discriminate by `typeof`. */ -export function defineComponent>( - config: ComponentConfig -): ComponentConfig { - return config; +export function normalizeComponentRegistration

( + reg: Registration

+): ComponentConfig

{ + if (typeof reg === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { component: reg as Component } as ComponentConfig

; + } + return reg; +} + +export function normalizeTemplateRegistration

( + reg: TemplateRegistration

+): TemplateConfig

{ + if (typeof reg === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { component: reg as Component } as TemplateConfig

; + } + return reg; } diff --git a/packages/core/src/resolve-experience.ts b/packages/core/src/resolve-experience.ts index 4fa6761..f29f656 100644 --- a/packages/core/src/resolve-experience.ts +++ b/packages/core/src/resolve-experience.ts @@ -30,27 +30,33 @@ import type { } from './types'; /** - * Structural type the resolver walker depends on. Matches the React - * adapter's `Config` shape but doesn't require importing it — render-core - * stays decoupled from React. + * Structural type the resolver walker depends on. Matches the React / + * Svelte adapter `Config` shape but doesn't require importing them — + * render-core stays decoupled from any framework. + * + * Registry values are typed as `unknown` because each adapter accepts + * either a bare framework component (function / Svelte class / etc.) OR + * a config-object shape with `{ component, defaults?, resolveData? }`. + * The resolver only cares about `resolveData`; it duck-types each entry + * at runtime and ignores anything without it. */ export interface ResolverConfig { - components: Record< - string, - { - resolveData?: ( - ctx: ResolveContext - ) => Record | Promise>; - } - >; - templates?: Record< - string, - { - resolveData?: ( + components: Record; + templates?: Record; +} + +function getResolver( + entry: unknown +): + | ((ctx: ResolveContext) => Record | Promise>) + | undefined { + if (typeof entry !== 'object' || entry === null) return undefined; + const candidate = (entry as { resolveData?: unknown }).resolveData; + return typeof candidate === 'function' + ? (candidate as ( ctx: ResolveContext - ) => Record | Promise>; - } - >; + ) => Record | Promise>) + : undefined; } export interface ResolveExperienceOptions { @@ -135,7 +141,7 @@ function buildNode( slots, }; if (node.id) built.nodeId = node.id; - if (config.components[componentTypeId]?.resolveData) { + if (getResolver(config.components[componentTypeId])) { nodeRefs.push(built); } return built; @@ -193,7 +199,7 @@ export async function resolveExperience( const tasks: Array> = []; for (const node of nodeRefs) { - const resolver = config.components[node.registration.componentTypeId]?.resolveData; + const resolver = getResolver(config.components[node.registration.componentTypeId]); if (!resolver) continue; const ctx: ResolveContext = { content: node.props.content, @@ -208,7 +214,7 @@ export async function resolveExperience( } if (template) { - const tplResolver = config.templates?.[template.templateId]?.resolveData; + const tplResolver = getResolver(config.templates?.[template.templateId]); if (tplResolver) { const ctx: ResolveContext = { content: template.props.content, From 5d9f7d066b577a0034f72c7334c2f65fcb377330 Mon Sep 17 00:00:00 2001 From: Max Toball Date: Tue, 7 Jul 2026 10:53:47 +0200 Subject: [PATCH 2/9] docs: rescope comments from proposal narrative to current API Rework docstrings and inline comments across the adapter packages and examples to describe the current API instead of contrasting against the previous shape. Drops "no longer injects", "escape hatch", "used to be", etc.; keeps the reasoning behind non-obvious behavior. Also removes a stale reference to a non-existent `DebugPanel` from the advanced example's docstring. Co-Authored-By: Claude Opus 4.7 --- .../nextjs/lib/experience-config-advanced.tsx | 15 ++--- examples/nextjs/lib/experience-config.tsx | 11 ++-- .../sveltekit/src/lib/experience-config.ts | 11 ++-- .../adapter-react/src/client-renderer.tsx | 5 +- packages/adapter-react/src/context.tsx | 29 +++------- packages/adapter-react/src/index.ts | 2 +- packages/adapter-react/src/nodes-renderer.tsx | 14 ++--- packages/adapter-react/src/types.ts | 58 ++++++++----------- .../src/ClientExperienceRenderer.svelte | 4 +- .../adapter-svelte/src/NodesRenderer.svelte | 16 +++-- packages/adapter-svelte/src/context.ts | 31 +++++----- packages/adapter-svelte/src/index.ts | 2 +- .../test-fixtures/CapturingComponent.svelte | 6 +- packages/adapter-svelte/src/types.ts | 39 ++++++------- 14 files changed, 100 insertions(+), 143 deletions(-) diff --git a/examples/nextjs/lib/experience-config-advanced.tsx b/examples/nextjs/lib/experience-config-advanced.tsx index 3fb00e4..d3fb5a9 100644 --- a/examples/nextjs/lib/experience-config-advanced.tsx +++ b/examples/nextjs/lib/experience-config-advanced.tsx @@ -1,19 +1,14 @@ /** * Advanced integration config — shows the knobs you reach for when the - * simple config (`./experience-config.tsx`) isn't enough. + * simple config (`./experience-config.tsx`) isn't enough: * - * Differences from the simple config: * - **Async `resolveData`** on `button` — a deliberately slow fake fetch, * plus a synthetic localized URL derived from `experience.metadata.locale`. - * Demonstrates that resolvers run **in parallel across nodes** before - * rendering, and that they have access to `experience.metadata` (which the - * advanced page passes in via `resolveExperience`'s third argument). - * - **Reads `experience.metadata`** to build a per-page URL — proves the + * Resolvers run in parallel across nodes before rendering, and they + * receive `experience.metadata` (which the advanced page passes in via + * `resolveExperience`'s third argument). + * - **Reads `experience.metadata`** to build a per-page URL, proving that * metadata is threaded through every resolver. - * - **DebugPanel** — a generic wrapper component using `useContentfulComponent` - * to render a `

` block in preview mode. Demonstrates the runtime - * escape hatch: customer code reaches the raw payload without the SDK - * spreading it onto every component. */ import { defineComponent, type Components, type Config, type Templates } from '@contentful/experiences-react'; diff --git a/examples/nextjs/lib/experience-config.tsx b/examples/nextjs/lib/experience-config.tsx index 38a976a..26a20fd 100644 --- a/examples/nextjs/lib/experience-config.tsx +++ b/examples/nextjs/lib/experience-config.tsx @@ -3,10 +3,9 @@ * the customer's design system. * * Design-system components live in `../components/` and stay free of any - * `@contentful/*` imports — they remain portable. When a component needs - * SDK runtime context or the raw Contentful payload, it opts in via - * `useExperience()` / `useContentfulComponent()` rather than receiving - * SDK-shaped props it didn't declare. + * `@contentful/*` imports. A component that needs the Experience runtime + * context or the raw Contentful payload calls `useExperience()` / + * `useContentfulComponent()` at its top. * * - `components` — `componentTypeId` → bare component OR config object. * Keys match the segment after the last slash in `componentType.sys.urn`. @@ -22,11 +21,11 @@ import { Page } from '@/components/Page'; import { Text } from '@/components/Text'; const components: Components = { - // Bare-component registrations — no defaults, no resolveData, no boilerplate. + // Bare-component form — for the common case with no defaults / resolveData. button: Button, text: Text, - // Config-object shape when you need defaults / resolveData. + // Config-object form — when you need defaults or resolveData. header: { component: Header, defaults: { variant: 'h2', text: 'Hello World' } }, }; diff --git a/examples/sveltekit/src/lib/experience-config.ts b/examples/sveltekit/src/lib/experience-config.ts index d30cb39..210e0ea 100644 --- a/examples/sveltekit/src/lib/experience-config.ts +++ b/examples/sveltekit/src/lib/experience-config.ts @@ -3,10 +3,9 @@ * the customer's design system. * * Design-system components in `./components/` stay free of any - * `@contentful/*` imports — they remain portable. When a component needs - * SDK runtime context or the raw Contentful payload, it opts in via - * `getExperience()` / `getContentfulComponent()` rather than receiving - * SDK-shaped props it didn't declare. + * `@contentful/*` imports. A component that needs the Experience runtime + * context or the raw Contentful payload calls `getExperience()` / + * `getContentfulComponent()` at the top of its `