fix(cms): renderJson serializes nested sections, keeps loader-owned device, runs loaders of deeply nested sections

Merged
AL
9d ago
+568-98

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[] | undefined
  • createDecoWorkerEntry({ renderJsonKeepKeys: { "Product/ProductDetails.tsx": ["device"] } }) — resolveType suffix, matched like renderJson.sectionsToIgnore; applied to the page envelope and the __section lazy 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; keepInjectedKeys keeps a key only for opted-in sections, top-level and nested.
  • packages/blocks/src/cms/sectionLoaders.test.ts (+1): loader runs for variants[].section and layout.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.all per 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: no Promise.all and same reference without nested sections; only the marked subtree is descended.
  • Cyclic loader output (medium): a.self = a overflowed the stack and, outside any try/catch, rejected the whole runSectionLoaders batch (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.