Skip to content

React Email → Elements migration (@unlayer/migrate) - #61

Merged
ivoIturrieta merged 300 commits into
mainfrom
feat/react-email-migration
Oct 13, 2026
Merged

ivoIturrieta merged 300 commits into
mainfrom
feat/react-email-migration

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Elements 0.2.0, and a one-command path from React Email to Elements. Users keep their templates, gain visual editing in the Unlayer editor and web/PDF output, and coding agents get a clear route to Elements.

npx @unlayer/migrate ./emails --report migration.md   # convert and check, write nothing
npx @unlayer/migrate ./emails --write                 # replace the templates
npx @unlayer/migrate compare <original> <migrated>    # check a template converted by hand

1. Elements 0.2.0 (@unlayer/react-elements)

One React render now runs the template, and the HTML, the head's CSS and the design JSON are all made from it. 0.1.22 walked the tree three separate ways, which disagreed. Rows from Fragments and components came out in a web layout in emails, ids repeated, a Context Provider never reached the design, and a component with hooks could blank its Column. Now hooks, Context, useId, Fragments, memo/forwardRef and conditions behave as React runs them. A component that throws, suspends or is async is left out with a console message naming it, and the rest of the email renders.

Also fixed:

  • <Row noStackMobile> works.
  • rgb() colors render (Tailwind writes them).
  • Phone settings match the editor's device CSS.
  • Roots accept fonts, textDirection and lang.
  • renderToHtml(<Welcome />) renders a template component as its root.

Security fixes: text placed straight in a Row or Column, Heading and Button children, Menu and Social text, link URLs, previewText, phone settings written into the head's CSS, and textDirection could all inject markup. They're escaped now.

Elements renders with react-dom/server, as 0.1.22 did, and does nothing at import. Called inside an app's own react-dom/server render (a preview page that works out its design in useMemo), it renders with a second react-dom module where one shares the app's React, so the page's hooks keep working. Otherwise it renders the way 0.1.22 did.

It's tested in:

  • Node 18–25 (ESM and CJS);
  • React 18 and 19;
  • Next.js 14 and 15 (route handlers, Server Components, Client Components, an edge route);
  • Jest 30 with jsdom;
  • browser and server bundles.

The bundle is 117,919 bytes (budget 118KB; 0.1.22 was about 76KB).

Behavior changes (release notes)

  • <Row noStackMobile> keeps columns side by side on phones; the design saves it as _override.mobile.noStackMobile.
  • Text shows as typed:
    • Heading and Button children, previewText, and text placed straight in a Row or Column escape markup and entities, as React shows text.
    • HTML goes in the text prop, Paragraph's html or an <Html> block.
    • So does template syntax with < or >, such as {% if a > b %}.
    • Write the inbox padding characters themselves, not &nbsp;&zwnj;.
  • Menu text in emails escapes markup and double quotes, also inside a template tag (the text is in an aria-label too), but keeps entities and single quotes, as in 0.1.22: write a template's string in single quotes ({{ name | default: 'Shop' }}).
  • ", < and > in link URLs are percent-encoded. SendGrid's <%name%> tags stay as written.
  • Every document's head carries the editor's device CSS (hideOnDesktop/hideOnMobile), so menu separators hide on phones.
  • Rows from Fragments and components are email tables with their own ids, so emails built that way are larger (a review's example went from 37KB to 81KB, closer to Gmail's 102KB clip).
  • renderToJson keeps phone settings, visibility flags and a Provider's context.
  • useId() values differ from 0.1.22's, but are unique and the same in the HTML and the design.
  • A component that throws, suspends or is async is left out with a console message (0.1.22 rendered no rows).
  • An undefined component (a broken import) or an object as a child fails the render wherever it is, as React's own render does, also inside a layout component.
  • Your components in a Column get only the props you give them; 0.1.22 also passed Elements' internal ones (_config, bodyValues, cells…). Markup they return (a wrapper, a link) renders where 0.1.22 left it out.
  • Markup written straight in a Row or Column is still left out, but a block, Column or Row inside it (a <div> around them) now renders.
  • A block's values._meta.htmlID is now its wrapper's id, which the head's CSS selects (0.1.22 wrote a generated id there, so its hover and link styles missed). One values object with an id, reused on several blocks, gives them all that id. An id or class name given there loses quotes, <, >, & and backticks (an id, spaces too), which could end its attribute.
  • rgb() colors are written as hex and double-quoted font names with single quotes, in the HTML and the design.
  • Plain text breaks the line at a <br> in a heading, link or list item, and decodes entities in headings.
  • renderToJson leaves markup in a Column out, as the HTML does (0.1.22 threw).
  • A component may be called once more on its own (to take its HTML for a Column, or to say why it failed), so side effects belong outside rendering.
  • renderToHtml reads the root's settings through a Provider, StrictMode or Profiler around the template: <UnlayerProvider><Email>… gives the email document. A class template, or a root inside other markup, renders as before, with a console warning that its root's settings (fonts, lang, dir, phone styles) aren't read.
  • Renders are slower. A review measured 2.6× 0.1.22's time on the server on average, about 0.2 ms more per render. Live-preview updates in the browser are 15–30% slower.
  • A textDirection other than ltr/rtl/auto is left out.
  • Text placed straight in a Row or Column logs a console warning, once per process: it isn't kept in the design.
  • A root's lang and fonts are root settings now, so the design's body values no longer copy them (0.1.22 passed them through as unknown props; the editor reads neither there). A font URL given twice is linked once.
  • Row's device props are typed.

2. React Email migration (@unlayer/migrate)

One published package: the CLI, and the API at @unlayer/migrate/react-email. @unlayer/from-react-email (the converter) and @unlayer/convert-core (shared tree, report, printer and content check) are private and bundled in.

What the CLI does:

  • It runs in the user's project with their React, React Email and tsconfig.
  • It writes nothing without --write or --out. --write replaces only files git can restore (--allow-dirty overrides that), and --out writes copies.
  • --overwrite replaces only what an earlier run wrote or what git can restore, never a design made in the editor.
  • --design writes design JSON for loadDesign(), with text props as merge tags.
  • --report writes Markdown or JSON.
  • Framework route files (Next.js, Remix) are never rewritten as emails.

How each template is checked. The original and the migrated template are rendered with PreviewProps, then with each boolean prop flipped. Every word, link, image and alt text must match on desktop, on phones, on wider screens and in dark mode. Any difference flags the template ✗ and the run exits 2.

Styles, layout, and CSS the check can't read are listed as "looks different": the template is still written, and the user fixes the look in the editor. The check fails closed: anything it can't evaluate on one side counts as a difference. It's tested against Chromium in CI.

Exit codes: 0 when every template checked out, 1 for usage errors or when nothing was checked, 2 for a content difference or a failure.

Results on 106 templates (React Email's demos, community and agent-written templates):

  • All convert and type-check, with no word, link or image lost or added.
  • About 98% of the content is native (editable) blocks.
  • 40 look different (for example phone styles at another breakpoint, a dropped shadow or rounded image corners), listed for the user to fix in the editor.
  • Every template that compiles with noUnusedLocals/noUnusedParameters migrates to one that does.

Details are in packages/from-react-email/FIDELITY.md.

Known limits

  • Called inside an app's own react-dom/server renderToString where no second react-dom module shares the app's React (a bundle with React in it, some test runners), Elements renders with the same module, as 0.1.22 did. A call in useMemo or before another hook then fails, as 0.1.22's renderToHtml did there, and renderToJson now does too (0.1.22's walked the tree without rendering); calling them in the component's body works.
  • In Next.js's edge runtime, react-dom can't render with React Server Components' React. renderToHtml fails there, as in 0.1.22, and renderToJson (a tree walk in 0.1.22) now fails too, with an error saying to use the Node.js runtime. Next.js 16 deprecates the edge runtime.
  • An ES module or a bundle on Node 18, or 20 below 20.16 (both past end of life), can't load the second react-dom module.
  • Called inside an app's own renderToString, an email's component reads the page's Context value where the email has no Provider of its own; with one, it reads the email's, and React warns in development ("Detected multiple renderers…"). The page keeps its values. (0.1.22 left such components out.)
  • In a live preview rendered with createRoot, a Context Provider around <Email> doesn't reach the components inside it.
  • In a Next.js Client Component's server render, Context read inside an email worked out there gets the page's value instead of the email's own Provider; the browser render gets it right, so React logs a hydration mismatch. (0.1.22 left such components out of the email.)
  • Pre-existing in 0.1.22 too:
    • table cells take HTML as given;
    • javascript: URLs render as given;
    • Body's linkStyle never applies;
    • Next.js 14 can't bundle Elements for an edge route.
  • Migration:
    • email columns sit at the top;
    • shadows, gradients and image border radius are dropped;
    • phone settings use the editor's 480px breakpoint;
    • @react-email/components (deprecated on npm) is still a dependency; switching to @react-email/render is a follow-up.

Testing

1,749 tests (shared 107, react 987, convert-core 193, from-react-email 283, migrate 179), and 17 CI steps. Beyond unit tests, the gates are:

  • Previous-release parity: examples, template patterns and 600 generated templates rendered with this build and with 0.1.22 must match where 0.1.22 was right (5,000 locally).
  • Consumers: Next.js 15 (Turbopack) and Next.js 14 apps render emails in route handlers, Server Components, Client Components and an edge route. Jest 30 with jsdom, browser and Node bundles, and Node scripts on React 18/19 must render and exit.
  • Hostile inputs, CSP, browser E2E, Storybook smoke and visual drift.
  • Migration: a fidelity smoke test in Chromium, and the check against Chromium on 400 seeded documents.

Before release, the Elements build was compared with 0.1.22 four ways, and everything found is fixed or listed above:

  • rendered output across about 8,600 template variants;
  • every export and type, under several TypeScript settings;
  • client previews in jsdom and Chromium;
  • runtime environments: Node, Next.js, Vite, Vitest, Jest, Deno, bundlers.

Releasing

The release workflow publishes each package only when its code changed since its last tag (Elements v*, migrate migrate-v*), each behind the existing approval. Elements is set to 0.2.0, and migrate depends on ^0.2.0.

Before merging:

  • Cancel any Release run still waiting for approval from an earlier merge. It runs main's old workflow and would ship this code as 0.1.23. None is waiting now.
  • For 30 days, also reject any approval for a "Publish to npm" job from a run that predates this merge.

After merging:

  1. Approve the run: Elements 0.2.0 publishes. Write its GitHub release notes by hand from "Behavior changes" above: the generated ones don't list them.
  2. Publish @unlayer/migrate 0.1.0 by hand from main:
    • run pnpm install --frozen-lockfile && pnpm build at the root;
    • run pnpm pack in packages/migrate, and check the tarball has dist/bin.js;
    • publish the tarball with public access;
    • push the tag migrate-v0.1.0.
  3. On npmjs.com, add the trusted publisher for @unlayer/migrate: repository unlayer/elements, workflow publish.yml, environment npm-publish.
  4. Run npx @unlayer/migrate on a sample template in an empty folder.
  5. Move @unlayer/react-elements-previous in packages/react/package.json to 0.2.0.

The review rounds' fixes, each with the test that fails without it, are in the commit messages.

🤖 Generated with Claude Code


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

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.55%. Comparing base (fc106a6) to head (aba992f).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #61      +/-   ##
==========================================
+ Coverage   95.54%   96.55%   +1.01%     
==========================================
  Files          32       40       +8     
  Lines        1975     2787     +812     
  Branches      390      795     +405     
==========================================
+ Hits         1887     2691     +804     
- Misses         87       95       +8     
  Partials        1        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.

ivoIturrieta and others added 29 commits October 8, 2026 13:34
renderToHtml read the root's settings (fonts, lang, dir, phone styles)
from the element it was given. React Email users write
render(<Welcome />), and renderToHtml(<Welcome />) rendered the email but
silently dropped those settings.

renderToHtml and renderToHtmlParts now unwrap a template component to its
root first, as renderToJson already did. The shared helper also unwraps
memo and forwardRef, nested in any order, for all three. A wrapper that
can't be unwrapped (React hooks, a class, an async component) still
renders through React, with a warning that its root's settings are
ignored.

The bundle budget goes to 87KB (86,697 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check called the inner function of a template exported with memo or
forwardRef, so it passed while renderToHtml(Template(props)) threw on the
memo object. It now calls the export itself, unwrapping memo and
forwardRef the way renderToHtml does, and the README renders templates as
JSX: renderToHtml(<Welcome name="Alex" />).

A template that calls React hooks failed with "Invalid hook call". The
CLI now says it calls React hooks, which Elements templates can't use yet,
and what to do instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tyle> rules

A style computed from props (`style={{ color, fontSize: size }}`) was
dropped whole, so white-on-black text came out black at 14px, invisible,
while the template still passed the check with only a note.

- Known values in a style object are kept one by one, and conditions the
  codemod can work out (`level === "h1" ? 24 : 20`, `undefined ? a : b`)
  are evaluated.
- Values computed from props are listed with their line as lost styles,
  and the check fails on them (exit 2; `--force` still writes). An element
  kept as HTML keeps its style, so it doesn't count.

Rules in a head <style> were dropped too, with nothing reported, and
text styled by a class rule lost its color and size. Rules on a single
class now apply under the element's own styles, phone media queries on
those classes become phone settings, and every other rule is listed in
the report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
With --write, a file imported by another scanned file is a shared
component and is left in place. Two cases went wrong:

- An `emails/index.ts` that re-exports templates counted as using them,
  so every template was skipped as shared and nothing was migrated.
- A template importing a shared footer through a re-export file outside
  the scanned folder didn't reach the footer, which was then migrated
  into a full email.

`export … from` lines no longer count as uses, and an import is followed
through re-export files to the modules that define the names it takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`--write=false` ignored the value and replaced the templates in place.
Flags now take no value: `--write=false` stops with "--write takes no
value" (exit 1) and writes nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`<Img width={80} height={20}>` showing a square file rendered at 80×80
after migration, and the report listed no difference. An Elements image
shows at its file's own aspect ratio, and the converter doesn't load the
file to compare, so an explicit height is now reported as not kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nd images

The content check lowercased words and dropped standalone signs before
comparing, and only counted links and images the conversion lost. So
"Use coupon SAVE10" passed against "save10", "Balance − 100 USD" against
"Balance 100 USD", and a template with an extra image passed as "same
words, links and images".

- Words compare in their written case.
- A lone sign is a word. Minus signs are one character however they're
  written (-, −, &minus;), and text is NFC-normalized with zero-width
  characters removed, so encodings don't fail the check.
- Links, image sources and alt text only the conversion has fail the
  check too. They compare as sets, so a renderer repeating a link (a
  button's fallback) isn't an addition.

The benchmark's 212 conversions all still pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…markup

React 19's renderToStaticMarkup adds `<link rel="preload" as="image">`
before each image. Blocks kept as React Email markup (rendered at runtime
by the migrated template, or at conversion in runtime mode) carried those
tags into the email body and the design JSON in React 19 projects, which
a fresh react-email 6 project is.

Both modes now render static markup through a small wrapper that removes
them. The 71 official demo templates on react-email 6 and React 19 now
give the same designs as on @react-email/components and React 18.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fidelity benchmark measures 106 templates, but only locally: its
corpus isn't in the repo. A layout regression, like phone sizes showing
on desktop, was caught only when someone ran it.

`pnpm --filter @unlayer/from-react-email test:fidelity` converts the
fixture templates in test/fixtures both ways and renders them next to
their originals in Chromium at 700px and 375px, with the network off. It
fails on lost or added content, on a word shown at another font size on
desktop, or on words moved more than bench/smoke-baseline.json allows.
A new fixture covers a theme's own Tailwind phone variant and type scale.

The benchmark's word measurement moves to bench/layout.ts, shared by
both. With the phone size put on desktop on purpose, the test fails and
names the resized words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…match

The smoke test's baseline held the words moved on phones for every
fixture, but for the two fixtures that still differ there the number
depends on the machine's fonts: weekly-digest moves 29% on macOS and 64%
on CI's Linux, so CI failed without any change in the conversion.

The rule is now fixed instead of measured: every fixture must keep its
content, its desktop text sizes and its desktop layout, and phones must
match for the fixtures that match there (welcome, phone-variants). A 2%
tolerance absorbs font rendering. The baseline file is gone. A phone size
shown on desktop on purpose still fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
An inlined component with an optional `style` prop left out spreads
`undefined` into its style object (`{ ...base, ...(undefined) }`). That
adds nothing, but the codemod counted it as a style value computed from
props, so the CLI failed a template that converts fine (one of the 106 in
the benchmark). A spread of `undefined` or `null` is now skipped.

The benchmark didn't catch it because it didn't count lost styles; it
does now, like the CLI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
renderToHtml(<Template />) called the template function directly to reach
its root. Called while the app is rendering (a live preview in useMemo),
a template's hooks ran on the app component's hook list: the app crashed
on its next render ("Rendered fewer hooks than expected"). 0.1.22 didn't.

Each wrapper is now called inside a throwaway renderToStaticMarkup, which
sets up and restores its own dispatcher, the way 0.1.22's nested body
render did. Its hooks run there, never on the app's component, and they
work now: a template that uses hooks keeps its root's settings in
renderToHtml and unwraps in renderToJson. An async or suspending template
gets a clear error.

Every example email, component and mode renders byte-identical to before;
only a template with hooks changes, now keeping its settings. The bundle
budget goes to 88KB (87,556 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Both publish jobs checked out `main` after the approval step. If another
change merged while an approval waited, the package that shipped wasn't
the commit this run had built and tested. They now check out the run's
own commit (github.sha), and the release tag goes on that commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ines the name

With `index.ts` holding `export * from "./footer"; export * from "./welcome"`
and a template importing `{ Footer }` from it, `--write` skipped
welcome.tsx as a shared component: the imported names were passed to
every `export *` source, and any file reached counted as used. A file now
counts only when it exports one of the imported names itself (a file with
no ES exports still counts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`--write` on a shared component given alone (a footer, without the
template that imports it) replaced it with a full Email, and the template
still importing it then rendered an email inside an email. With `--write`,
a component that doesn't render <Html> is now skipped as a shared
component. `--out` keeps writing separate copies, as documented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Only `display: none` counted as hidden, so `<Text style={{ visibility:
"hidden" }}>` became a visible block. `visibility: hidden` now keeps the
element as its original HTML, like `display: none`, so it stays hidden
and keeps its space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`<Img width="100%" style={{ maxWidth: "300px" }}>` shows at 300px in
React Email, but the percent width isn't a size the converter reads, so
the cap was dropped and the image filled its column (600px on desktop).
The width is now the smaller of the width and the max-width, as in CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Three cases looked like desktop on a 375px phone, with no difference
reported:

- React Email 6 writes phone queries in range syntax, `(480px>=width)`,
  which the converter didn't recognize (Tailwind `screens` with `max`).
  Phone queries are now read in every form: `max-width`, `width <= …`,
  `… >= width`; anything with a lower bound still isn't one.
- A CSS comment inside a phone `@media` block dropped its rules. Comments
  are removed first.
- Content hidden with `display: none` and shown by a phone rule stayed
  hidden. It's now converted as a block hidden on desktop. A box (a
  Section) can't be hidden that way, so it stays HTML and the report says
  it stays hidden on phones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… size

Browsers show monospace text at 13px where the text around it is the
default 16px, but only while that size is the default (`medium`). A
`<code>` with no size of its own became a 16px paragraph (Notion's login
code: 13px became 16px), and blocks kept as HTML stated `16px`, which
cancels the browser's monospace size too.

A paragraph that is a lone monospace element now gets 13px, and blocks
kept as HTML state the default size as `medium`: still 16px, including in
the editor's canvas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Apple's receipt showed its invoice date and order number in black
instead of #333. They sit in a Section kept as HTML, inside a div with
the inherited color, but an Elements email sets `table, td { color:
#000000 }` (as the editor's export does), which beats inheritance.

Kept markup now gives its tables and cells the color of the div around
them, unless they set their own. Both modes; word positions unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… rule

`.copy { font-size: 24px }` in a head <style> showed the text at 24px
after migration, but at 14px in React Email: `<Text>` sets its size
inline, and an inline style beats a stylesheet rule unless the rule is
`!important`.

A head rule (and a phone rule) now applies only to what the component
doesn't set inline itself (measured: Text's size, line height and
top/bottom margins; Link's color; Button, Img, Hr, Container and Row
defaults), unless it's `!important`. Both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A template's `const paragraphStyle = {...}` used in a `style` prop stayed
in the migrated file after its values were written into the props, so
`tsc --noUnusedLocals` failed (TS6133).

The codemod now removes what the template read but the migrated file
doesn't: constants (at the top or inside functions), inlined components,
and imports (as TypeScript would drop them), plus helpers it added itself
and didn't use. Only declarations without side effects: a constant whose
value calls a function stays. Names the template never read stay too.

Across the benchmark, migrated files went from 292 unused names to 62;
the rest are unused in the originals as well, or inline Tailwind configs
built with a call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ten out

Three <Text> blocks from `items.map(...)` sat 56px apart after migration
where React Email shows 40px: each item kept both its margins (16px top,
40px bottom) as padding, so they added up instead of collapsing.

When a loop's items share their margins and the top one is no larger
than the bottom one (Text's 16px each), each item now keeps only its
bottom margin, which is what CSS shows between two items. The loop
carries both margins, so the block above it, the block below it, the run
spacer and the column it starts all collapse with its first and last
item. Other loops, and conditions, keep their margins as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…% padding

A container with `padding: '0 10%'` lost its side gutters with nothing
in the report, and em padding was always taken at 16px per em.

Padding and margins in em now use the element's own font size, as CSS
does. A %, calc() or viewport unit, which Elements can't hold in px, is
listed in the report ("style not converted: padding: 0 10%"), and so is
an em the converter had to take at 16px because the element sets no px
font size. Kept HTML keeps its own CSS, so it's not reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…written

The report named the line of a lost style after local components were
inlined, so a template with components above it got the wrong line (121
for a background image on line 79).

The line now comes from a line diff of the template and its inlined
source: a line the inlining left alone keeps its own number, and a style
inside an inlined component points to where the component is used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ender nothing

`import * as Email from "@react-email/components"` with `<Email.Text>`
failed with "Element type is invalid", and a template that returns null
for one prop value failed the check with "Cannot read properties of
null". Both render fine in React Email.

The codemod now reads a namespace import as named imports (`Email.Text`
becomes `Text`, with an alias when the file already uses the name),
unless the namespace is used some other way. The check compares a
template that renders nothing for some props with an empty original: it
passes when the original renders nothing too, and fails with the missing
words when it doesn't. A template that renders nothing with its own
PreviewProps gets a message that says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Migrated emails ran 5x the size of their originals, and 21 of the 106
benchmark templates went over Gmail's ~102KB clipping limit. A card
inset in a colored box became three columns ([24, 592, 24]), and every
column is tables of its own in email HTML.

Spacer columns on both sides of a row, as wide and showing one opaque
color, now become a border of that color on the columns inside them. On
phones the border is as wide as the spacers were there (their share of
the row), or none where the row stacks, so phones look as before.
Spacers stay where a border can't stand in: rounded corners or a border
of the column's own, an image or translucent color behind, one side only
(right-to-left emails show the columns the other way round). The color
behind can be the email's own background.

Benchmark: no change on desktop, phones within pixel noise, no word
moved; 18 templates over 102KB instead of 21.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… the email

Every column that only holds space carried an invisible zero-height
divider, so the editor wouldn't draw an empty column as a "No content
here" placeholder. In the email it changes nothing but costs about 1KB of
HTML each: 22% of the largest migrated email.

The migrated code no longer has them; the designs for the editor (the
CLI's design JSON, the runtime design, and the merge-tag comparison) get
one in each empty column instead, numbered after the design's own.

Benchmark: no change on desktop or phones; 13 templates over Gmail's
~102KB clipping limit instead of 18 (21 before the spacer border), the
median size 4.3x the original instead of 5x; no design has an empty
column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s out of the email"

This reverts commit b49ba7a. With the dividers only in the CLI's design
JSON, a migrated template opened in the editor through `renderToJson`
(the usual Elements way) showed its spacer columns as "No content here"
placeholders again. The size it saved isn't worth that; Elements adding
the divider itself would be the way to drop them from the email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ivoIturrieta and others added 29 commits October 12, 2026 21:06
… your own

The README said Menu item text shows entities as typed; in an email it keeps
them, as in 0.1.22. Template syntax with `<` or `>` (a Liquid `{% if a > b %}`)
in Heading or Button children is escaped like any text: it goes in the `text`
prop. And where Elements can render inside a render: anywhere React renders,
inside react-dom/server's own renderToString where a synchronous require can
load another of react-dom's modules (CommonJS, Jest, a module on Node 20.16+ or
22.3+), not in Next.js's edge runtime.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…gain

`renderToStaticMarkup(<Email>…</Email>)` with react-dom/server, where it has a
single server build (a browser bundle: an iframe preview, "copy HTML"; a fully
bundled server's SSR), rendered an empty body with a console error naming
functions the user never called, and a page's SSR output then failed to
hydrate. Body, rendered by that render, asked for another of react-dom's
modules, which a bundle can't load. Body calls no hooks after its render, so
it renders with the module it's in, as in 0.1.22; a component of the user's
own calling renderToJson there still gets the other module, or the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tion build

React's production build throws "Minified React error #426" where a
component suspends in a synchronous render, which Elements reported as one
that threw (a console.error) instead of one that suspends (a warning).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…1.22

`<Row><div><Column/><Column/></div></Row>` (or a component of the user's own
returning `<div><Column/></div>`) rendered the Columns with NaN widths
(`u-col-NaN`, `width="NaN"`) while the design had two cells: the collected
render kept the Columns inside the markup as the Row's. 0.1.22 rendered none,
and the Row says the markup isn't a Column. They're left out again, with the
Row's one warning; Columns in a Fragment are still the Row's.

A test also renders an email with a Context Provider around it in the app's own
react-dom/server render, with blocks whose web exporters render with
react-dom/server themselves (Divider, Html, Menu, Social): an earlier build
failed there ("Tried to pop a Context at the root of the app").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Leaving out Columns inside markup in a Row, as 0.1.22 did, added about 600
bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s' wrapper

React 19 warns where a list without keys is rendered (React 18 where it's
created): a template's unkeyed `items.map(…)` in a Column now renders in the
collecting render, and the warning said "Check the render method of
`Sentinel`", Elements' own wrapper. It's named after the component it stands
for: "Check the render method of `Column`".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…in 0.1.22

An <Email> (Page, Document, Body) inside a Column was left out of the HTML and
the design without a word, where 0.1.22 warned (and its renderToJson threw).
It's left out with a warning that says why, and isn't rendered for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e, it renders as 0.1.22 did

Called inside a render of the react-dom/server module it renders with, where
no other module of react-dom's renders with the app's React, Elements threw
("can't run inside a react-dom/server render"). That broke setups 0.1.22
renders in:
- a Next.js 15/16 Client Component that works out an email in `useMemo`:
  `next build` failed prerendering it (Next's own react-dom);
- a bundle with React in it (Vite `noExternal`, esbuild, webpack, rollup, a
  browser bundle) calling Elements in a component renderToString renders, and
  with React 19 Node never exited: the search loaded react-dom's browser
  build, which opens a MessageChannel;
- Vitest's vm pools, Jest 29's native ESM, and Jest's jsdom without the
  browser condition on React 18.
It renders with the module it's in there, as 0.1.22 did (the component
calling it then can't call a hook after the call, as with 0.1.22's
renderToHtml), and on React 19 the search skips the browser build.

The CommonJS build loads that module with `module`'s require (Jest's registry
too) instead of handing `require` around, which webpack warned on ("Critical
dependency: require function is used in a way…").

Tests: an esbuild browser bundle and a Node bundle, each with React in it,
render an email renderToString renders and Elements called in its components
(the Node one exits on its own); both Next.js integrations server-render a
Client Component that works an email out in useMemo (it fails `next build` at
the last commit); a component three renders deep renders, and one calling a
hook after the call is left out, named, while the page's hooks keep working.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Desktop compares the words a reader gets in order, but phones, wider screens
and dark mode only counted them, so words only those show, in another order
("Subtotal $5 Total $40" against "Subtotal $40 Total $5" in a phone-only
block), passed `compare` and a run. Reordered words there also left the style
comparison for that width silently undone. Each now checks the order where the
counts match, as desktop does; on the corpus nothing changes.

Found by the independent review (Codex and Claude, both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nders

Two of the last regression fixes went too far (found by an independent review):
- Columns inside markup in a Row were left out to avoid their `NaN` widths, by
  counting the markup's tags, which counted tags inside comments too: an
  Outlook conditional comment a component renders in a Row (`<!--[if mso]>
  <table><tr><td><![endif]-->`) dropped every Column after it, where 0.1.22
  kept them. The `NaN` came from the Row giving each Column its place among all
  its children, so markup or text before a Column pushed it past the cells.
  Each Column now takes its place among the Columns: they keep their widths
  beside markup or inside it, as the design already had them; the Row still
  says the markup isn't a Column. A reviewed snapshot that had `width="NaN"`
  (text straight in a Row) now has the column's width.
- A root placed in a Row or Column is left out with a warning, but the rule
  also left out a root among another root's rows (`<Email><Email>…</Email>
  </Email>`, a shared layout's root inside the template's), which 0.1.22
  rendered. It renders again; only one in a Row or Column is left out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ngs take single quotes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…u Liquid kept

From the final review of the PR:
- The module build wrote `import.meta.url` (for Node's createRequire), which
  webpack 4 can't parse: Create React App 4 and Storybook 6, with babel-loader
  over node_modules, failed to build where 0.1.22 built. The second react-dom
  module is now looked up from the app's folder (one with another React is
  passed over anyway), and a test checks both builds have no `import.meta`.
  Checked with webpack 4.47 and babel-loader: 0.1.22 builds, the last push
  fails, this builds.
- Text placed straight in a Row or Column escaped an email service's
  `<%first_name%>` tag, which the README says stays as written: it does now.
- A Liquid output tag in Menu text in an email (`{{ x | default: "Shop" }}`)
  keeps its double quotes, where 0.1.22 wrote them as given; one with `<`, `>`
  or `=` in it, which could add an attribute, is still escaped.
- The warning about text placed straight in a Row or Column is said once per
  process, not on every render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… email

A component that's undefined (a broken or circular import), or an object or a
Date as a child, fails the render where it's written straight among the rows
(as 0.1.22's renderToHtml threw there). Inside a component of the user's own
or a Provider (a layout around the rows), the boundary around it caught
React's error and left the whole layout out: renderToHtml returned an email
with an empty body, the render succeeded, and the only message blamed the
layout as having thrown. React's "Element type is invalid" and "Objects are not
valid as a React child" (its production errors 130 and 31) now fail the
render wherever they happen, the HTML, the design and the plain text alike.
Found by an independent review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…rote them

An id or class name in `values._meta` lost every character but letters, digits, "_" and "-" (`סיכום`, `section.2`,
`sm:hidden`), while the design kept them. Now only what could end the attribute is taken out: quotes, `<`, `>`, `&`,
a backtick, control characters and, in an id, spaces. The head's phone CSS, the one place an id is a selector,
escapes it as CSS.escape does (`#section\.2`), so its rules can't be ended or added to and still find the block.

Tests: ids and class names keep letters of any script, dots, colons and slashes (Email and Page); the head's
selector finds the block in jsdom for an id with `.`, `:` and `#`, and escapes a leading digit (`#\31 st`); the
hostile ids still add no rule to the head. Both fail without the fix. The escape matches Chromium's CSS.escape on
18 ids, and Chromium finds the block by each selector.

The bundle budget is 115KB (114,534 bytes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tings; a class template's message says why

`renderToHtml(<StrictMode><Welcome /></StrictMode>)` wasn't unwrapped, so the root's settings went unread (a web
document instead of the email's) and the warning called it `<wrapper>`, saying it doesn't return a root element.
StrictMode and Profiler are unwrapped as a Fragment is: they add nothing to a server render. A class template still
renders through React, as in 0.1.22, and the warning now says it's a class component.

Test: StrictMode and Profiler around a template render as the template does, with no warning; a class template
renders and names the reason. It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The README still said to write a Liquid filter's string in single quotes; since the Menu fix, a `{{ … }}` tag stays
as written, as in 0.1.22.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…` props are its own values again

Content blocks gained device props in 0.2.0, and custom tools inherited them: a tool with a value of its own
by one of those names (a contact card's `mobile` phone number) no longer type-checked, and a truthy `hideOnMobile`
value hid the block on phones, where 0.1.22 passed every prop to the tool's exporter. A tool's props are all its
own again, in the types and in Column; its device settings go in `values._override`, as the editor saves them.
Found by an independent check of the API against 0.1.22.

Tests: a tool's own `mobile`, `hideOnMobile` and `hideOnDesktop` reach its exporter and its design, and the block
isn't hidden (an `_override` still hides it); the type gate accepts those props on `ElementsToolProps`. Both fail
without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… in it is left out whole

`<Column><p>Hello <Name />, welcome back</p></Column>` came out in pieces: React's render is cut where a component
or block is, and each piece was read on its own. "Hello" was left out, ", welcome back" showed as text placed
straight in the Column (with that warning), and the name became an HTML block of its own. A link around a
component's image lost its link and its text; a Row showed the end of a `<div>`'s text. 0.1.22 left all of it out.

An element whose start and end tags are in different pieces is now read as one: it's left out whole, as 0.1.22
left it out, with its text and the markup a component renders in it; a block, Column or Row in it is kept, as one
beside it is (where 0.1.22 lost it too). The same holds in markup a component renders around blocks.
Found by an independent check of user components against 0.1.22.

Tests: three patterns that must match 0.1.22's HTML text and plain text (a name in a sentence and a link around a
component's image in a Column, markup with a component in a Row), and markup with a block in it keeping the block,
in the HTML, the plain text and the design. Six fail without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…written without keys

On React 19, `items.map((t) => <Paragraph>{t}</Paragraph>)` in a Column (or Rows or Columns made that way) logged
React's "Each child in a list should have a unique key" on every render, in development: React 19 warns about an
element of such a list wherever it's rendered, and the collecting render placed the template's own elements in its
lists. 0.1.22 never rendered them and said nothing; a static render has nothing to match keys for. The collecting
render now places copies, with the keys the list gives them, and a Row or Column that fails renders keyed children.
React 18 warns once, where the list is made, as with 0.1.22. Found by two independent checks against 0.1.22.

Test: the built package on React 18 and 19 renders blocks, components, markup, Rows and Columns from unkeyed lists
with no key warning (React 19 logged 16 without the fix).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nread

`renderToHtml(<Banner />)` for a component returning Rows (or nothing, or a list), and a Provider around a bare
Row, warned that the template's root's fonts, lang, dir and phone styles were ignored: there's no root, and the
output is 0.1.22's, which said nothing. The message is now given where a root's settings do go unread: a class
template, more than 20 wrappers, or a root inside other markup (which it now names). Unwrapping stops at a Row or
block instead of calling it. Found by two independent checks against 0.1.22.

Test: Rows, nothing, a list and a Provider around a Row render with no warning; a root inside markup is named. It
fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fixes from the last independent checks against 0.1.22 (markup cut by a component left out whole, the key
warning, custom tools' own props, the unwrap message) add about 2.7KB to the unminified bundle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Menu text in an email is written into the link and into a double-quoted `aria-label`. Keeping a Liquid tag's
double quotes raw (for `{{ name | default: "Shop" }}`) let it end that attribute: `{{ " }} onmouseover=alert(1) x`
added a handler, and `{{ x" hidden }}` an attribute, with or without `=`. Any raw double quote there can, so they're
all escaped again, as the security fix in this release says; entities and single quotes stay as 0.1.22 wrote them.
A template's string goes in single quotes, which Liquid, Handlebars and Jinja read alike (backticks in Go templates);
the README says so. Found by an independent adversarial review.

Tests: the hostile inputs include a template tag's quote with and without `=` and after a `<%name%>` tag, in every
value and mode; Menu links in an email have only their own attributes. Both fail without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`htmlClassNames: "hero\ndark"` in `values._meta` became one class, `herodark`: the rule that keeps a class name in
its attribute took out control characters, line breaks and tabs among them. They're spaces now, as the browser reads
them and 0.1.22 wrote them. Found by an independent adversarial review.

Test: class names apart by a line break, a tab and CRLF are each a class. It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…er changes the render

To say where a warning came from (an undefined component, a list without keys), React 18's development build calls
the component once more with hooks off. When Elements called a component on its own (a template to find its root, a
failed component to say why), it took that call's failure for the component's: a layout with a hook around an
undefined component gave a blank email blamed on the layout (78d8f20's error held only without the hook), and a
template with a hook and a list without keys rendered as a web document, with a warning, the first time only. Only
React's own call counts now. Found by an independent adversarial review.

Tests, on React 18's development build: a layout with a hook around an undefined component fails all three
renders; a template with a hook and a list without keys renders the email document, the same every time. Both fail
without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…eact is

The module build looked for it from the folder Node was started in. A React 18 app that's a module, started from a
folder above it whose React is 19, loaded React 19's builds there: rejected, but the browser one's MessageChannel
kept Node running after the email rendered (0.1.22 exited). Started from a folder with no React (`cd / && node
app.mjs`, systemd's default), none was found, so a design worked out in `useMemo` before a hook failed, where 0.1.22's
tree walk worked. It's now looked for from the app's script too, and only where `react` is the React Elements
renders with: another React's react-dom isn't loaded at all. Found by an independent check of runtime environments.

Test: the built package in a React 18 module app inside a folder whose React is 19, started from that folder and
from another: it exits, and a page that works out the design in `useMemo` and then calls a hook renders. Without
the fix it keeps running (and the hook fails).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…st render, as in React

7a1cc1a kept only the first call of a template Elements calls on its own, to pass over the one React's development
build makes to describe a warning. A template that sets its state while it renders is rendered again by React,
and that render counts: `renderToHtml` gave `n=0` where React and 0.1.22 give `n=1`, and a root picked from that
state rendered as a web page. Only a call made from inside the one under way is passed over now (React's
description). Found by an independent adversarial review.

Test: a template that sets its state while it renders shows its last render, with the root that render picks, in
the HTML and the design. It fails without the fix; 7a1cc1a's two tests still pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…design again

0b56c6a stopped unwrapping at a Row or block, which it told by name: a template component whose `displayName` is
"Row" or "Column" wasn't called, and `renderToJson` threw "Root element must be …" where 0.1.22 gave its design.
Unwrapping now stops only at Elements' own components (marked as such), as the "couldn't unwrap" check does. The
HTML is as before (0.1.22's too). Found by an independent adversarial review.

Test: `renderToJson` gives the design of a template whose displayName is "Row". It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… cuts

897f6cf leaves markup in a Row or Column out whole when a component or block cuts it, and took any end tag that
matched nothing open for the end of that markup: `<div><Name /><span dangerouslySetInnerHTML={{ __html:
"</b>Hidden" }} /></div>` let "Hidden" out as text placed straight in the Column, where 0.1.22 left it out. Markup
open across pieces is now told by name, and an end tag that ends nothing is passed over, as the browser does.
Found by an independent adversarial review.

Test: a stray `</b>` and `</p>` in a component's HTML inside cut markup lets no text out. It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ts is, too

76dae47 looked for it from the app's script (`process.argv[1]`) and the folder Node started in. An app run as its
folder (`node /srv/app`), through a link to its script (a global bin), imported by a script elsewhere, or from
`node -e` has neither, so a design worked out in `useMemo` before a hook still failed there, where 0.1.22's tree walk
worked. The module build now looks first from its own URL, which a stack names (the syntax for it is what webpack 4
can't parse), still only where `react` is the React it renders with. Found by an independent adversarial review.

Test: the built package in a React 18 module app, run as its folder, through a link elsewhere, imported by a script
elsewhere and from `node -e`, all started from another folder: each renders a page that works out the design in
`useMemo` and then calls a hook, and exits. The folder fails without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ivoIturrieta
ivoIturrieta merged commit 3ac5f73 into main Oct 13, 2026
4 checks passed
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.

2 participants