feat(render): section-controlled JSON output via renderJson export#1208
feat(render): section-controlled JSON output via renderJson export#1208marcoferreiradev wants to merge 3 commits into
Conversation
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>
Tagging OptionsShould a new tag be published when this PR is merged?
|
📝 WalkthroughWalkthroughThe PR introduces a ChangesSection JSON Serialization
deepOmit Utility
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
hooks/mod.tshooks/useSection.tsmod.tsruntime/routes/render.tsxruntime/routes/serialize-section.tsutils/mod.tsutils/object.ts
| const serialized = serializeResolvedSection( | ||
| page as ResolvedSection, | ||
| serializeCtx, | ||
| ); |
There was a problem hiding this comment.
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.
| for (const [k, v] of Object.entries(value)) { | ||
| if (k === "Component" && typeof v === "function") continue; | ||
| out[k] = walkValue(v, ctx); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| return { | ||
| ...current, | ||
| [key]: omitAtPath(current[key], rest), | ||
| }; |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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
| const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true; | ||
| jsonResponse.headers.set( | ||
| "cache-control", | ||
| shouldCache && shouldCacheFromVary |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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>
|
|
||
| return { | ||
| ...current, | ||
| [key]: omitAtPath(current[key], rest), |
There was a problem hiding this comment.
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>
|
Superseded by #1209 — reopening from the oficina-dev org fork (this should be an org contribution, not my personal fork). |
Summary
Adds a
renderJsonsection export that lets each section control its own JSON serialization, surfaced through a?format=jsonrender branch:renderJsonexport on a section module:false→ the section is dropped from the JSON payload (its prop-loaders short-circuit, seeengine/core/resolver.ts).SectionProps<typeof loader>— no new type to declare).?format=jsonrender branch +serializeResolvedSection(newruntime/routes/serialize-section.ts) that walks the resolved section tree and applies each section'srenderJson.computeRenderCblifted out ofhooks/useSectionso the lazy-section URL builder and the JSON serializer share one cache-bust recipe instead of drifting.deepOmithelper inutils/object.tsfor 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
renderJsoncontract 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
computeRenderCbis exported fromuseSectionso the lazy-section URL builder and the serializer compute the same render callback.Test plan
deno fmt --checkclean on touched filesdeno check mod.tspasses againstmain?asJsonbyte-identical to baseline;?format=jsonmatches the resolved render tree; lazy fetch applies the projectionCompanion PR
Native
?renderJsonsupport in the Fresh handler lands indeco-cx/appsas a separate PR that depends on this one being published.🤖 Generated with Claude Code
Summary by cubic
Adds section-controlled JSON output via
?format=jsonand a newrenderJsonexport so each section defines its JSON shape. Also exportscomputeRenderCbto keep web and JSON cache-busting in sync.?format=jsonin/deco/renderserializes the resolved section tree, returning{ component, props }or lazy{ component, lazyUrl }. Honors each section’srenderJson(falseto omit, or a projection function). UsessectionModuleLookupto find module exports, unwraps eager lazies, and stripsComponentfunctions.renderJsonsection export for per-section control. Use a function to project resolved props (typed viaSectionProps<typeof loader>), orfalseto drop the section.deepOmithelper added for ergonomic projections.computeRenderCbextracted and exported fromuseSectionso lazy URLs and JSON serialization share the exact cache-bust recipe.serializeResolvedSection,buildLazyUrl,sectionModuleLookup, and related types viamod.ts.Written for commit 61d9563. Summary will update on new commits.
Summary by CodeRabbit
New Features
format=jsonquery parameter for lazy-loading.Chores