import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms"; import { resolveDeferredSection } from "@decocms/blocks/cms"; import { cloneElement, type ReactElement, type ReactNode } from "react"; import { DeferredSectionBoundary } from "./DeferredSection"; import { SectionRenderer } from "./SectionRenderer"; interface DecoPageRendererProps { sections: ResolvedSection[]; deferredSections?: DeferredSection[]; pagePath: string; pageUrl?: string; loadingFallback?: ReactNode; errorFallback?: ReactNode; } type PageItem = | { type: "eager"; section: ResolvedSection; sort: number } | { type: "deferred"; deferred: DeferredSection; sort: number }; function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] { const items: PageItem[] = []; resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i }), ); for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index }); items.sort((a, b) => a.sort - b.sort); return items; } /** * Top-level renderer for a resolved CMS page. Kicks off (but does not await) * each deferred section's resolve promise immediately, so they resolve * concurrently with the eager sections' own rendering — React's streaming * SSR then flushes each boundary as its promise settles. */ export async function DecoPageRenderer({ sections, deferredSections = [], pagePath, pageUrl, loadingFallback, errorFallback, }: DecoPageRendererProps) { const items = mergeSections(sections, deferredSections); // `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts) // takes positional args `(component, rawProps, pagePath, matcherCtx?)` and // returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped // call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it // flows in via `matcherCtx.url`, mirroring how the TanStack route loader // builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts). // We also stamp `index` onto the resolved section ourselves (mirroring what // `resolveDeferredSectionFull` does) so downstream consumers can rely on it. const matcherCtx = pageUrl ? { url: pageUrl } : undefined; const deferredPromises = new Map>(); for (const d of deferredSections) { deferredPromises.set( d.index, resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then( (resolved) => { if (!resolved) return null; return { ...resolved, index: d.index }; }, ), ); } // Eager sections are resolved via a direct (awaited) function call rather // than nested as a `` JSX child. `SectionRenderer` is an // async function; Next's real RSC renderer awaits async components nested // anywhere in the tree, but that's specific to the RSC streaming renderer — // plain `react-dom/server` (used by this package's unit tests, and by any // consumer that isn't going through Next's RSC pipeline) throws ("A // component suspended while responding to synchronous input") when an async // component suspends outside a `` boundary. Deferred sections are // deliberately exempt: `DeferredSectionBoundary` wraps its async child in a // ``, so letting it suspend there is exactly the intended // behavior (renders the fallback synchronously, then streams in later). // // `deferredPromises` above is already populated before this `Promise.all` // runs, so every deferred resolve is in flight concurrently with eager // section rendering — nothing here blocks on it. const rendered = await Promise.all( items.map(async (item): Promise => { if (item.type === "eager") { const element = await SectionRenderer({ resolved: item.section, errorFallback, }); if (!element) return null; return cloneElement(element, { key: `${item.section.key}-${item.section.index}` }); } return ( ); }), ); return <>{rendered}; }