Repository navigation
React Email → Elements migration (@unlayer/migrate) - #61
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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 (-, −, −), 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]>
… 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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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/forwardRefand 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).fonts,textDirectionandlang.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, andtextDirectioncould 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 ownreact-dom/serverrender (a preview page that works out its design inuseMemo), 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:
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.previewText, and text placed straight in a Row or Column escape markup and entities, as React shows text.textprop, Paragraph'shtmlor an<Html>block.<or>, such as{% if a > b %}. ‌.aria-labeltoo), 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.hideOnDesktop/hideOnMobile), so menu separators hide on phones.renderToJsonkeeps 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._config,bodyValues,cells…). Markup they return (a wrapper, a link) renders where 0.1.22 left it out.<div>around them) now renders.values._meta.htmlIDis 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). Onevaluesobject 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.<br>in a heading, link or list item, and decodes entities in headings.renderToJsonleaves markup in a Column out, as the HTML does (0.1.22 threw).renderToHtmlreads 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.textDirectionother thanltr/rtl/autois left out.langandfontsare 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.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:
--writeor--out.--writereplaces only files git can restore (--allow-dirtyoverrides that), and--outwrites copies.--overwritereplaces only what an earlier run wrote or what git can restore, never a design made in the editor.--designwrites design JSON forloadDesign(), with text props as merge tags.--reportwrites Markdown or JSON.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 andalttext 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:
0when every template checked out,1for usage errors or when nothing was checked,2for a content difference or a failure.Results on 106 templates (React Email's demos, community and agent-written templates):
noUnusedLocals/noUnusedParametersmigrates to one that does.Details are in
packages/from-react-email/FIDELITY.md.Known limits
react-dom/serverrenderToStringwhere 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 inuseMemoor before another hook then fails, as 0.1.22'srenderToHtmldid there, andrenderToJsonnow does too (0.1.22's walked the tree without rendering); calling them in the component's body works.renderToHtmlfails there, as in 0.1.22, andrenderToJson(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.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.)createRoot, a Context Provider around<Email>doesn't reach the components inside it.javascript:URLs render as given;linkStylenever applies;@react-email/components(deprecated on npm) is still a dependency; switching to@react-email/renderis 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:
Before release, the Elements build was compared with 0.1.22 four ways, and everything found is fixed or listed above:
Releasing
The release workflow publishes each package only when its code changed since its last tag (Elements
v*, migratemigrate-v*), each behind the existing approval. Elements is set to 0.2.0, and migrate depends on^0.2.0.Before merging:
After merging:
@unlayer/migrate0.1.0 by hand frommain:pnpm install --frozen-lockfile && pnpm buildat the root;pnpm packinpackages/migrate, and check the tarball hasdist/bin.js;migrate-v0.1.0.@unlayer/migrate: repositoryunlayer/elements, workflowpublish.yml, environmentnpm-publish.npx @unlayer/migrateon a sample template in an empty folder.@unlayer/react-elements-previousinpackages/react/package.jsonto 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/