From f3eb7d20d9e8861f330ec9e52f08853e22042ec5 Mon Sep 17 00:00:00 2001 From: Max Toball Date: Mon, 1 Jun 2026 08:52:19 +0200 Subject: [PATCH] feat(react-web-sdk): add OptimizedExperience component Encapsulates the full render pipeline for a personalized Experience tree returned by the XDA personalized read path: tree walk + slot recursion, component dispatch via a consumer-supplied componentMap, design/content prop spreading, and the per-leaf useOptimizedNode ref stamping that NodeViewRuntime needs to fire view events. Adds one outer wrapper that resolves to the Experience attribution layer so the Experience-level view event fires exactly once regardless of tree size, plus per-leaf dedup so each non-Experience scope (persisted Fragment, etc.) emits at most one event per render. OptimizationProvider continues to own the global config (clientId, environment, autoTrackNodeInteraction); this component is the per-Experience rendering surface that consumes that context. Co-Authored-By: Claude Opus 4.7 --- .../web/frameworks/react-web-sdk/src/index.ts | 7 + .../optimized-entry/OptimizedExperience.tsx | 296 ++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedExperience.tsx diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts index ce28f096..03800e1e 100644 --- a/packages/web/frameworks/react-web-sdk/src/index.ts +++ b/packages/web/frameworks/react-web-sdk/src/index.ts @@ -21,6 +21,13 @@ export type { OptimizedEntryLoadingFallback, OptimizedEntryProps, } from './optimized-entry/OptimizedEntry' +export { OptimizedExperience } from './optimized-entry/OptimizedExperience' +export type { + ExperienceComponentMap, + ExperienceComponentTypeLink, + ExperienceTreeNode, + OptimizedExperienceProps, +} from './optimized-entry/OptimizedExperience' export { useOptimizedEntry } from './optimized-entry/useOptimizedEntry' export type { UseOptimizedEntryParams, diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedExperience.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedExperience.tsx new file mode 100644 index 00000000..7d7c0173 --- /dev/null +++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedExperience.tsx @@ -0,0 +1,296 @@ +import { resolveNodeViewPayload } from '@contentful/optimization-web' +import type { SourceMap } from '@contentful/optimization-web/api-schemas' +import { useMemo, type ElementType, type JSX, type ReactNode } from 'react' +import { useOptimizedNode } from './useOptimizedNode' + +/** + * Resource link payload pointing at the ComponentType definition for an + * Experience node. The relevant id is the trailing segment of the + * `componentTypes/` portion of the urn. Inlined here rather than imported + * from `@contentful/experiences-api-schemas`, which is not a public package. + * + * @public + */ +export interface ExperienceComponentTypeLink { + sys: { + type: 'ResourceLink' + linkType: 'Contentful:ComponentType' + urn: string + } +} + +/** + * Minimal shape of a hydrated Experience tree node as returned by the + * delivery/view synthesis pipeline. Only the fields {@link OptimizedExperience} + * actually consumes are required; consumers may extend with additional fields. + * + * @public + */ +export interface ExperienceTreeNode { + id?: string + componentType: ExperienceComponentTypeLink + contentProperties?: Record + designProperties?: Record> + slots?: Record +} + +/** + * Map of `componentType` id (the trailing urn segment) to the React component + * that should render nodes of that type. Components receive the merged content + * + design property bag as props, plus `children` when the node has a + * `children` slot. + * + * @public + */ +/** + * `ElementType` accepts both coded components (`ComponentType

`) and + * intrinsic tags (`"div"`, `"section"`, …); matches what host dispatch + * tables typically already look like and avoids forcing per-call casts. + */ +export type ExperienceComponentMap = Record + +/** + * Props for the {@link OptimizedExperience} component. + * + * @public + */ +export interface OptimizedExperienceProps { + /** Hydrated Experience tree returned by the personalized read path. */ + nodes: ExperienceTreeNode[] + /** Source-map from `extensions.sourceMap` of the same response. */ + sourceMap?: SourceMap + /** Lookup table mapping `componentType` ids to coded components. */ + componentMap: ExperienceComponentMap + /** + * Viewport key used to flatten `designProperties` into a single prop bag. + * Each design property is a record keyed by viewport; we pick one value + * here so coded components see plain props. Defaults to `"test-desktop"` + * to match the current demo configuration. + */ + viewportId?: string + /** + * Rendered when `componentMap` has no entry for a node's component type. + * Defaults to `null` so unknown nodes are silently dropped. Pass a + * component to surface placeholder UI during development. + */ + renderUnsupported?: (params: { componentTypeId: string; node: ExperienceTreeNode }) => ReactNode +} + +const EMPTY_SOURCE_MAP: SourceMap = { variants: [], layers: [], nodes: {} } + +const COMPONENT_TYPE_URN_PATTERN = /\/componentTypes\/([^/]+)$/ + +function getComponentTypeId(node: ExperienceTreeNode): string { + return COMPONENT_TYPE_URN_PATTERN.exec(node.componentType.sys.urn)?.[1] ?? '' +} + +/** + * Picks any leaf whose source-map attribution resolves to the Experience. + * Its node id is fed to the outer wrapper so the resolver walks up to the + * Experience layer and stamps Experience-attributed `data-ctfl-*` attributes + * on a single host element. + */ +function findExperienceAttributedLeafId( + nodes: ExperienceTreeNode[], + sourceMap: SourceMap, +): string | undefined { + for (const node of nodes) { + if (node.id) { + const payload = resolveNodeViewPayload(node.id, sourceMap) + if (payload?.entityKind === 'Experience') { + return node.id + } + } + if (node.slots) { + for (const slot of Object.values(node.slots)) { + const found = findExperienceAttributedLeafId(slot, sourceMap) + if (found) return found + } + } + } + return undefined +} + +/** + * Set of inner leaf ids whose attribution target is not the Experience, + * deduplicated to one node id per unique `(entityKind, entityId)` pair. The + * outer wrapper is the sole emitter for the Experience event; this set + * ensures each non-Experience scope (a persisted Fragment, an inline + * fragment, etc.) emits at most one view event no matter how many coded + * leaves share it. + */ +function selectInnerFiringNodeIds(nodes: ExperienceTreeNode[], sourceMap: SourceMap): Set { + const seen = new Set() + const firing = new Set() + + const visit = (items: ExperienceTreeNode[]): void => { + for (const node of items) { + if (node.id) { + const payload = resolveNodeViewPayload(node.id, sourceMap) + if (payload && payload.entityKind !== 'Experience') { + const key = `${payload.entityKind}:${payload.entityId}` + if (!seen.has(key)) { + seen.add(key) + firing.add(node.id) + } + } + } + if (node.slots) { + for (const slot of Object.values(node.slots)) { + visit(slot) + } + } + } + } + + visit(nodes) + return firing +} + +interface ExperienceWrapperProps { + nodeId: string + sourceMap: SourceMap + children: ReactNode +} + +function ExperienceWrapper({ nodeId, sourceMap, children }: ExperienceWrapperProps): JSX.Element { + const { ref } = useOptimizedNode({ nodeId, sourceMap }) + return

}>{children}
+} + +interface NodeViewProps { + node: ExperienceTreeNode + sourceMap: SourceMap + firingNodeIds: Set + componentMap: ExperienceComponentMap + viewportId: string + renderUnsupported: OptimizedExperienceProps['renderUnsupported'] + parentId: string + index: number +} + +function NodeView({ + node, + sourceMap, + firingNodeIds, + componentMap, + viewportId, + renderUnsupported, + parentId, + index, +}: NodeViewProps): ReactNode { + // Inner leaves only stamp ids when this leaf is the dedup winner for its + // attribution target; the Experience event itself is emitted by the outer + // wrapper. Passing an empty id is the SDK's documented no-op signal — the + // ref callback clears any prior attributes without registering observers. + const sdkNodeId = node.id && firingNodeIds.has(node.id) ? node.id : '' + const { ref } = useOptimizedNode({ nodeId: sdkNodeId, sourceMap }) + + const componentTypeId = getComponentTypeId(node) + const { [componentTypeId]: Component } = componentMap + + if (!Component) { + return renderUnsupported ? <>{renderUnsupported({ componentTypeId, node })} : null + } + + const resolvedDesignProperties = Object.entries(node.designProperties ?? {}).reduce< + Record + >((acc, [key, byViewport]) => { + const { [viewportId]: viewportValue } = byViewport + acc[key] = viewportValue + return acc + }, {}) + + const children = node.slots?.children?.map((child, childIndex) => ( + + )) + + // The ref-bearing wrapper MUST have a real layout box: NodeViewRuntime's + // IntersectionObserver only fires for elements with non-zero bounding + // rects, and `display: contents` removes the box from layout. + return ( +
} + key={`${node.id ?? componentTypeId}-${parentId}-${index}`} + > + + {children} + +
+ ) +} + +/** + * Render a hydrated personalized Experience tree end-to-end: tree walk, + * component dispatch via {@link ExperienceComponentMap}, design/content prop + * spread, and `data-ctfl-*` ref stamping that wires each node into the + * `NodeViewRuntime` for automatic view-event emission. + * + * @remarks + * The component fires exactly one view event for the Experience itself + * (via an outer wrapper that resolves to the Experience attribution layer) + * plus one event per unique non-Experience attribution target found in the + * tree (e.g. each persisted Fragment), regardless of how many coded leaves + * roll up to that target. + * + * Requires an `` ancestor to supply the global + * `clientId` / `environment` / `autoTrackNodeInteraction` configuration — + * this component handles only the per-Experience rendering and stamping. + * + * @public + */ +export function OptimizedExperience({ + nodes, + sourceMap, + componentMap, + viewportId = 'test-desktop', + renderUnsupported, +}: OptimizedExperienceProps): ReactNode { + const resolvedSourceMap = sourceMap ?? EMPTY_SOURCE_MAP + + const experienceNodeId = useMemo( + () => findExperienceAttributedLeafId(nodes, resolvedSourceMap), + [nodes, resolvedSourceMap], + ) + + const firingNodeIds = useMemo( + () => selectInnerFiringNodeIds(nodes, resolvedSourceMap), + [nodes, resolvedSourceMap], + ) + + const tree = nodes.map((node, index) => ( + + )) + + if (experienceNodeId) { + return ( + + {tree} + + ) + } + + return <>{tree} +} + +export default OptimizedExperience