From 4f63cf0aab1dce498a069d548a8ca17645c239ba Mon Sep 17 00:00:00 2001 From: Ivo Date: Sun, 28 Jun 2026 13:38:26 +0200 Subject: [PATCH 1/4] fix(elements): size images against the real content width in columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item exporters received no column/body context, so the width-aware image exporter fell back to a fixed ~500px regardless of contentWidth or column count — full-width images rendered small and images in multi-column rows could overflow their column. Column now threads its index, the row cells, and the row/column/body values to its item children, and renderComponent surfaces them on the exporter `meta`, so the exporter computes the available width (contentWidth × column fraction, minus padding) the same way the editor does. A standalone item (no Body) now defaults contentWidth to 500 to match the schema default. Result: a full-width image fills the content width, an image in a 3-column row sizes to ~1/3, and nothing overflows. Updates the golden snapshot to the corrected sizes; adds regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../golden-template.test.tsx.snap | 8 ++-- packages/react/src/components/Column.tsx | 14 ++++++- packages/react/src/components/Image.test.tsx | 38 +++++++++++++++++++ packages/react/src/utils/create-component.tsx | 23 +++++++++-- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/react/src/__snapshots__/golden-template.test.tsx.snap b/packages/react/src/__snapshots__/golden-template.test.tsx.snap index b7d8953..65d8a34 100644 --- a/packages/react/src/__snapshots__/golden-template.test.tsx.snap +++ b/packages/react/src/__snapshots__/golden-template.test.tsx.snap @@ -46,7 +46,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 - Acme Co + Acme Co @@ -183,7 +183,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 - Spring Collection + Spring Collection @@ -346,7 +346,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 - Product A + Product A @@ -408,7 +408,7 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 - Product B + Product B diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx index 0075acf..e417ba6 100644 --- a/packages/react/src/components/Column.tsx +++ b/packages/react/src/components/Column.tsx @@ -126,7 +126,19 @@ export const Column: React.FC = (props) => { const ComponentType = child.type as any; // Use __unlayerRender (hook-free) if available, otherwise call directly const renderFn: Function = ComponentType[UNLAYER_RENDER_KEY] || ComponentType; - const rendered = renderFn({ ...child.props, _config }); + // Thread the column/body context so width-aware item exporters (Image) + // can size against the real available width (contentWidth × column + // fraction) instead of the fallback. Mirrors the editor, which passes + // this context to the content exporters. + const rendered = renderFn({ + ...child.props, + _config, + colIndex: index, + cells, + bodyValues, + rowValues, + columnValues: valuesWithMeta, + }); // Extract HTML from dangerouslySetInnerHTML if ( diff --git a/packages/react/src/components/Image.test.tsx b/packages/react/src/components/Image.test.tsx index 7e39491..dd2d277 100644 --- a/packages/react/src/components/Image.test.tsx +++ b/packages/react/src/components/Image.test.tsx @@ -3,9 +3,12 @@ import React from "react"; import { render } from "@testing-library/react"; import Image from "./Image"; import Body from "./Body"; +import Email from "./Email"; import Row from "./Row"; import { Column } from "./Column"; +import { ColumnLayouts, type ColumnLayout } from "../layouts/ColumnLayouts"; import { renderToJson } from "../utils/render-to-json"; +import { renderToHtml } from "../utils/render-to-html"; describe("Image Component", () => { it("renders an img element", () => { @@ -194,4 +197,39 @@ describe("Image Component", () => { expect(src.maxWidth).toBe("50%"); }); }); + + // Regression: an image must size against the real available width + // (contentWidth × column fraction), not a fixed fallback. The Column threads + // its body/column context to the exporter so this matches the editor. + describe("sizes against contentWidth × column width (threaded to the exporter)", () => { + const big = { url: "https://x/p.jpg", width: 1200, height: 600 }; + const imgWidth = (html: string) => { + const m = html.match(/]*\bwidth="(\d+)"/); + return m ? Number(m[1]) : null; + }; + const renderImg = (contentWidth: string, layout: ColumnLayout, cols: number) => { + const columns = Array.from({ length: cols }, (_, i) => ( + + + + )); + return renderToHtml( + + {columns} + + ); + }; + + it("a full-width image fills the content width (not the 500 fallback)", () => { + expect(imgWidth(renderImg("600px", ColumnLayouts.OneColumn, 1))).toBe(600); + }); + + it("an image in a 3-column row sizes to its column (~1/3), staying responsive", () => { + expect(imgWidth(renderImg("600px", ColumnLayouts.ThreeEqual, 3))).toBe(200); + }); + + it("respects a wider contentWidth", () => { + expect(imgWidth(renderImg("900px", ColumnLayouts.OneColumn, 1))).toBe(900); + }); + }); }); diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx index a6c7ca2..67a7fdf 100644 --- a/packages/react/src/utils/create-component.tsx +++ b/packages/react/src/utils/create-component.tsx @@ -48,6 +48,8 @@ export interface BaseItemComponentProps { cells?: any[]; bodyValues?: any; rowValues?: any; + /** @internal - this column's values, threaded by Column for width-aware exporters */ + columnValues?: any; /** @internal - Unlayer config threaded from UnlayerProvider */ _config?: UnlayerConfig; } @@ -124,6 +126,8 @@ interface RenderConfig { className?: string; style?: React.CSSProperties; args?: any[]; + /** Column/body context merged into the exporter `meta` (8th arg). */ + metaContext?: Record; innerHTML?: string; _config?: UnlayerConfig; exporter: Function; @@ -146,7 +150,7 @@ function ensureMeta(values: any, type: string, index: number = 0): any { * Handles error boundaries, exporterConfig construction, and container vs item calling conventions. */ function renderComponent(config: RenderConfig): JSX.Element { - const { type, values, mode, className, style, args = [], innerHTML, _config, exporter } = config; + const { type, values, mode, className, style, args = [], innerHTML, _config, exporter, metaContext } = config; try { // Build exporterConfig from _config (falls back to defaults) @@ -170,6 +174,7 @@ function renderComponent(config: RenderConfig): JSX.Element { const meta = { exporterConfig, mergeTagState: cfg.mergeTagState, + ...(metaContext ?? {}), }; html = exporter(values, ...args, undefined, meta); } @@ -240,6 +245,7 @@ export function createItemComponent< cells = [], bodyValues = {}, rowValues = {}, + columnValues = {}, _config, // Children @@ -268,9 +274,11 @@ export function createItemComponent< index ); - // 4. Ensure bodyValues has required fields + // 4. Ensure bodyValues has a contentWidth. Default to 500 to match the + // schema default (BodyDefaults.contentWidth) and the exporter's image + // fallback width, so a standalone item (no Body) sizes the same as the editor. const safeBodyValues = { - contentWidth: 600, + contentWidth: 500, ...bodyValues }; @@ -293,6 +301,15 @@ export function createItemComponent< className, style, args: [index, colIndex, cells, safeBodyValues, rowValues], + // The 8th arg the exporters actually read: column/body context for + // width-aware rendering (Image's available-width calc), mirroring the editor. + metaContext: { + columnIndex: colIndex, + columnValues, + rowCells: cells, + rowValues, + bodyValues: safeBodyValues, + }, _config, exporter, }); From d0001f5042fae7865ee0c07868f360bdcf43750c Mon Sep 17 00:00:00 2001 From: Ivo Date: Sun, 28 Jun 2026 13:47:34 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(elements):=20expose=20containerPadding?= =?UTF-8?q?=20as=20a=20typed=20item=20prop=20(number=20=E2=86=92=20px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit containerPadding (an item's content-wrapper padding) was threaded at runtime but only typed as a string and never exposed on item props — so containerPadding="10px" was a type error, and a bare number would render unitless. Type it as SizeInput on the item base props and normalize a number to px in Column, matching the other size props. Renders identically to the equivalent px string. Adds render + type tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/components/Column.tsx | 11 ++++++++--- packages/react/src/dx-behaviors.test.tsx | 18 ++++++++++++++++++ packages/react/src/dx-types.test-d.tsx | 2 ++ packages/react/src/utils/create-component.tsx | 4 ++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx index e417ba6..49b1590 100644 --- a/packages/react/src/components/Column.tsx +++ b/packages/react/src/components/Column.tsx @@ -167,13 +167,18 @@ export const Column: React.FC = (props) => { // `child.props.values?.containerPadding` only, which is undefined // for the flat-prop API, so every block collapsed to 0px padding.) const childProps = child.props as { - containerPadding?: string; - values?: { containerPadding?: string }; + containerPadding?: string | number; + values?: { containerPadding?: string | number }; }; - const containerPadding = + const rawContainerPadding = childProps.containerPadding ?? childProps.values?.containerPadding ?? DEFAULT_CONTAINER_PADDING; + // A bare number is treated as px (same idiom as other size props). + const containerPadding = + typeof rawContainerPadding === "number" + ? `${rawContainerPadding}px` + : rawContainerPadding; // Wrap via the canonical content-container exporter for this mode. const contentValues = { diff --git a/packages/react/src/dx-behaviors.test.tsx b/packages/react/src/dx-behaviors.test.tsx index 796cc1b..f1a44e5 100644 --- a/packages/react/src/dx-behaviors.test.tsx +++ b/packages/react/src/dx-behaviors.test.tsx @@ -225,4 +225,22 @@ describe("DX: shorthands and JSON output", () => { expect(out).not.toMatch(/failed to render/i); expect(out).toMatch(/40px/); }); + + // containerPadding (the item's content-wrapper padding) is a typed input now, + // and a bare number is treated as px — same as the other size props. + describe("containerPadding", () => { + it("a numeric containerPadding renders as px on the content wrapper", () => { + expect(html()).toMatch(/padding:\s*14px/); + }); + + it("a string containerPadding passes through", () => { + expect(html()).toContain("16px 24px"); + }); + + it("a numeric and the equivalent px-string render identically", () => { + const a = html(); + const b = html(); + expect(a).toBe(b); + }); + }); }); diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index a04264c..1383da0 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -47,6 +47,8 @@ export const _border_reject_string: ColumnProps["border"] = "1px solid #ccc"; export const _col_radius_num: ColumnProps["borderRadius"] = 8; export const _btn_radius_num: ButtonProps["borderRadius"] = 8; export const _btn_padding_num: ButtonProps["padding"] = 14; +export const _btn_containerPadding_num: ButtonProps["containerPadding"] = 14; +export const _btn_containerPadding_str: ButtonProps["containerPadding"] = "16px 24px"; export const _btn_border_factored: ButtonProps["border"] = HAIRLINE; export const _menu_padding_num: MenuProps["padding"] = 10; export const _table_padding_num: TableProps["padding"] = 12; diff --git a/packages/react/src/utils/create-component.tsx b/packages/react/src/utils/create-component.tsx index 67a7fdf..4086171 100644 --- a/packages/react/src/utils/create-component.tsx +++ b/packages/react/src/utils/create-component.tsx @@ -12,6 +12,7 @@ import type { ExporterName } from "@unlayer/types"; /** Exporter map keyed by display mode. Defined locally until added to @unlayer/types. */ type ItemExporters = Partial string>>; import type { RenderMode, UnlayerConfig } from "@unlayer-internal/shared-elements"; +import type { SizeInput } from "../types"; import { mergeValues, generateHtmlFromTextJson, @@ -41,6 +42,9 @@ export interface BaseItemComponentProps { className?: string; style?: React.CSSProperties; mode?: RenderMode; + /** Padding of the content wrapper around this item — a number (→ px) or a CSS + * string ("10px", "16px 24px"). Applied by the containing Column. */ + containerPadding?: SizeInput; // Internal props (for advanced use) index?: number; From b4e2acf038e5eacba5756081ba0fddcb63baebee Mon Sep 17 00:00:00 2001 From: Ivo Date: Sun, 28 Jun 2026 14:03:31 +0200 Subject: [PATCH 3/4] fix(elements): unique element ids in renderToHtml (match renderToJson + editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderToHtml generated ids per element position, so multi-row designs repeated u_row_1 / u_column_1 / u_content_*_1 — invalid HTML5 and out of step with both renderToJson (a global counter) and the editor (unique stored ids). Thread a per-render id counter on _config (reset by Body, shared by reference down the tree, SSR-safe) so every body/row/column/content id is unique. Updates the multi-element snapshots (id attributes only); adds a uniqueness test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../golden-template.test.tsx.snap | 62 +++++++++---------- packages/react/src/components/Body.tsx | 7 ++- packages/react/src/components/Column.tsx | 6 +- packages/react/src/components/Row.tsx | 3 +- .../__snapshots__/snapshots.test.tsx.snap | 4 +- packages/react/src/utils/create-component.tsx | 14 +++++ .../react/src/utils/render-to-html.test.tsx | 24 +++++++ 7 files changed, 81 insertions(+), 39 deletions(-) diff --git a/packages/react/src/__snapshots__/golden-template.test.tsx.snap b/packages/react/src/__snapshots__/golden-template.test.tsx.snap index 65d8a34..ca192c0 100644 --- a/packages/react/src/__snapshots__/golden-template.test.tsx.snap +++ b/packages/react/src/__snapshots__/golden-template.test.tsx.snap @@ -84,13 +84,13 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 } -
+
-
+
@@ -164,17 +164,17 @@ exports[`Golden Template: Marketing Email > snapshot locks the full email HTML 1 } -
+
-
+
- + - + - + - +