Skip to content

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

Open
marcoferreiradev wants to merge 4 commits into
deco-cx:mainfrom
oficina-dev:pr/render-json
Open

feat(render): section-controlled JSON output via renderJson export#1209
marcoferreiradev wants to merge 4 commits into
deco-cx:mainfrom
oficina-dev: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 a JSON render path for pages via ?format=json, letting each section control its JSON with an optional renderJson export. Tightens typing and utils for predictable output and exposes a shared computeRenderCb for consistent cache-busting.

  • New Features

    • ?format=json on /deco/render returns a JSON-safe section tree with proper cache-control.
    • Section-level renderJson: false to omit; function to project resolved props (must return an object); defaults to full props.
    • Lazy handling: eager lazies unwrapped; deferred lazies emit { component, lazyUrl }; omitted sections are removed from arrays and become null in object fields.
    • Exports: serializeResolvedSection, sectionModuleLookup, buildLazyUrl, computeRenderCb, and deepOmit.
  • Bug Fixes

    • deepOmit no longer creates undefined branches when a path is missing.
    • Stricter renderJson return type (object) prevents unsafe projections.

Written for commit b409595. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added format=json support for section render requests, including cache headers tuned to variant behavior.
    • Implemented section serialization with lazy-loading URLs for optimized delivery and nested section handling.
    • Added a deep object omission utility that supports dot-paths and * wildcards.
  • Refactor
    • Centralized render cache-buster callback computation into a shared reusable helper.

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>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9204156-0433-4d8f-8e4f-85f1ce38ee08

📥 Commits

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

📒 Files selected for processing (3)
  • mod.ts
  • runtime/routes/serialize-section.ts
  • utils/object.ts
💤 Files with no reviewable changes (1)
  • mod.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • utils/object.ts

📝 Walkthrough

Walkthrough

Adds a JSON serialization path for the /deco/render route (format=json), which resolves sections to serialized props or lazyUrl placeholders via a new serialize-section.ts module. Extracts computeRenderCb from useSection into a reusable exported helper. Introduces a deepOmit utility for immutable dot-path property removal. Expands public re-exports in mod.ts and hooks/mod.ts.

Changes

Section JSON Serialization

Layer / File(s) Summary
computeRenderCb extraction
hooks/useSection.ts, hooks/mod.ts
Exports RenderCbInput interface and computeRenderCb function; replaces inline hasher logic in useSection with a call to the new helper; re-exports from hooks/mod.ts.
serialize-section.ts: types and serialization logic
runtime/routes/serialize-section.ts
Defines ResolvedSection, SerializedSection, RenderJson, SectionJsonModule, LazyUrlContext, SerializeContext types. Implements sectionModuleLookup, buildLazyUrl, serializeResolvedSection (lazy-placeholder unwrapping and renderJson projection), and walkValue (recursive props traversal).
render.tsx: format=json handler
runtime/routes/render.tsx
Adds an early branch when format=json is present; constructs SerializeContext, calls serializeResolvedSection, sets cache-control, and returns Response.json.
Public API re-exports
mod.ts
Re-exports all new serialization helpers, types, computeRenderCb, and RenderCbInput from the top-level module.

deepOmit Utility

Layer / File(s) Summary
deepOmit implementation and re-export
utils/object.ts, utils/mod.ts
Adds omitAtPath (dot-path, wildcard-aware, immutable removal) and the exported deepOmit<T> generic with JSDoc; re-exports from utils/mod.ts.

Sequence Diagram

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

  Client->>render.tsx: GET /deco/render?format=json&href=...
  render.tsx->>sectionModuleLookup: build component resolver from manifest
  sectionModuleLookup-->>render.tsx: getSectionModule fn
  render.tsx->>serializeResolvedSection: resolvedPage as ResolvedSection, SerializeContext
  loop each nested section
    serializeResolvedSection->>sectionModuleLookup: getSectionModule(component)
    sectionModuleLookup-->>serializeResolvedSection: SectionJsonModule or undefined
    alt lazy placeholder
      serializeResolvedSection->>buildLazyUrl: resolveChain, LazyUrlContext
      buildLazyUrl-->>serializeResolvedSection: /deco/render?format=json&...
    end
  end
  serializeResolvedSection-->>render.tsx: SerializedSection or null
  render.tsx-->>Client: Response.json(serialized) + cache-control
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 A bunny hops through props so deep,
Serializing sections in their sleep,
With lazy URLs and render hashes bright,
deepOmit trims the paths just right,
JSON flows where HTML once sat —
hop hop, the rabbit's proud of that! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing section-controlled JSON output via a renderJson export mechanism in the render system.
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.

@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 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: 1

🧹 Nitpick comments (2)
runtime/routes/serialize-section.ts (2)

156-168: 💤 Low value

Non-null assertions assume valid metadata.

Line 157 uses current.metadata!.component! without a guard. While ResolvedSection.metadata is optional, if a malformed node reaches this path, it will throw. Consider adding an early check or returning null if metadata is missing, for defensive robustness.

🛡️ Defensive guard suggestion
+  const component = current.metadata?.component;
+  if (!component) return null;
-  const component = current.metadata!.component!;
   const renderJson = renderJsonOf(ctx, component);
🤖 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 156 - 168, The code uses
non-null assertions on `current.metadata!.component!` without verifying these
properties exist, which can throw at runtime if metadata is missing or
malformed. Add an early guard check at the beginning of the block that returns
null if `current.metadata` is falsy or if `current.metadata.component` is falsy,
ensuring the function safely handles cases where these properties are not
present before attempting to access them.

143-155: Clarify whether buildLazyUrl should use the inner section's resolveChain when present.

Lines 149–150 extract and check renderJson on the inner section's component when it exists, but line 153 always builds the lazy URL using the outer wrapper's resolveChain. If the inner section was resolved in a different context and has a different resolve path, this mismatch could cause the lazy fetch to reconstruct a different section than the one whose renderJson export was consulted. Either use inner?.metadata?.resolveChain ?? current.metadata!.resolveChain for consistency, or add a comment explaining why the wrapper's chain is the correct choice regardless of which component is serialized.

🤖 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 143 - 155, In the lazy
component serialization block, there is an inconsistency between which
component's metadata is used for the renderJson check versus which resolveChain
is used for the lazy URL. The component to serialize is determined by preferring
the inner section's component when available (line 150), but the buildLazyUrl
call on line 153 always uses the outer wrapper's resolveChain from
current.metadata. To fix this, update the buildLazyUrl call to use
inner?.metadata?.resolveChain ?? current.metadata!.resolveChain to ensure the
resolve chain matches the component that was selected for serialization,
maintaining consistency between the component check and the lazy URL
construction.
🤖 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 `@utils/object.ts`:
- Around line 181-184: The omitAtPath function currently creates undefined keys
when the next path segment does not exist in the object. Add a conditional check
before assigning the recursive result to the key: only call omitAtPath
recursively and assign the result if current[key] actually exists (is not
undefined). If the key does not exist in the current object, simply return the
current object without adding that key to avoid creating undefined branches in
the result.

---

Nitpick comments:
In `@runtime/routes/serialize-section.ts`:
- Around line 156-168: The code uses non-null assertions on
`current.metadata!.component!` without verifying these properties exist, which
can throw at runtime if metadata is missing or malformed. Add an early guard
check at the beginning of the block that returns null if `current.metadata` is
falsy or if `current.metadata.component` is falsy, ensuring the function safely
handles cases where these properties are not present before attempting to access
them.
- Around line 143-155: In the lazy component serialization block, there is an
inconsistency between which component's metadata is used for the renderJson
check versus which resolveChain is used for the lazy URL. The component to
serialize is determined by preferring the inner section's component when
available (line 150), but the buildLazyUrl call on line 153 always uses the
outer wrapper's resolveChain from current.metadata. To fix this, update the
buildLazyUrl call to use inner?.metadata?.resolveChain ??
current.metadata!.resolveChain to ensure the resolve chain matches the component
that was selected for serialization, maintaining consistency between the
component check and the lazy URL construction.
🪄 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: 272a199e-f884-415b-ac26-d6ac22f56c94

📥 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 utils/object.ts

@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.

3 issues found across 7 files

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

Re-trigger cubic

Comment thread runtime/routes/serialize-section.ts
Comment thread mod.ts Outdated
Comment thread utils/object.ts
- utils/object.ts: omitAtPath is now a no-op for absent paths instead of
  creating `key: undefined` branches (deepOmit must never add keys).
- serialize-section.ts: tighten RenderJson projection return to
  Record<string, unknown> so a non-object projection fails to type-check —
  the serializer's cast is now sound by construction.
- serialize-section.ts: document why the lazy slot uses the wrapper's
  resolveChain (it reconstructs the inner on fetch; the inner is not
  independently resolvable).
- mod.ts: drop the Murmurhash3 re-export — computeRenderCb already
  encapsulates the cache-bust hash; no consumer needs the raw primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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.

1 issue found across 3 files (changes from recent commits).

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="mod.ts">

<violation number="1" location="mod.ts:63">
P1: Potential unintended breaking change: `Murmurhash3` re-export removed from the public barrel (`mod.ts`) without deprecation path or PR description mention.</violation>
</file>

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

Re-trigger cubic

Comment thread mod.ts
SerializeContext,
SerializedSection,
} from "./runtime/routes/serialize-section.ts";
export * from "./types.ts";

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.

P1: Potential unintended breaking change: Murmurhash3 re-export removed from the public barrel (mod.ts) without deprecation path or PR description mention.

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

<comment>Potential unintended breaking change: `Murmurhash3` re-export removed from the public barrel (`mod.ts`) without deprecation path or PR description mention.</comment>

<file context>
@@ -60,7 +60,6 @@ export type {
   SerializedSection,
 } from "./runtime/routes/serialize-section.ts";
-export { Murmurhash3 } from "./deps.ts";
 export * from "./types.ts";
 export { allowCorsFor } from "./utils/http.ts";
 export type { StreamProps } from "./utils/invoke.ts";
</file context>

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