fix(cms): renderJson serializes nested sections, keeps loader-owned device, runs loaders of deeply nested sections
Three ?renderJson divergences from Fresh/deco-runtime, all on the same path (sections nested in another section's props). Closes #567.
1. Nested sections came out raw and as { Component, props }
serializeRenderJson only applied the renderJson projection (and the framework-key strip) at the top level. A section nested in another's props (vitrine/section-tabs.tsx → variants[].section, the LP Prime hero's contentSections) was emitted with its raw CMS props and the resolver's capital Component key.
Reference — deco-runtime's serializer recurses (runtime/routes/serialize-section.ts, walkValue): every nested section is serialized with its own renderJson and written as { component, props }, the same shape as the top level; a dropped nested section (renderJson === false) is null in an object and removed from an array (dense list for the consumer).
Measured (from #567): www.oficina.com/vitrine?renderJson → sections[7].props.section.props is the projected contract (path, media, optimizedUrl); the TanStack site returned link, responsiveMedia, textColorMode: { desktop } and the desktop image. The site works around both the raw props and the Component key in src/sdk/render-json-globals.ts (withNestedComponentKey).
Fix: after projection + strip, walkValue recurses into the props; nested { Component, props } → { component, props: project(...) } with the same projector (projection → strip → recurse). Same reference when nothing changed.
2. device stripped even when the section's loader owns it
#472 strips device/isMobile/currentSearchParam from every section because the withDevice/withMobile/withSearchParam mixins inject them (request-derived, ETag churn). That is right for the mixins, but a section whose own loader returns device: ctx.device as content (Fresh: ProductDetails, listing-page, the rich-content sections, The Post's post-content/post-cover…) loses a field production's ?renderJson carries — measured on www.oficina.com/<pdp>?renderJson → ProductDetails.props.device: "desktop" for the app's UA; TanStack omitted it. The site re-attaches it from an allowlist (LOADER_DEVICE_SECTIONS in render-json-globals.ts).
The serializer cannot tell an injected key from a loader-returned one, so this is opt-in per section (a kept key legitimately varies the ETag per device, since the content does):
SerializeOptions.keepInjectedKeys?: (component) => readonly string[] | undefinedcreateDecoWorkerEntry({ renderJsonKeepKeys: { "Product/ProductDetails.tsx": ["device"] } })— resolveType suffix, matched likerenderJson.sectionsToIgnore; applied to the page envelope and the__sectionlazy fetch, top-level and nested.
Default behavior is unchanged (nothing kept).
3. Loaders of sections nested inside plain objects never ran
enrichNestedSections only inspected direct prop values and direct array items for { Component, props }. A section reachable through a plain object — variants: [{ name, section: Section }], layout: { aside: Section } — was never found, so its loader never ran and it rendered (HTML and JSON) with raw CMS props. The resolver resolves a section reference wherever the CMS placed it; the loader walk now does the same: descends plain objects and arrays at any depth, loaders in parallel, same reference when nothing changed (the zero-alloc leaf path is kept).
Tests
packages/blocks/src/cms/renderJson.test.ts(+6): nested section inside an object in an array is projected and written as{ component, props }; dropped nested →null/ removed from array; wrapper-inside-wrapper recursion; props without nested sections keep the same reference;keepInjectedKeyskeeps a key only for opted-in sections, top-level and nested.packages/blocks/src/cms/sectionLoaders.test.ts(+1): loader runs forvariants[].sectionandlayout.aside, non-section siblings untouched.
vitest run on renderJson, sectionLoaders, resolve, cmsRoute tests: all pass (168/169 — the 1 failure is layoutCacheRace.test.ts, which fails identically on a clean main on Node 22 for lack of URLPattern; the package's engines asks for Node 24). tsc --noEmit clean for packages/blocks and packages/tanstack; biome check clean on the touched files.
Found while validating deco-sites/oficina-tanstack#479.
🤖 Generated with Claude Code
Review follow-up
- Cost on every section (high): the nested-section walk was async per node (a Promise +
Promise.allper object/array) on every loader output, even with nothing nested. 0.42 MB PLP props: 10.5 ms → 0.67 ms per section call (main: 0.03 ms — it only looked one level deep). A synchronous scan marks the subtrees that hold a nested section and the async walk descends only into those; props without one keep their reference with no Promise allocated. Tests: noPromise.alland same reference without nested sections; only the marked subtree is descended. - Cyclic loader output (medium):
a.self = aoverflowed the stack and, outside any try/catch, rejected the wholerunSectionLoadersbatch (main rendered it). The scan memoizes below a depth guard, the walk keeps a cycle's original reference, and a failing enrich logs and keeps the section's props. Tests: cyclic props resolve; a nested section reachable through a cycle runs once; a subtree shared by two props is enriched in both places.
blocks + tanstack: 1159 passed; the 43 failures are the pre-existing ones (loader/layoutCacheRace without URLPattern on Node 22, 3 draft-preview/CDN in workerEntry). Typecheck and biome clean.