Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hooks/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
addAllowedQS,
addBlockedQS,
addBlockedQS as unstable_blockUseSectionHrefQueryStrings,
computeRenderCb,
useSection,
} from "./useSection.ts";
export { useSetEarlyHints } from "./useSetEarlyHints.ts";
43 changes: 35 additions & 8 deletions hooks/useSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<P> = {
/** Section props partially applied */
props?: Partial<P extends ComponentType<infer K> ? K : P>;
Expand All @@ -119,15 +149,12 @@ export const useSection = <P>(

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)],
Expand Down
15 changes: 15 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ 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,
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,
ResolvedSection,
SectionJsonModule,
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>

export { allowCorsFor } from "./utils/http.ts";
export type { StreamProps } from "./utils/invoke.ts";
Expand Down
30 changes: 30 additions & 0 deletions runtime/routes/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ 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 ResolvedSection,
sectionModuleLookup,
type SerializeContext,
serializeResolvedSection,
} from "./serialize-section.ts";

interface Options {
resolveChain?: FieldResolver[];
Expand Down Expand Up @@ -91,6 +97,30 @@ export const handler = createHandler(async (
if (isDebugRequest) {
return Response.json({ debugData: state.vary.debug.build() });
}

if (opts.searchParams.get("format") === "json") {
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,
serializeCtx,
);
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,
Expand Down
202 changes: 202 additions & 0 deletions runtime/routes/serialize-section.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Context } from "../../deco.ts";
import { FieldResolver } from "../../engine/core/resolver.ts";

const LAZY_SECTION_PATH = "/Rendering/Lazy.tsx";

export interface ResolvedSection {
Component?: unknown;
props?: Record<string, unknown>;
metadata?: {
component?: string;
resolveChain?: FieldResolver[];
};
}

export type SerializedSection =
| { component: string; props: Record<string, unknown> }
| { 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<typeof loader>`
* (or the section's own Props) to keep it compile-checked.
*
* ```ts
* export const renderJson = false;
*
* export const renderJson = (
* { internalOnly, ...rest }: SectionProps<typeof loader>,
* ) => rest;
* ```
*/
export type RenderJson =
// deno-lint-ignore no-explicit-any
| ((props: any) => Record<string, unknown>)
| false;

export interface SectionJsonModule {
renderJson?: RenderJson;
}

export interface LazyUrlContext {
href: string;
pathTemplate: string;
renderSalt?: string;
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<string, SectionJsonModule>;
})?.sections ?? {};
return (component) => sections[component];
};

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;
}

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: SerializeContext,
): SerializedSection | null {
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);
const component = inner?.metadata?.component ??
current.metadata!.component!;
if (renderJsonOf(ctx, component) === false) return null;
return {
component,
// The wrapper's resolveChain is what reconstructs this slot on the lazy
// fetch — it re-resolves the inner section and applies the inner's
// renderJson there. The inner is not independently resolvable, so its own
// chain would not rebuild this position; the wrapper's chain is correct.
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,
props: walkValue(props, ctx) as Record<string, unknown>,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

function walkValue(value: unknown, ctx: SerializeContext): unknown {
if (Array.isArray(value)) {
// 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)) {
return serializeResolvedSection(value, ctx);
}
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;
}
return value;
}
2 changes: 1 addition & 1 deletion utils/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading