From 6c6b3a81648204a60f0fdfaa2ee44a5fa19e16db Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Thu, 14 May 2026 00:02:29 -0300 Subject: [PATCH 1/3] feat(render): add ?format=json branch with serializeResolvedSection 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) --- mod.ts | 10 +++ runtime/routes/render.tsx | 28 +++++++ runtime/routes/serialize-section.ts | 110 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 runtime/routes/serialize-section.ts diff --git a/mod.ts b/mod.ts index a4c9bb4e6..71b6e277b 100644 --- a/mod.ts +++ b/mod.ts @@ -45,6 +45,16 @@ export { type DecoRouteState } from "./runtime/middleware.ts"; export * from "./runtime/mod.ts"; export { Deco } from "./runtime/mod.ts"; export type { PageData } from "./runtime/mod.ts"; +export { + buildLazyUrl, + serializeResolvedSection, +} from "./runtime/routes/serialize-section.ts"; +export type { + LazyUrlContext, + ResolvedSection, + 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"; diff --git a/runtime/routes/render.tsx b/runtime/routes/render.tsx index 094312721..ee003be3e 100644 --- a/runtime/routes/render.tsx +++ b/runtime/routes/render.tsx @@ -8,6 +8,11 @@ import { singleFlight } from "../../utils/mod.ts"; import { createHandler, DEBUG_QS } from "../middleware.ts"; import type { PageParams } from "../mod.ts"; import Render, { type PageData } from "./entrypoint.tsx"; +import { + type LazyUrlContext, + type ResolvedSection, + serializeResolvedSection, +} from "./serialize-section.ts"; interface Options { resolveChain?: FieldResolver[]; @@ -91,6 +96,29 @@ export const handler = createHandler(async ( if (isDebugRequest) { return Response.json({ debugData: state.vary.debug.build() }); } + + if (opts.searchParams.get("format") === "json") { + const lazyCtx: LazyUrlContext = { + href: opts.href, + pathTemplate: opts.pathTemplate, + renderSalt: opts.renderSalt, + cb: opts.searchParams.get("__cb") ?? undefined, + }; + const serialized = serializeResolvedSection( + page as ResolvedSection, + lazyCtx, + ); + const jsonResponse = Response.json(serialized); + const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true; + jsonResponse.headers.set( + "cache-control", + shouldCache && shouldCacheFromVary + ? DECO_RENDER_CACHE_CONTROL + : "no-store, no-cache, must-revalidate", + ); + return jsonResponse; + } + const props = { params: ctx.req.param(), url: ctx.var.url, diff --git a/runtime/routes/serialize-section.ts b/runtime/routes/serialize-section.ts new file mode 100644 index 000000000..c61681f73 --- /dev/null +++ b/runtime/routes/serialize-section.ts @@ -0,0 +1,110 @@ +import { FieldResolver } from "../../engine/core/resolver.ts"; + +const LAZY_SECTION_PATH = "/Rendering/Lazy.tsx"; + +export interface ResolvedSection { + Component?: unknown; + props?: Record; + metadata?: { + component?: string; + resolveChain?: FieldResolver[]; + }; +} + +export type SerializedSection = + | { component: string; props: Record } + | { component: string; lazyUrl: string }; + +export interface LazyUrlContext { + href: string; + pathTemplate: string; + renderSalt?: string; + cb?: string; +} + +export function buildLazyUrl( + resolveChain: FieldResolver[], + ctx: LazyUrlContext, +): string { + const params = new URLSearchParams([ + ["format", "json"], + ["props", JSON.stringify({ loading: "eager" })], + ["href", ctx.href], + ["pathTemplate", ctx.pathTemplate], + [ + "resolveChain", + JSON.stringify(FieldResolver.minify(resolveChain.slice(0, -1))), + ], + ]); + if (ctx.renderSalt) params.set("renderSalt", ctx.renderSalt); + if (ctx.cb) params.set("__cb", ctx.cb); + return `/deco/render?${params}`; +} + +function isSectionShape(value: unknown): value is ResolvedSection { + if (!value || typeof value !== "object") return false; + const meta = (value as ResolvedSection).metadata; + return typeof meta?.component === "string"; +} + +function isLazyComponent(component: string | undefined): boolean { + return !!component?.endsWith(LAZY_SECTION_PATH); +} + +function getInnerSection(node: ResolvedSection): ResolvedSection | undefined { + const inner = (node.props as { section?: unknown } | undefined)?.section; + return isSectionShape(inner) ? inner : undefined; +} + +function getLoading(node: ResolvedSection): string | undefined { + return (node.props as { loading?: string } | undefined)?.loading; +} + +export function serializeResolvedSection( + node: ResolvedSection, + ctx: LazyUrlContext, +): SerializedSection { + let current = node; + while ( + isLazyComponent(current.metadata?.component) && + getLoading(current) === "eager" + ) { + const inner = getInnerSection(current); + if (!inner) break; + current = inner; + } + + if ( + isLazyComponent(current.metadata?.component) && + getLoading(current) === "lazy" + ) { + const inner = getInnerSection(current); + return { + component: inner?.metadata?.component ?? current.metadata!.component!, + lazyUrl: buildLazyUrl(current.metadata!.resolveChain ?? [], ctx), + }; + } + + return { + component: current.metadata!.component!, + props: walkValue(current.props ?? {}, ctx) as Record, + }; +} + +function walkValue(value: unknown, ctx: LazyUrlContext): unknown { + if (Array.isArray(value)) { + return value.map((v) => walkValue(v, ctx)); + } + if (value && typeof value === "object") { + if (isSectionShape(value)) { + return serializeResolvedSection(value, ctx); + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (k === "Component" && typeof v === "function") continue; + out[k] = walkValue(v, ctx); + } + return out; + } + return value; +} From bbadf989af7449bdbd2685305020c981096c5250 Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Wed, 10 Jun 2026 09:35:38 -0300 Subject: [PATCH 2/3] feat(serialize): sections control their JSON rendering via renderJson export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mod.ts | 4 ++ runtime/routes/render.tsx | 8 ++- runtime/routes/serialize-section.ts | 102 ++++++++++++++++++++++++++-- utils/mod.ts | 2 +- utils/object.ts | 53 +++++++++++++++ 5 files changed, 158 insertions(+), 11 deletions(-) diff --git a/mod.ts b/mod.ts index 71b6e277b..9b9e94416 100644 --- a/mod.ts +++ b/mod.ts @@ -47,11 +47,15 @@ export { Deco } from "./runtime/mod.ts"; export type { PageData } from "./runtime/mod.ts"; export { buildLazyUrl, + sectionModuleLookup, serializeResolvedSection, } from "./runtime/routes/serialize-section.ts"; export type { LazyUrlContext, + RenderJson, ResolvedSection, + SectionJsonModule, + SerializeContext, SerializedSection, } from "./runtime/routes/serialize-section.ts"; export { Murmurhash3 } from "./deps.ts"; diff --git a/runtime/routes/render.tsx b/runtime/routes/render.tsx index ee003be3e..a36279730 100644 --- a/runtime/routes/render.tsx +++ b/runtime/routes/render.tsx @@ -9,8 +9,9 @@ import { createHandler, DEBUG_QS } from "../middleware.ts"; import type { PageParams } from "../mod.ts"; import Render, { type PageData } from "./entrypoint.tsx"; import { - type LazyUrlContext, type ResolvedSection, + sectionModuleLookup, + type SerializeContext, serializeResolvedSection, } from "./serialize-section.ts"; @@ -98,15 +99,16 @@ export const handler = createHandler(async ( } if (opts.searchParams.get("format") === "json") { - const lazyCtx: LazyUrlContext = { + const serializeCtx: SerializeContext = { href: opts.href, pathTemplate: opts.pathTemplate, renderSalt: opts.renderSalt, cb: opts.searchParams.get("__cb") ?? undefined, + getSectionModule: await sectionModuleLookup(), }; const serialized = serializeResolvedSection( page as ResolvedSection, - lazyCtx, + serializeCtx, ); const jsonResponse = Response.json(serialized); const shouldCacheFromVary = ctx?.var?.vary?.shouldCache === true; diff --git a/runtime/routes/serialize-section.ts b/runtime/routes/serialize-section.ts index c61681f73..179b48e09 100644 --- a/runtime/routes/serialize-section.ts +++ b/runtime/routes/serialize-section.ts @@ -1,3 +1,4 @@ +import { Context } from "../../deco.ts"; import { FieldResolver } from "../../engine/core/resolver.ts"; const LAZY_SECTION_PATH = "/Rendering/Lazy.tsx"; @@ -15,6 +16,32 @@ export type SerializedSection = | { component: string; props: Record } | { component: string; lazyUrl: string }; +/** + * Module-level control of how a section renders to JSON. + * + * - `false` — the section has no JSON rendering; it is dropped from the + * serialized output entirely. + * - function — a projection applied to the resolved props before + * serialization. Annotate the parameter with `SectionProps` + * (or the section's own Props) to keep it compile-checked. + * + * ```ts + * export const renderJson = false; + * + * export const renderJson = ( + * { internalOnly, ...rest }: SectionProps, + * ) => rest; + * ``` + */ +export type RenderJson = + // deno-lint-ignore no-explicit-any + | ((props: any) => unknown) + | false; + +export interface SectionJsonModule { + renderJson?: RenderJson; +} + export interface LazyUrlContext { href: string; pathTemplate: string; @@ -22,6 +49,29 @@ export interface LazyUrlContext { cb?: string; } +export interface SerializeContext extends LazyUrlContext { + /** + * Resolves a section module by its component name (resolveType) so the + * serializer can honor its `renderJson` export. When omitted, every + * section serializes with its full resolved props. + */ + getSectionModule?: (component: string) => SectionJsonModule | undefined; +} + +/** + * Builds a `getSectionModule` lookup over the active context's merged + * manifest (site + apps), keyed by resolveType. + */ +export const sectionModuleLookup = async (): Promise< + (component: string) => SectionJsonModule | undefined +> => { + const runtime = await Context.active().runtime; + const sections = (runtime?.manifest as unknown as { + sections?: Record; + })?.sections ?? {}; + return (component) => sections[component]; +}; + export function buildLazyUrl( resolveChain: FieldResolver[], ctx: LazyUrlContext, @@ -60,10 +110,26 @@ function getLoading(node: ResolvedSection): string | undefined { return (node.props as { loading?: string } | undefined)?.loading; } +function renderJsonOf( + ctx: SerializeContext, + component: string, +): RenderJson | undefined { + return ctx.getSectionModule?.(component)?.renderJson; +} + +/** + * Serializes a resolved section honoring its `renderJson` export. + * Returns `null` when the section opted out (`renderJson === false`) — + * callers must drop it from the output. + * + * A `renderJson` projection cannot run for lazy placeholders (their props + * are not resolved yet); it applies on the lazy fetch itself, when + * `/deco/render?format=json` serializes the resolved section. + */ export function serializeResolvedSection( node: ResolvedSection, - ctx: LazyUrlContext, -): SerializedSection { + ctx: SerializeContext, +): SerializedSection | null { let current = node; while ( isLazyComponent(current.metadata?.component) && @@ -79,21 +145,43 @@ export function serializeResolvedSection( getLoading(current) === "lazy" ) { const inner = getInnerSection(current); + const component = inner?.metadata?.component ?? + current.metadata!.component!; + if (renderJsonOf(ctx, component) === false) return null; return { - component: inner?.metadata?.component ?? current.metadata!.component!, + component, lazyUrl: buildLazyUrl(current.metadata!.resolveChain ?? [], ctx), }; } + const component = current.metadata!.component!; + const renderJson = renderJsonOf(ctx, component); + if (renderJson === false) return null; + + const props = typeof renderJson === "function" + ? renderJson(current.props ?? {}) + : current.props ?? {}; + return { - component: current.metadata!.component!, - props: walkValue(current.props ?? {}, ctx) as Record, + component, + props: walkValue(props, ctx) as Record, }; } -function walkValue(value: unknown, ctx: LazyUrlContext): unknown { +function walkValue(value: unknown, ctx: SerializeContext): unknown { if (Array.isArray(value)) { - return value.map((v) => walkValue(v, ctx)); + // Dropped sections (renderJson === false) are removed from arrays so + // consumers iterate a dense list; non-section nulls pass through. + const out: unknown[] = []; + for (const v of value) { + if (isSectionShape(v)) { + const serialized = serializeResolvedSection(v, ctx); + if (serialized !== null) out.push(serialized); + } else { + out.push(walkValue(v, ctx)); + } + } + return out; } if (value && typeof value === "object") { if (isSectionShape(value)) { diff --git a/utils/mod.ts b/utils/mod.ts index c46c6627d..b30a9079a 100644 --- a/utils/mod.ts +++ b/utils/mod.ts @@ -12,7 +12,7 @@ export { adminUrlFor, isAdmin, resolvable } from "./admin.ts"; */ export { readFromStream } from "./http.ts"; export { metabasePreview } from "./metabase.tsx"; -export { tryOrDefault } from "./object.ts"; +export { deepOmit, tryOrDefault } from "./object.ts"; export type { DotNestedKeys } from "./object.ts"; export { createServerTimings } from "./timings.ts"; export type { Device } from "./userAgent.ts"; diff --git a/utils/object.ts b/utils/object.ts index 7d3b9db6f..3b237a032 100644 --- a/utils/object.ts +++ b/utils/object.ts @@ -146,3 +146,56 @@ export const tryOrDefault = (fn: () => R, defaultValue: R): R => { return defaultValue; } }; + +const omitAtPath = (obj: unknown, parts: string[]): unknown => { + if (!obj || typeof obj !== "object" || parts.length === 0) return obj; + + const [key, ...rest] = parts; + + // `*` fans the remaining path out over every array element / record value. + if (key === "*") { + if (Array.isArray(obj)) { + return obj.map((v) => omitAtPath(v, rest)); + } + return Object.fromEntries( + Object.entries(obj as Record).map(([k, v]) => [ + k, + omitAtPath(v, rest), + ]), + ); + } + + // Preserve array shape: apply the same path to each element. + if (Array.isArray(obj)) { + return obj.map((v) => omitAtPath(v, parts)); + } + + const current = obj as Record; + + if (rest.length === 0) { + const copy = { ...current }; + delete copy[key]; + return copy; + } + + return { + ...current, + [key]: omitAtPath(current[key], rest), + }; +}; + +/** + * Removes properties from `obj` by path, immutably. + * + * Supports top-level keys (`"seoProps"`), nested dot-notation paths + * (`"page.seo"`), and the `*` wildcard over record/array values + * (`"page.productsMap.*.internalFlag"`). Prefer typed rest-destructuring + * for top-level keys; reach for deepOmit on deep paths and dynamic keys. + */ +export const deepOmit = (obj: T, ...paths: string[]): T => { + let result: unknown = obj; + for (const path of paths) { + result = omitAtPath(result, path.split(".")); + } + return result as T; +}; From 61d9563413b9606c38c4bcb296a7a0c860bd52f0 Mon Sep 17 00:00:00 2001 From: marcoferreiradev Date: Wed, 10 Jun 2026 14:37:01 -0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(hooks):=20export=20computeRenderCb=20?= =?UTF-8?q?=E2=80=94=20shared=20cache-bust=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hooks/mod.ts | 1 + hooks/useSection.ts | 43 +++++++++++++++++++++++++++++++++++-------- mod.ts | 2 ++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/hooks/mod.ts b/hooks/mod.ts index 6de76d44a..7c0635b73 100644 --- a/hooks/mod.ts +++ b/hooks/mod.ts @@ -8,6 +8,7 @@ export { addAllowedQS, addBlockedQS, addBlockedQS as unstable_blockUseSectionHrefQueryStrings, + computeRenderCb, useSection, } from "./useSection.ts"; export { useSetEarlyHints } from "./useSetEarlyHints.ts"; diff --git a/hooks/useSection.ts b/hooks/useSection.ts index cfbe5d0f6..bab1c2255 100644 --- a/hooks/useSection.ts +++ b/hooks/useSection.ts @@ -94,6 +94,36 @@ const createStableHref = (href: string): string => { return hrefUrl.href; }; +export interface RenderCbInput { + revision: unknown; + vary: unknown; + href: string; + deploymentId?: string; +} + +/** + * Cache-bust value for `/deco/render` URLs. Single source of truth shared by + * `useSection` (web partials) and JSON serialization consumers (e.g. the + * website app's ?renderJson handler) — both sides MUST produce identical + * values or web/JSON cache invalidation diverges. + * + * `revision`/`vary` join as-is (undefined → "undefined") to preserve the + * historical recipe byte-for-byte. Shared `hasher` is safe ONLY because this + * function is fully synchronous — no await between hash() and reset(). + */ +export const computeRenderCb = (input: RenderCbInput): string => { + const cbString = [ + input.revision, + input.vary, + createStableHref(input.href), + input.deploymentId, + ].join("|"); + hasher.hash(cbString); + const cb = `${hasher.result()}`; + hasher.reset(); + return cb; +}; + export type Options

= { /** Section props partially applied */ props?: Partial

? K : P>; @@ -119,15 +149,12 @@ export const useSection =

( const hrefParam = href ?? request.url; const stableHref = createStableHref(hrefParam); - const cbString = [ - revisionId, + const cb = computeRenderCb({ + revision: revisionId, vary, - stableHref, - ctx?.deploymentId, - ].join("|"); - hasher.hash(cbString); - const cb = hasher.result(); - hasher.reset(); + href: hrefParam, + deploymentId: ctx?.deploymentId, + }); const params = new URLSearchParams([ ["props", JSON.stringify(props)], diff --git a/mod.ts b/mod.ts index 9b9e94416..0aeccd57a 100644 --- a/mod.ts +++ b/mod.ts @@ -50,6 +50,8 @@ export { sectionModuleLookup, serializeResolvedSection, } from "./runtime/routes/serialize-section.ts"; +export { computeRenderCb } from "./hooks/useSection.ts"; +export type { RenderCbInput } from "./hooks/useSection.ts"; export type { LazyUrlContext, RenderJson,