From 5e878927bf0c4e132eaf5404ba5c62d70d000243 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 19:00:04 +0200 Subject: [PATCH 1/5] fix(elements): renderToJson accepts a wrapper component, like renderToHtml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderToHtml renders a custom wrapper component through React, but renderToJson walked the element tree and rejected anything whose root wasn't // / — so renderToJson() threw while renderToHtml() worked. Unwrap a plain function-component root to its returned element (bounded loop; class/forwardRef/memo still hit the clear root-type error). Adds tests for the wrapper case and the still-invalid case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../react/src/utils/render-to-json.test.tsx | 20 +++++++++++ packages/react/src/utils/render-to-json.ts | 33 +++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/react/src/utils/render-to-json.test.tsx b/packages/react/src/utils/render-to-json.test.tsx index 20364a2..cbfdd61 100644 --- a/packages/react/src/utils/render-to-json.test.tsx +++ b/packages/react/src/utils/render-to-json.test.tsx @@ -310,6 +310,26 @@ describe("renderToJson", () => { ).toThrow("Root element must be "); }); + it("unwraps a user wrapper component down to its root (parity with renderToHtml)", () => { + const MyEmail = () => ( + + + + Wrapped + + + + ); + const design = renderToJson(); + expect(design.body.rows.length).toBe(1); + expect(design.body.rows[0].columns.length).toBe(1); + }); + + it("still throws when a wrapper does not resolve to a valid root", () => { + const NotARoot = () => nope; + expect(() => renderToJson()).toThrow("Root element must be "); + }); + it("generates _meta at all levels", () => { const design = renderToJson( diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts index 5b1e428..2755b9a 100644 --- a/packages/react/src/utils/render-to-json.ts +++ b/packages/react/src/utils/render-to-json.ts @@ -38,6 +38,33 @@ function getDisplayName(element: React.ReactElement): string | undefined { return type?.displayName || type?.name; } +/** The root components renderToJson understands. */ +const VALID_ROOTS = new Set(["Body", "Email", "Page", "Document"]); + +/** + * Unwrap a user wrapper component down to the underlying root element. + * `renderToHtml` renders wrappers through React; `renderToJson` walks the element + * tree, so a custom component that *returns* /// + * (e.g. `renderToJson()`) must be invoked first. Only plain function + * components are unwrapped — anything else (class / forwardRef / memo) falls + * through to the clear root-type error. + */ +function unwrapRoot(element: React.ReactElement): React.ReactElement { + let current = element; + for (let depth = 0; depth < 10; depth++) { + const name = getDisplayName(current); + if (name && VALID_ROOTS.has(name)) break; + const type = current.type as any; + const isPlainFunctionComponent = + typeof type === "function" && !type.prototype?.isReactComponent; + if (!isPlainFunctionComponent) break; + const produced = type({ ...(current.props as Record) }); + if (!React.isValidElement(produced)) break; + current = produced; + } + return current; +} + /** Collect valid React element children from a node. */ function collectChildren(node: React.ReactNode): React.ReactElement[] { const result: React.ReactElement[] = []; @@ -408,10 +435,12 @@ export function renderRowToJson(element: React.ReactElement): DesignRow { } export function renderToJson(element: React.ReactElement): DesignJSON { + // Accept a user wrapper component (e.g. ) by unwrapping to its root, + // matching renderToHtml which renders wrappers through React. + element = unwrapRoot(element); const displayName = getDisplayName(element); - const validRoots = new Set(["Body", "Email", "Page", "Document"]); - if (!displayName || !validRoots.has(displayName)) { + if (!displayName || !VALID_ROOTS.has(displayName)) { throw new Error( `[Unlayer] renderToJson: Root element must be , , , or , ` + `but got <${displayName || "unknown"}>. ` + From 2aafba085f456fa7d8539fff468a8b6651ddd041 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 19:00:04 +0200 Subject: [PATCH 2/5] fix(elements): relax Menu's text inputs to match Heading/Paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu's fontFamily/fontWeight/fontSize/letterSpacing kept the canonical strict types, so a string fontFamily or a number/em size that compiles on Heading failed on Menu. Relax them to the shared agent-friendly inputs (Menu has no color/lineHeight field, so only these four). Type-only — values are normalized at render time the same way as the other text components. Guarded in the tsc contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/components/Menu.tsx | 22 +++++++++++++++------- packages/react/src/dx-types.test-d.tsx | 7 +++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/Menu.tsx b/packages/react/src/components/Menu.tsx index e1f2af1..9d947ac 100644 --- a/packages/react/src/components/Menu.tsx +++ b/packages/react/src/components/Menu.tsx @@ -1,14 +1,22 @@ import { MenuExporters, MenuDefaults } from "@unlayer/exporters"; -import type { MenuValues, MenuItem, SizeInput } from "../types"; +import type { MenuValues, MenuItem, SizeInput, TextStyleProps } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -type MenuSemanticProps = Omit, "padding"> & { - /** Menu items shorthand */ - items?: MenuItem[]; - /** Inner padding — a number (→ px) or CSS string. */ - padding?: SizeInput; -}; +// Menu carries fontFamily/fontWeight/fontSize/letterSpacing (but not color or +// lineHeight) — relax those to the same agent-friendly inputs Heading/Paragraph +// use, so a string fontFamily or a number/em size type-checks (normalized at +// render time). It has no `color` field (uses linkColor/textColor). +type MenuSemanticProps = Omit< + SemanticProps, + "padding" | "fontFamily" | "fontWeight" | "fontSize" | "letterSpacing" +> & + Omit & { + /** Menu items shorthand */ + items?: MenuItem[]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; + }; export interface MenuProps extends ItemComponentProps {} diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index 951a196..a04264c 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -53,6 +53,13 @@ export const _table_padding_num: TableProps["padding"] = 12; export const _table_border_factored: TableProps["border"] = HAIRLINE; export const _divider_border_factored: DividerProps["border"] = HAIRLINE; +// Menu's text inputs are relaxed to match Heading/Paragraph (string fontFamily, +// number/em sizes) — it has fontFamily/fontWeight/fontSize/letterSpacing. +export const _menu_fontFamily_string: MenuProps["fontFamily"] = "Arial"; +export const _menu_fontSize_num: MenuProps["fontSize"] = 14; +export const _menu_letterSpacing_em: MenuProps["letterSpacing"] = "0.08em"; +export const _menu_fontWeight_num: MenuProps["fontWeight"] = 700; + // ── the rest of the natural DX surface (broader contract) ─────────────────── export const _fontSize_number: HeadingProps["fontSize"] = 28; export const _fontSize_string: HeadingProps["fontSize"] = "28px"; From 05ff64e29a2711b04ee75cd6679c5eb8a2ece441 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 19:25:12 +0200 Subject: [PATCH 3/5] docs(elements): keep extract-head's type comment to its own local concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-repo hygiene — the comment now describes only this file's local head type, not anything about how the rendering dependency is structured. --- packages/react/src/utils/extract-head.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/src/utils/extract-head.ts b/packages/react/src/utils/extract-head.ts index 730f368..06556d8 100644 --- a/packages/react/src/utils/extract-head.ts +++ b/packages/react/src/utils/extract-head.ts @@ -19,9 +19,8 @@ import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaul type HeadArgs = [Record, Record, Record]; /** - * The head contributions a component can emit — optional css/js/tags builders. - * The exporters' `heads` registry is untyped (Record), so describe - * the shape this file calls. + * Local type for a component's head contributions — the optional css/js/tags + * builders this file invokes to collect the CSS/JS/tags. */ type ComponentHead = { css?: (...args: HeadArgs) => string | undefined; From a0b2698a9f0f045d1e97cc5412692b25e0735378 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 19:31:59 +0200 Subject: [PATCH 4/5] fix(elements): give a clear error when a renderToJson wrapper throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoking a wrapper component that uses React hooks throws a bare "Invalid hook call" that masked the intended guidance. Catch the invocation and rethrow an actionable error: a wrapper must synchronously return a root (Email/Page/ Document/Body) and use no hooks — pass the root element or call the component. Adds a test for the throwing-wrapper path. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/utils/render-to-json.test.tsx | 7 +++++++ packages/react/src/utils/render-to-json.ts | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/react/src/utils/render-to-json.test.tsx b/packages/react/src/utils/render-to-json.test.tsx index cbfdd61..f91e44a 100644 --- a/packages/react/src/utils/render-to-json.test.tsx +++ b/packages/react/src/utils/render-to-json.test.tsx @@ -330,6 +330,13 @@ describe("renderToJson", () => { expect(() => renderToJson()).toThrow("Root element must be "); }); + it("rethrows an actionable error when a wrapper throws while unwrapping", () => { + const Boom = (): React.ReactElement => { + throw new Error("invalid hook call"); + }; + expect(() => renderToJson()).toThrow("could not unwrap"); + }); + it("generates _meta at all levels", () => { const design = renderToJson( diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts index 2755b9a..e85e320 100644 --- a/packages/react/src/utils/render-to-json.ts +++ b/packages/react/src/utils/render-to-json.ts @@ -58,7 +58,21 @@ function unwrapRoot(element: React.ReactElement): React.ReactElement { const isPlainFunctionComponent = typeof type === "function" && !type.prototype?.isReactComponent; if (!isPlainFunctionComponent) break; - const produced = type({ ...(current.props as Record) }); + // Invoking the wrapper can throw (e.g. it uses React hooks, which aren't + // valid when called outside React's render). Turn that into an actionable + // message instead of a bare "Invalid hook call". + let produced: unknown; + try { + produced = type({ ...(current.props as Record) }); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `[Unlayer] renderToJson: could not unwrap <${name || "wrapper"}>. A wrapper must ` + + `be a plain component that synchronously returns a root (, , ` + + `, or ) and uses no React hooks. Pass the root element directly, ` + + `or call your component: renderToJson(MyEmail()). (${detail})` + ); + } if (!React.isValidElement(produced)) break; current = produced; } From 51306258a2e034dab533ea6ba105bdc660191427 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sun, 28 Jun 2026 09:49:07 +0200 Subject: [PATCH 5/5] fix(elements): drop misleading workaround from the unwrap error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error fires only when invoking the wrapper itself threw, so calling it manually (renderToJson(MyEmail())) would fail identically — suggesting it was misleading. Point only to passing the root element directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/utils/render-to-json.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts index e85e320..05852e6 100644 --- a/packages/react/src/utils/render-to-json.ts +++ b/packages/react/src/utils/render-to-json.ts @@ -69,8 +69,8 @@ function unwrapRoot(element: React.ReactElement): React.ReactElement { throw new Error( `[Unlayer] renderToJson: could not unwrap <${name || "wrapper"}>. A wrapper must ` + `be a plain component that synchronously returns a root (, , ` + - `, or ) and uses no React hooks. Pass the root element directly, ` + - `or call your component: renderToJson(MyEmail()). (${detail})` + `, or ) and uses no React hooks. Pass the root element directly — ` + + `e.g. renderToJson(). (${detail})` ); } if (!React.isValidElement(produced)) break;