Skip to content

feat(render): section-controlled JSON output via renderJson export#1208

Closed
marcoferreiradev wants to merge 3 commits into
deco-cx:mainfrom
marcoferreiradev:pr/render-json
Closed

feat(render): section-controlled JSON output via renderJson export#1208
marcoferreiradev wants to merge 3 commits into
deco-cx:mainfrom
marcoferreiradev:pr/render-json

Conversation

@marcoferreiradev

@marcoferreiradev marcoferreiradev commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Adds a renderJson section export that lets each section control its own JSON serialization, surfaced through a ?format=json render branch:

  • renderJson export on a section module:
    • false → the section is dropped from the JSON payload (its prop-loaders short-circuit, see engine/core/resolver.ts).
    • a function → a typed projection of the resolved props (typed via SectionProps<typeof loader> — no new type to declare).
  • ?format=json render branch + serializeResolvedSection (new runtime/routes/serialize-section.ts) that walks the resolved section tree and applies each section's renderJson.
  • computeRenderCb lifted out of hooks/useSection so the lazy-section URL builder and the JSON serializer share one cache-bust recipe instead of drifting.
  • deepOmit helper in utils/object.ts for ergonomic projections.

Why

Headless / app consumers need a stable JSON projection of a page's sections without scraping rendered HTML. The alternatives are over-fetching the full resolved tree or maintaining a parallel serializer that drifts from the render path. Co-locating a renderJson contract with each section keeps the JSON shape next to the section and reuses the real render + cache machinery — one source of truth for the cache-bust recipe.

How it works

GET /page?format=json
  └─ render path resolves the section tree (identical to HTML)
       └─ serializeResolvedSection walks it
            ├─ renderJson === false → omit the section
            ├─ renderJson is a fn   → project resolved props (typed)
            └─ default              → serialize resolved props

computeRenderCb is exported from useSection so the lazy-section URL builder and the serializer compute the same render callback.

Test plan

  • deno fmt --check clean on touched files
  • deno check mod.ts passes against main
  • Dogfooded on a real deco storefront (oficina-reserva): HTML and ?asJson byte-identical to baseline; ?format=json matches the resolved render tree; lazy fetch applies the projection

Companion PR

Native ?renderJson support in the Fresh handler lands in deco-cx/apps as a separate PR that depends on this one being published.

🤖 Generated with Claude Code


Summary by cubic

Adds section-controlled JSON output via ?format=json and a new renderJson export so each section defines its JSON shape. Also exports computeRenderCb to keep web and JSON cache-busting in sync.

  • New Features
    • ?format=json in /deco/render serializes the resolved section tree, returning { component, props } or lazy { component, lazyUrl }. Honors each section’s renderJson (false to omit, or a projection function). Uses sectionModuleLookup to find module exports, unwraps eager lazies, and strips Component functions.
    • New renderJson section export for per-section control. Use a function to project resolved props (typed via SectionProps<typeof loader>), or false to drop the section. deepOmit helper added for ergonomic projections.
    • computeRenderCb extracted and exported from useSection so lazy URLs and JSON serialization share the exact cache-bust recipe.
    • Serializer utilities and types exported: serializeResolvedSection, buildLazyUrl, sectionModuleLookup, and related types via mod.ts.

Written for commit 61d9563. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Sections can now be rendered as JSON via the format=json query parameter for lazy-loading.
  • Chores

    • Refactored cache-bust value generation with dedicated helper function.
    • Expanded module exports to include section serialization utilities and object manipulation helpers.
    • Added support for caching configuration based on section rendering mode.

marcoferreiradev and others added 3 commits June 18, 2026 11:31
New helper serializeResolvedSection turns the resolved page tree into a
JSON-safe shape: strips Component functions, unwraps eager-resolved Lazy
wrappers, and emits {component, lazyUrl} placeholders for deferred Lazies.
Recursive walk covers Sections nested inside arbitrary props.

The /deco/render handler branches on ?format=json before the HTML render
path, returning Response.json(serialized) with the same cache-control
treatment. Single-flight is intentionally not added on the JSON path
(serialization is cheap).

Companion to ADR-0001 in oficina-reserva (mobile-app appJson lazy support).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n export

  serializeResolvedSection now honors a section's  named export:
   drops the section from the JSON output (null return — arrays stay
  dense, object values become null), a function projects the resolved props
  before serialization. Module lookup runs over the merged manifest via
  sectionModuleLookup() (Context.active().runtime) and is injected through
  SerializeContext.getSectionModule — the serializer stays pure and behaves
  identically when no lookup is provided. /deco/render?format=json passes the
  lookup, so projections also apply on lazy fetches.

  Also promotes deepOmit (dot-paths,  wildcard over records/arrays,
  immutable) from oficina-reserva's sdk into @deco/deco/utils.
Extracts the /deco/render __cb computation out of the useSection hook
closure into an exported function in the same module. useSection now calls
it, and JSON serialization consumers (e.g. the website app's ?renderJson
handler) can import the exact same recipe instead of copying it — web and
JSON cache invalidation can no longer diverge. Recipe preserved
byte-for-byte (raw join semantics, shared sync-only hasher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.202.1 update
  • 🎉 for Minor 1.203.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR introduces a format=json render path in render.tsx backed by a new serialize-section.ts module that converts resolved section trees into JSON payloads (supporting lazy URLs, renderJson projection/dropping, and recursive value walking). It extracts cache-bust computation into an exported computeRenderCb helper, adds a deepOmit utility for immutable dot-notation property removal, and re-exports all new symbols through the public API.

Changes

Section JSON Serialization

Layer / File(s) Summary
Data shapes, RenderJson contract, and computeRenderCb extraction
runtime/routes/serialize-section.ts, hooks/useSection.ts
Defines ResolvedSection, SerializedSection, RenderJson, SectionJsonModule, LazyUrlContext, SerializeContext, and sectionModuleLookup. Extracts computeRenderCb (with RenderCbInput) from useSection's inline hash logic into a standalone exported helper.
serializeResolvedSection, buildLazyUrl, and walkValue
runtime/routes/serialize-section.ts
Implements buildLazyUrl to construct /deco/render?format=json URLs, serializeResolvedSection to advance through eager lazy wrappers and produce { component, props } or { component, lazyUrl } payloads, and walkValue to recursively serialize nested section trees.
render.tsx format=json branch and public API re-exports
runtime/routes/render.tsx, hooks/mod.ts, mod.ts
Adds a format=json handling branch in render.tsx that builds a SerializeContext, serializes the resolved page, and conditionally sets cache-control headers. Re-exports all new symbols (buildLazyUrl, sectionModuleLookup, serializeResolvedSection, computeRenderCb, RenderCbInput, serialization types, Murmurhash3) from the public module surfaces.

deepOmit Utility

Layer / File(s) Summary
deepOmit implementation and export
utils/object.ts, utils/mod.ts
Adds omitAtPath recursive helper with wildcard * fan-out, array preservation, and immutable rebuild. Exposes deepOmit applying omission for multiple dot-notation paths, and re-exports it alongside tryOrDefault from utils/mod.ts.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant render.tsx
  participant sectionModuleLookup
  participant serializeResolvedSection
  participant walkValue

  Client->>render.tsx: GET /deco/render?format=json&href=...
  render.tsx->>sectionModuleLookup: await lookup()
  sectionModuleLookup-->>render.tsx: (component) => SectionJsonModule
  render.tsx->>serializeResolvedSection: serialize(page as ResolvedSection, SerializeContext)
  serializeResolvedSection->>sectionModuleLookup: lookup(component) → renderJson
  alt renderJson === false
    serializeResolvedSection-->>render.tsx: null (dropped)
  else lazy and non-eager
    serializeResolvedSection->>serializeResolvedSection: buildLazyUrl(resolveChain, ctx)
    serializeResolvedSection-->>render.tsx: { component, lazyUrl }
  else eager or non-lazy
    serializeResolvedSection->>walkValue: walk resolved props
    walkValue-->>serializeResolvedSection: serialized props tree
    serializeResolvedSection-->>render.tsx: { component, props }
  end
  render.tsx-->>Client: JSON Response + cache-control
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • deco-cx/deco#1095: Both PRs modify the render.tsx request-handler rendering path — the retrieved PR changes render deduplication via async single-flight while this PR adds the format=json serialization branch to the same handler.

Poem

🐇 Hop, hop, through the section tree I go,
Serializing props with a lazy URL in tow,
deepOmit trims the paths, dot by dot,
computeRenderCb hashes the cache-bust slot,
JSON responses spring forth fresh and neat—
This rabbit's refactor is quite the feat! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main feature being introduced: section-controlled JSON output via a renderJson export mechanism for controlling serialization behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@runtime/routes/render.tsx`:
- Around line 109-112: The serializeResolvedSection function call casts page to
ResolvedSection without validating the page object's shape first. If the render
output is missing required properties like metadata.component, the serializer's
non-null assertions will throw, causing an unhandled 500 error. Before calling
serializeResolvedSection with the cast, add validation to check that page has
the expected ResolvedSection shape (particularly that page.metadata and
page.metadata.component exist). If the validation fails, return a controlled
error response rather than attempting the cast and serialization.

In `@runtime/routes/serialize-section.ts`:
- Around line 191-194: The loop iterating through object entries in the
serialization block unconditionally assigns walked values to the output object,
causing opted-out nested sections with renderJson === false to become null
properties instead of being removed entirely. Fix this by checking the result of
walkValue and only assigning the key-value pair to out when the walked value is
not null, using continue to skip adding dropped properties so that objects
behave consistently with arrays by removing opted-out sections entirely rather
than keeping them as null.

In `@utils/object.ts`:
- Around line 181-184: The omitAtPath function at lines 181-184 is
unconditionally rebuilding the key property even when it doesn't exist in the
current object, causing missing nested paths to materialize as undefined
properties. Modify the return statement to conditionally include the key only if
it exists in the current object. Check whether the key is present in the current
object before adding it to the spread result, so that paths that don't exist are
not created as new properties with undefined values during the deep omit
operation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3c2f8e6b-fbf3-4338-8370-6641b5aec635

📥 Commits

Reviewing files that changed from the base of the PR and between ce2cbd7 and 61d9563.

📒 Files selected for processing (7)
  • hooks/mod.ts
  • hooks/useSection.ts
  • mod.ts
  • runtime/routes/render.tsx
  • runtime/routes/serialize-section.ts
  • utils/mod.ts
  • utils/object.ts

Comment thread runtime/routes/render.tsx
Comment on lines +109 to +112
const serialized = serializeResolvedSection(
page as ResolvedSection,
serializeCtx,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate root shape before casting page to ResolvedSection.

Line 110 force-casts page, but the serializer uses non-null assertions on metadata.component. If render output is not section-shaped, this can throw and return 500 instead of a controlled client error.

Suggested fix
   if (opts.searchParams.get("format") === "json") {
+    if (
+      !page || typeof page !== "object" ||
+      typeof (page as ResolvedSection).metadata?.component !== "string"
+    ) {
+      throw badRequest({
+        code: "400",
+        message: "JSON format requires a resolved section root",
+      });
+    }
+
     const serializeCtx: SerializeContext = {
       href: opts.href,
       pathTemplate: opts.pathTemplate,
       renderSalt: opts.renderSalt,
       cb: opts.searchParams.get("__cb") ?? undefined,
       getSectionModule: await sectionModuleLookup(),
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runtime/routes/render.tsx` around lines 109 - 112, The
serializeResolvedSection function call casts page to ResolvedSection without
validating the page object's shape first. If the render output is missing
required properties like metadata.component, the serializer's non-null
assertions will throw, causing an unhandled 500 error. Before calling
serializeResolvedSection with the cast, add validation to check that page has
the expected ResolvedSection shape (particularly that page.metadata and
page.metadata.component exist). If the validation fails, return a controlled
error response rather than attempting the cast and serialization.

Comment on lines +191 to +194
for (const [k, v] of Object.entries(value)) {
if (k === "Component" && typeof v === "function") continue;
out[k] = walkValue(v, ctx);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop object properties for sections that opt out of JSON (renderJson === false).

Line 193 always writes the walked value, so opted-out nested sections become null on object fields instead of being removed. That contradicts the documented “dropped entirely” behavior and creates a contract mismatch between arrays and objects.

Suggested fix
   const out: Record<string, unknown> = {};
   for (const [k, v] of Object.entries(value)) {
     if (k === "Component" && typeof v === "function") continue;
-    out[k] = walkValue(v, ctx);
+    if (isSectionShape(v)) {
+      const serialized = serializeResolvedSection(v, ctx);
+      if (serialized !== null) out[k] = serialized;
+      continue;
+    }
+    out[k] = walkValue(v, ctx);
   }
   return out;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const [k, v] of Object.entries(value)) {
if (k === "Component" && typeof v === "function") continue;
out[k] = walkValue(v, ctx);
}
for (const [k, v] of Object.entries(value)) {
if (k === "Component" && typeof v === "function") continue;
if (isSectionShape(v)) {
const serialized = serializeResolvedSection(v, ctx);
if (serialized !== null) out[k] = serialized;
continue;
}
out[k] = walkValue(v, ctx);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runtime/routes/serialize-section.ts` around lines 191 - 194, The loop
iterating through object entries in the serialization block unconditionally
assigns walked values to the output object, causing opted-out nested sections
with renderJson === false to become null properties instead of being removed
entirely. Fix this by checking the result of walkValue and only assigning the
key-value pair to out when the walked value is not null, using continue to skip
adding dropped properties so that objects behave consistently with arrays by
removing opted-out sections entirely rather than keeping them as null.

Comment thread utils/object.ts
Comment on lines +181 to +184
return {
...current,
[key]: omitAtPath(current[key], rest),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not materialize missing branches during deep omit.

At Line 181, nested omission always rebuilds [key], so a missing path like "a.b" on {} produces { a: undefined }. deepOmit should not add new keys while removing others.

Proposed fix
   if (rest.length === 0) {
     const copy = { ...current };
     delete copy[key];
     return copy;
   }
+
+  if (!(key in current)) {
+    return current;
+  }
 
   return {
     ...current,
     [key]: omitAtPath(current[key], rest),
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
...current,
[key]: omitAtPath(current[key], rest),
};
if (rest.length === 0) {
const copy = { ...current };
delete copy[key];
return copy;
}
if (!(key in current)) {
return current;
}
return {
...current,
[key]: omitAtPath(current[key], rest),
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/object.ts` around lines 181 - 184, The omitAtPath function at lines
181-184 is unconditionally rebuilding the key property even when it doesn't
exist in the current object, causing missing nested paths to materialize as
undefined properties. Modify the return statement to conditionally include the
key only if it exists in the current object. Check whether the key is present in
the current object before adding it to the spread result, so that paths that
don't exist are not created as new properties with undefined values during the
deep omit operation.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="runtime/routes/render.tsx">

<violation number="1" location="runtime/routes/render.tsx:117">
P2: Cache-control logic is duplicated between the new JSON response path and the existing HTML response path. Extract into a shared helper to prevent drift.</violation>
</file>

<file name="runtime/routes/serialize-section.ts">

<violation number="1" location="runtime/routes/serialize-section.ts:167">
P2: `renderJson` projection result is not validated as an object before being emitted as `props`, causing potential schema/type contract breakage at runtime.</violation>

<violation number="2" location="runtime/routes/serialize-section.ts:193">
P2: Object traversal keeps opted-out sections as `null` instead of omitting keys, producing inconsistent "drop" semantics versus arrays.</violation>
</file>

<file name="utils/object.ts">

<violation number="1" location="utils/object.ts:183">
P2: When `key` does not exist in `current`, this still writes `[key]: omitAtPath(undefined, rest)` into the result, materializing a path that was never present (e.g., `deepOmit({}, "a.b")` produces `{ a: undefined }`). Add a guard to return `current` unchanged when the key is missing.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread runtime/routes/render.tsx
const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true;
jsonResponse.headers.set(
"cache-control",
shouldCache && shouldCacheFromVary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Cache-control logic is duplicated between the new JSON response path and the existing HTML response path. Extract into a shared helper to prevent drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/routes/render.tsx, line 117:

<comment>Cache-control logic is duplicated between the new JSON response path and the existing HTML response path. Extract into a shared helper to prevent drift.</comment>

<file context>
@@ -91,6 +97,30 @@ export const handler = createHandler(async (
+    const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true;
+    jsonResponse.headers.set(
+      "cache-control",
+      shouldCache && shouldCacheFromVary
+        ? DECO_RENDER_CACHE_CONTROL
+        : "no-store, no-cache, must-revalidate",
</file context>


return {
component,
props: walkValue(props, ctx) as Record<string, unknown>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: renderJson projection result is not validated as an object before being emitted as props, causing potential schema/type contract breakage at runtime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/routes/serialize-section.ts, line 167:

<comment>`renderJson` projection result is not validated as an object before being emitted as `props`, causing potential schema/type contract breakage at runtime.</comment>

<file context>
@@ -0,0 +1,198 @@
+
+  return {
+    component,
+    props: walkValue(props, ctx) as Record<string, unknown>,
+  };
+}
</file context>

const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (k === "Component" && typeof v === "function") continue;
out[k] = walkValue(v, ctx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Object traversal keeps opted-out sections as null instead of omitting keys, producing inconsistent "drop" semantics versus arrays.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/routes/serialize-section.ts, line 193:

<comment>Object traversal keeps opted-out sections as `null` instead of omitting keys, producing inconsistent "drop" semantics versus arrays.</comment>

<file context>
@@ -0,0 +1,198 @@
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value)) {
+      if (k === "Component" && typeof v === "function") continue;
+      out[k] = walkValue(v, ctx);
+    }
+    return out;
</file context>

Comment thread utils/object.ts

return {
...current,
[key]: omitAtPath(current[key], rest),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When key does not exist in current, this still writes [key]: omitAtPath(undefined, rest) into the result, materializing a path that was never present (e.g., deepOmit({}, "a.b") produces { a: undefined }). Add a guard to return current unchanged when the key is missing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At utils/object.ts, line 183:

<comment>When `key` does not exist in `current`, this still writes `[key]: omitAtPath(undefined, rest)` into the result, materializing a path that was never present (e.g., `deepOmit({}, "a.b")` produces `{ a: undefined }`). Add a guard to return `current` unchanged when the key is missing.</comment>

<file context>
@@ -146,3 +146,56 @@ export const tryOrDefault = <R>(fn: () => R, defaultValue: R): R => {
+
+  return {
+    ...current,
+    [key]: omitAtPath(current[key], rest),
+  };
+};
</file context>

@marcoferreiradev

Copy link
Copy Markdown
Author

Superseded by #1209 — reopening from the oficina-dev org fork (this should be an org contribution, not my personal fork).

@marcoferreiradev marcoferreiradev deleted the pr/render-json branch June 18, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant