Skip to content

@unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes - #69

Open
ivoIturrieta wants to merge 18 commits into
mainfrom
feat/react-elements-0.3
Open

ivoIturrieta wants to merge 18 commits into
mainfrom
feat/react-elements-0.3

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

What

The next @unlayer/react-elements release (0.3.0): formats developers already use, a design → code converter, and fixes so the HTML and the design that opens in the Unlayer editor always agree. Nothing here is breaking.

New entries (separate imports, so the main bundle stays small)

Entry What it gives you
/tailwind <Tailwind>: classes become each component's props at render time, so the email gets inline styles and the design stays editable. Phone variants become phone settings, hover: becomes button hover colors, container text classes reach the text inside, and card grids get gutters.
/markdown <Markdown>: Markdown becomes editable blocks.
/code CodeBlock / CodeInline with highlight.js themes.
/font <Font>: web fonts with fallbacks. The URL is kept in the design.
/head <Head>: custom head tags.
/async Async templates and components, Suspense, and pretty output.
/codegen designToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones.

Main entry

  • Link.
  • style, className and skipInText on every component.
  • Plain-text options and toPlainText.
  • Prop aliases: Menu color, align/textAlign, Heading as/level. Close misspellings get "did you mean" warnings.
  • values accepts partial objects with strict keys (ValuesInput).
  • Rich inline JSX children.

HTML / editor parity fixes

  • <UnlayerProvider config> now applies. The most specific setting wins: render options, then the root's config, then the Provider, then defaults. Safe in Server Components and on the edge.
  • Paragraph text is stored as typed, so the editor no longer formats markup the email shows literally.
  • Exact px image widths.
  • Bare zeros are written as 0px in designs.
  • The Row background-image fullWidth default agrees between HTML and design.
  • The body link color applies in the HTML.
  • url() values are percent-encoded.
  • Right-to-left dir/lang on the body.
  • A Row whose Column count doesn't match its layout is left out alone, with an error, instead of blanking the email.
  • Render errors keep their cause.
  • Checked against the real editor (load, save and export 152 templates in the Unlayer editor): a Paragraph's HTML is now the editor's text block (a percent line height, no <p> wrapper), so lines with mixed text sizes no longer shift; a root's fonts reach the design; the document has the editor's base stylesheet for each mode; images and video thumbnails are sized in the block's slot (the default 10px padding), not the column; a fixed-width image's percent is the same in HTML and design; and the design records displayMode, so code generated from it picks the right root. A per-block test checks every block's HTML equals the editor's export of its design, in all three modes.

Row padding keeps the editor's meaning (outer padding, in email, web and print). Elements now warns when it sits outside a content background; use Column padding to inset. Tailwind p-* on a Row insets, as CSS does.

Validation

  • Tests: react 1333, shared 116, from-react-email 344. Typecheck, test:csp, test:browser-runtime, test:e2e, test:fidelity and test:differential pass.
  • Visual: Storybook builds and test:visual matches on 138 stories × 2 viewports. The baseline was regenerated after attributing every changed render to intended changes (exact image widths, the Provider fix, the text block's markup, images sized in their slot).
  • Integrations: the packed tarball builds and verifies in the Next.js 14, 15 and 16 apps and the Jest 30 app, including an UnlayerProvider check in a route handler, a Server Component, a Client Component and an edge route.
  • Bundle: the main entry is 130,245 / 130,500 bytes (budget raised from 118 KB; about 1.1 KB of it is the editor's base stylesheet). That's tight: the next main-entry addition needs room first.
  • Real editor round trip (152 templates: feature tests, showcase, CLI starters, all docs patterns and starters, MJML and Beefree imports; editor 1.524.0): every design loads, saves and exports with nothing lost, and the code generated back from it type-checks and gives the same design. Elements' HTML looks identical to the editor's export on desktop for 134/152 (115 before these fixes). The rest are the editor's own behavior (its stylesheet for Google fonts it lists, phone settings that need mobile design editing), the HTML-only props, or the newer editor's page margin for documents. The one Elements difference left is sub-pixel: a fixed-width image keeps the exact px the author asked for (64px), where the editor derives 63.97px from the design's percent.
  • Tailwind accuracy, measured against real Tailwind 4.2.1 in Chrome on 263 classes: 96.9% render like Tailwind in email and 95.7% on web; 168/170 colors are byte-exact; line heights are within 0.5px.

Release notes

  • Releases as 0.3.0 (minor): new entries and changed HTML output, no breaking API. The version is committed in packages/react/package.json, which the release job publishes instead of its default patch. Without it, this would ship as 0.2.2 to every ^0.2 project.
  • @unlayer/migrate's Elements range goes to ^0.3.0 here. The release job refuses an Elements outside the published migrate's range, so migrate then republishes after it (0.1.3, the same converter) and asks projects on 0.2.x to upgrade first.
  • The CHANGELOG lists everything new in 0.3 (every new entry, <Link>, style/className, the plain-text options, inline JSX), what changed, and the history from 0.1.1 to 0.2.1.
  • After publishing, move the @unlayer/react-elements-previous alias to 0.3.0.

📖 Storybook Preview: https://unlayer.github.io/elements/pr/69/

ivoIturrieta and others added 11 commits October 15, 2026 11:20
…nd codegen

New entries, each its own import so the main bundle stays within budget:
- `/tailwind`: <Tailwind> turns classes into each component's props at render time, so the
  email gets inline styles and the design stays editable; classes no prop takes become inline
  styles (HTML only, warned); max-sm: → phone settings, hover: → button hover colors. Container
  text classes reach the text inside, Row padding classes inset content and card grids get
  gutters built from borders in the surrounding color.
- `/markdown` (Markdown → blocks), `/code` (CodeBlock, CodeInline, highlight.js themes), `/font`
  (<Font> with fallbacks, URL kept in the design), `/head` (<Head>), `/async` (async templates and
  components, Suspense, `pretty`), `/codegen` (designToElements: an Unlayer design → readable
  Elements code; exact round trip on 48 of 49 real designs and fuzzed ones).
- Main entry: Link, style/className/skipInText on every component, plain-text options and
  toPlainText, prop aliases (Menu color, align/textAlign, Heading as/level) with "did you mean"
  warnings, `values` as ValuesInput (partial, strict keys), rich inline children.

Editor parity fixes (HTML and design now agree): UnlayerProvider config applies (render options >
root config > Provider > defaults); Paragraph text is stored as typed; exact px image widths;
bare zeros written as 0px; Row background-image fullWidth default; the body link color selector;
url() values percent-encoded; right-to-left dir/lang on body; a Row whose Column count doesn't
match its layout is left out alone, with an error; render errors keep their cause. Row padding
stays the editor's outer padding, with a warning when it sits outside a content background.

The main bundle budget rises from 118 KB to 126 KB (125,962 bytes now).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Guides for Tailwind, Markdown, code blocks, fonts, async templates and codegen; README sections
for Link, Head, aliases, the Provider precedence and Row padding; CHANGELOG Unreleased entries.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
CI's main-entry budget goes to 126,000 bytes. The Next.js 14/15/16 integration apps also render
a template inside an UnlayerProvider (route handler, Server Component, Client Component and the
Pages edge route) and verify the right-to-left HTML and design.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ding

A container with margins around a dark header and footer must stay row-level padding in the
design, the outer container in the HTML and `padding` in the TSX, so the gap shows the page
background.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A round trip through the real editor (load, save, export) showed where
renderToHtml and the editor's export of renderToJson's design differ:

- A Paragraph rendered through the Lexical paragraph exporter (a
  unitless line height and a <p> wrapper) while its design holds a text
  block, which the editor exports with a percent line height and no
  wrapper, so lines with text of another size moved. Both now use the
  text block, and formatted textJson stays HTML in the design.
- A root's `fonts` never reached the design. Families a font URL names
  now get the URL on their fontFamily, as <Font> does; anything else is
  warned about once.
- The document shell lacked the editor's base stylesheet for each mode.
- Images and video thumbnails were sized against the column, not the
  slot the default 10px block padding leaves, so they ran 20px wide.
- A pinned image's percent was rounded up in the HTML and to the
  nearest in the design.
- The design now holds the body's displayMode, which the editor keeps
  and codegen reads to pick the root.

A per-block test checks each block's HTML equals its design's exporter
output in all three modes. The bundle budget grows by about 3.9KB.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…check

The converted template's fonts are already listed in the report, so the
new warning for a root font the design can't hold isn't a lost block.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…Mode

Also say that the editor links its own stylesheet for the Google fonts
it already lists, with weights 400 and 700.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ender

With async templates under way at once, <Head> and <Font> recorded into
the top of the shared collection stack, which can be another template's:
one request's head tags and fonts could land in another's email. They now
find their template's collection by the render's ids, as Rows and blocks
do. Font's pure helpers move to utils/font-design so codegen still imports
nothing that renders.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A component's lone <a> became a Paragraph holding only what text can
hold, so a linked image was lost, and an <hr> with a style became a
default Divider. Each becomes a block only when the block keeps all of
it; anything else stays an HTML block, as in 0.2.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
String.fromCodePoint threw on a reference past U+10FFFF, so untrusted
Markdown could make <Markdown> throw and its Column be left out.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.71973% with 103 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.83%. Comparing base (eb0a80d) to head (a10b343).

Files with missing lines Patch % Lines
packages/react/src/markdown/Markdown.tsx 92.51% 14 Missing ⚠️
packages/react/src/async/stream.ts 85.88% 12 Missing ⚠️
packages/react/src/tailwind/values.ts 90.00% 9 Missing ⚠️
packages/react/src/codegen/blocks.ts 96.68% 8 Missing ⚠️
packages/react/src/codegen/test-harness.ts 95.10% 7 Missing ⚠️
packages/react/src/codegen/device.ts 89.65% 6 Missing ⚠️
packages/react/src/tailwind/inherit.ts 94.00% 6 Missing ⚠️
packages/react/src/tailwind/load.ts 92.50% 6 Missing ⚠️
packages/react/src/tailwind/color.ts 97.05% 5 Missing ⚠️
packages/react/src/codegen/design-to-elements.ts 96.11% 4 Missing ⚠️
... and 12 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   96.55%   97.83%   +1.27%     
==========================================
  Files          40       95      +55     
  Lines        2789     6550    +3761     
  Branches      795     2277    +1482     
==========================================
+ Hits         2693     6408    +3715     
- Misses         95      142      +47     
+ Partials        1        0       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… hold it

A bare <a> in a Column became a Paragraph only when its markup was
exactly what text holds, so a link with another attribute (aria-label)
was left out, call to action and all. A link whose tags text holds is a
Paragraph again (other attributes aside); one holding more (an image),
or a rule with a style, is an HTML block as it is, instead of a block
that loses part of it. A component's markup still becomes a block only
when nothing is lost, and is otherwise its own HTML block.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
@ivoIturrieta
ivoIturrieta marked this pull request as ready for review October 15, 2026 16:58
…o it

The release job bumps a patch unless a newer version is committed, so
this would have published 0.2.2 and given every ^0.2 project the
changed HTML. And it refuses an Elements outside the published
@unlayer/migrate's range (^0.2.0) unless the range is raised in the
same change. Committing 0.3.0 and raising migrate's range to ^0.3.0
lets Elements publish as 0.3.0 and migrate republish after it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 2 commits October 16, 2026 08:00
0.2.1 only exported the main entry, so Tailwind, Markdown, Code, Font,
Head and Async are new entries in 0.3, with <Link>, style/className/
skipInText, the plain-text options and inline JSX children. The
changelog listed few of them, and Tailwind's details as fixes. Also
adds the RTL alignment, Column markup and async error changes, and
drops mentions of CLI commands that aren't released.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The changelog went from Unreleased straight to 0.1.0. Each release since
has its entry now, from its release notes and pull requests.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
renderToHtml rethrew "[Unlayer] renderToHtml failed: …" without the error it caught, so tools
lost the template's stack and line numbers. The rethrown error now carries it as `cause`.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 2 commits October 16, 2026 18:36
…om's

In Next.js App Router (route handlers and Server Components run on two
React copies) and anywhere else the copies differ, the async renderers
never detected it, so Rows rendered as plain HTML: a web layout with 3
tables instead of 14 and a design with no rows, with only a warning.
They now probe as the sync path does and record without useId, kept
apart per render by AsyncLocalStorage (one at a time where there's
none). A render whose Rows still weren't collected now throws instead
of returning a broken email, sync and async alike; empty, text-only and
head-only templates don't.

The bundle budget grows by 1 KB for it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
An async route handler and an async Server Component in the Next 15 and
16 apps, checked to give the synchronous renderers' tables and rows.
Both failed before the fix (3 tables, no rows).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant