Skip to content
Merged
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
13 changes: 11 additions & 2 deletions packages/react/src/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,20 @@ const Row: React.FC<RowProps> = (props) => {
// Resolve mode: explicit prop > _config > default
const mode: RenderMode = modeProp ?? _config?.mode ?? "web";

// Determine cells from layout or props
let cells = propsCells || [1];
// Determine cells from layout or props. With neither, default to one equal
// cell PER <Column> child (mirroring renderToJson) — `[1]` regardless of column
// count left the 2nd/3rd column with no cell, rendering width="NaN".
let cells: number[];
if (layout) {
validateColumnLayout(layout, React.Children.count(children));
cells = layout.cells;
} else if (propsCells) {
cells = propsCells;
} else {
const columnCount = React.Children.toArray(children).filter(
(c) => React.isValidElement(c) && /^Column$/.test((c.type as any)?.displayName || (c.type as any)?.name || "")
).length;
cells = Array(Math.max(1, columnCount)).fill(1);
}

// Merge body values with defaults
Expand Down
50 changes: 50 additions & 0 deletions packages/react/src/dx-footguns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,53 @@ describe("attrs href round-trips into the editor (renderToJson stores values.hre
expect(href.values.href).toBe("https://x.com/s");
});
});

describe("multi-column Row without an explicit layout defaults to equal columns (no NaN)", () => {
const threeColNoLayout = (
<Email contentWidth="600px">
<Row>
<Column><Image src="u" /></Column>
<Column><Image src="u" /></Column>
<Column><Image src="u" /></Column>
</Row>
</Email>
);
it("renderToHtml emits no NaN width", () => {
expect(renderToHtml(threeColNoLayout)).not.toContain("NaN");
});
it("renderToJson defaults cells to the Column count", () => {
expect((renderToJson(threeColNoLayout) as any).body.rows[0].cells).toEqual([1, 1, 1]);
});
it("a layout mistakenly placed on <Column> still doesn't NaN (Row defaults)", () => {
const html = renderToHtml(
<Email contentWidth="600px"><Row>
<Column layout={ColumnLayouts.TwoEqual as any}><Image src="u" /></Column>
<Column><Image src="u" /></Column>
</Row></Email>
);
expect(html).not.toContain("NaN");
});
});

describe("border widths render with a px unit (number → Npx)", () => {
const borderTop = (border: any) => {
const html = renderToHtml(
<Email contentWidth="600px"><Row layout={ColumnLayouts.OneColumn}><Column border={border}><Image src="u" /></Column></Row></Email>
);
return (html.match(/border-top:[^;"]*/i) || [""])[0];
};
it("a number border width gets px (not unitless)", () => {
expect(borderTop({ borderTopWidth: 1, borderTopStyle: "solid", borderTopColor: "#000" })).toBe("border-top: 1px solid #000");
});
it("a px-string border width is unchanged", () => {
expect(borderTop({ borderTopWidth: "3px", borderTopStyle: "solid", borderTopColor: "#000" })).toBe("border-top: 3px solid #000");
});
it("flat border-side props (gathered) also get px", () => {
const html = renderToHtml(
<Email contentWidth="600px"><Row layout={ColumnLayouts.OneColumn}>
<Column borderBottomWidth={2 as any} borderBottomStyle={"solid" as any} borderBottomColor={"#abc" as any}><Image src="u" /></Column>
</Row></Email>
);
expect(html).toContain("border-bottom: 2px solid #abc");
});
});
39 changes: 39 additions & 0 deletions packages/shared/src/utils/semantic-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,42 @@ describe("normalizeValuesForExporter", () => {
expect(out.href).toEqual(RENDER_HREF);
});
});

describe("border width px normalization", () => {
// Normalization only runs for components that declare a `border` field (Column,
// Divider, Table, …); use a Column-like defaults with an empty border group.
const COLUMN_DEFAULTS = { border: {}, padding: "0px" } as any;

// The BorderInput type accepts a number, but a bare number renders
// `border-top: 1 solid` (invalid CSS); the mapper must add the px unit.
it("normalizes a number border width to px", () => {
const r = mapSemanticProps(
{ border: { borderTopWidth: 1, borderTopStyle: "solid", borderTopColor: "#000" } },
COLUMN_DEFAULTS,
"Column"
);
expect((r.border as any).borderTopWidth).toBe("1px");
});

it("gathers flat border-side props and px-normalizes the width", () => {
const r = mapSemanticProps(
{ borderBottomWidth: 2, borderBottomStyle: "solid", borderBottomColor: "#abc" },
COLUMN_DEFAULTS,
"Column"
);
expect((r.border as any).borderBottomWidth).toBe("2px");
});

it("leaves a px / non-numeric width string unchanged", () => {
const r = mapSemanticProps({ border: { borderTopWidth: "3px" } }, COLUMN_DEFAULTS, "Column");
expect((r.border as any).borderTopWidth).toBe("3px");
});

it("does not mutate the caller's border object (stays pure)", () => {
// A reused `const HAIRLINE` must not be rewritten as a side effect.
const userBorder = { borderTopWidth: 1, borderTopStyle: "solid", borderTopColor: "#000" };
const r = mapSemanticProps({ border: userBorder }, COLUMN_DEFAULTS, "Column");
expect((r.border as any).borderTopWidth).toBe("1px"); // output normalized
expect(userBorder.borderTopWidth).toBe(1); // caller's object untouched
});
});
20 changes: 20 additions & 0 deletions packages/shared/src/utils/semantic-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,26 @@ export function mapSemanticProps<T extends Record<string, any>>(
if (Object.keys(collected).length > 0) {
final.border = { ...(final.border || {}), ...collected };
}

// Border widths must carry a unit — `borderTopWidth: 1` would otherwise
// render `border-top: 1 solid` (invalid CSS the browser drops). The
// BorderInput type accepts a number, so normalize *Width fields (bare number
// or unit-less numeric string → px), wherever the border came from.
if (final.border && typeof final.border === "object") {
// Clone before rewriting: final.border may alias the caller's object (the
// `values` escape hatch is only a shallow clone, and a nested `border` prop
// passes through by reference), so mutating in place would be an impure side
// effect — e.g. a shared `const HAIRLINE = { borderTopWidth: 1 }` would get
// rewritten to "1px" in the caller.
const b: Record<string, any> = { ...(final.border as Record<string, any>) };
for (const key of Object.keys(b)) {
if (!/Width$/.test(key)) continue;
const v = b[key];
if (typeof v === "number") b[key] = `${v}px`;
else if (typeof v === "string" && /^\d+(?:\.\d+)?$/.test(v.trim())) b[key] = `${v.trim()}px`;
}
final.border = b;
}
}

return final as T;
Expand Down
Loading