Skip to content

@unlayer/migrate 0.1.2: React Email's current demo, and a regression run on real projects - #67

Merged
ivoIturrieta merged 19 commits into
mainfrom
fix/migrate-0.1.2
Oct 13, 2026
Merged

ivoIturrieta merged 19 commits into
mainfrom
fix/migrate-0.1.2

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

@unlayer/migrate 0.1.2: React Email's current demo, and a regression run on real projects

We ran the published 0.1.1 on React Email's current demo templates, 62 of them from the react-email repository, kept as a real project: shared files, a Tailwind config, and URLs built from baseUrl. 61 kept the same content, and all loaded in the Unlayer editor. Our corpus has these templates flattened by hand into single files, which is why it missed the two bugs the demo found. This release fixes them, the two content failures left from the 0.1.1 field test, and what a new-user test found. Elements isn't changed.

Fixes

  • A background image built from a value (url(${baseUrl}/…)) is written as the template's own code. Before, it was dropped, and the content check failed the template (React Email's Arcane promo lost its hero image).
  • A shared tailwind.config.ts that templates attach (X.tailwindConfig = tailwindConfig) is skipped as a non-template. Before, it was "left as it is", and a clean --write or dry run exited 1. Any file whose default export isn't a component is now handled the same way.
  • Text from a variable given JSX in one branch (let message: string | ReactNode), and calls to a function of the template's own file that returns JSX, count as possibly holding elements. Their markup is kept. Before, it was flattened to text, so sentences ran together and links and bold were lost: Dub's partner-payout-processed, inbox-zero's meeting-briefing and meeting-recap.
  • Editability in the report comes from the design the editor gets, so the report and the design agree.
  • Tailwind classes the migration can't keep are named as the template writes them (md:text-base, not md_text-base).

CLI

  • --version works, also on a Node.js too old to run the CLI.
  • A usage mistake prints the error and one line pointing to --help, not the whole help.
  • "Output already exists" says who wrote the file: an earlier run, an earlier version, the editor, or not @unlayer/migrate.
  • Words are quoted as the template writes them, without invisible characters.
  • A dry run with nothing to write says so.
  • The "still importing React Email" note after --write appears only when every template was written.

Results: 0.1.1 → 0.1.2

0.1.1 0.1.2
React Email's current demo (62 templates) 61 same content; --write exits 1 62 same content; exits 0
Dub content failures 2 1
inbox-zero content failures 2 0
Second --write in a project with shared files exit 1 exit 0
Designs loaded and exported in the Unlayer editor demo 62/62, field 242/243 demo 62/62, field 242/243

Nothing in any project is worse than in 0.1.1: no text, link or image lost, no new type errors, and no new React Email imports.

The one editor exception is an editor bug, not a migration one. An HTML block with an <svg class="…"> logs r.trim is not a function, because an SVG's class isn't a string. That design still loads and exports with all its words.

Not changed

  • Spacer columns: turning them into padding would change how emails look. In Elements, Row padding sits outside the content and Column padding inside the background.
  • npm deprecation warnings for @react-email/components: moving to React Email 6's react-email package would pull its whole CLI (esbuild, socket.io, tailwindcss, babel) into every npx run.

Testing

  • Unit tests: convert-core 212, from-react-email 343, migrate 211. Each fix has a test that fails without it.
  • Local CI: all 18 steps.
  • Corpus bench: 106 templates, 0 content failures, 212/212 unchanged.
  • Field test on the packed package: 8 open-source projects, the React Email starter, a Next.js app (next build on Elements 0.2.0 and 0.2.1), and new templates in ten setups.
  • A new regression harness, run against a baseline of the published 0.1.1:
    • React Email's current demo, run as a project through the CLI, with type-check and render comparison;
    • every design loaded into the Unlayer editor and saved with no lost values;
    • each design exported with the same words, and console errors recorded.

🤖 Generated with Claude Code


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

ivoIturrieta and others added 19 commits October 13, 2026 20:19
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`<Section style={{ backgroundImage: \`url(${baseUrl}/static/hero.png)\`,
backgroundSize: "cover" }}>`, with `baseUrl` from `process.env`, lost its
image (the URL can't be read when migrating), and the check failed the
template as a lost image; the same URL written out converted fine. React
Email's own Arcane promo demo does this.

A box's `backgroundImage` that is a template literal of one `url(…)` now
stands in as a placeholder URL while the box is converted, as computed
background colors do (e4d21d9), and is written as the template's code
for the URL where it lands: a Row's or Column's
`backgroundImage={{ url: \`${baseUrl}/static/hero.png\`, size: "cover", … }}`,
with its size, repeat and position as written. Where it can't be written
(the box flattened, the URL inside HTML text), the template is converted
again with it lost, as before. A Body's image isn't converted either way.

Test: a hero Section with a computed background image URL migrates with
the URL's code, no lost style, type-checks under noUnusedLocals, passes the
check with no lost image, and its design's background image is the URL
evaluated, with VERCEL_URL unset and set; before, the image was lost. A
test that used a computed background image as its example of a dropped
style now uses an outline color. Corpus output is unchanged.

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

React Email's demo keeps tailwind.config.ts beside its templates, and
three of them attach it (`X.tailwindConfig = tailwindConfig`). The config
imports React Email's types, so the run took it for a template; as templates
import it, it was left "as it is" for them, and since a migrated template
still attaches it, the run ended with "Templates left as they are weren't
checked … Migrate them with --out <dir>", exit 1, with --write and in a dry
run, though every template migrated.

A file the templates import whose default export isn't a component (a
config object, plain data, a helper) is now skipped as what it is, as it
would be on its own ("its default export isn't a component"), never a
template left as it is for the files that import it.

Test: two templates that import and attach a shared Tailwind config: a dry
run and --write exit 0, list the config as skipped, and leave it as it was.
It fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`npx @unlayer/migrate --version` was an unknown option: there was no way to
tell which version a project's npx cache runs, short of reading its
package.json. --version (and -v) now prints it, and the help lists it.

Test: --version and -v print the package's version and nothing else, exit
0; it fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
An unknown option, a value left out, no templates given with other
options, or compare without both files printed the mistake and then the
whole help, about 45 lines: on most screens the mistake scrolled away
before it could be read. It now prints the mistake and one line saying
where the options are (`npx @unlayer/migrate --help`); compare's says how
it's called. Run with nothing at all, it still shows the help, as that's
what a first run is after.

Test: an unknown option, --write alone, --report without a value and
compare with one file each print their mistake and the --help line only,
exit 1; a run with nothing shows the help. It fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A second run into the same --out folder stopped with "Output already
exists: migrated/welcome.tsx. This run didn't produce it", though the run
before it had. The message now says who wrote the file, as far as can be
told: "written by an earlier run" (its header), "written by an earlier run,
and edited since" (its hash), "a design, written by an earlier run or the
editor" (a design JSON says neither), or "not written by @unlayer/migrate",
then what to do as before.

Test: a second run into an --out folder names the template an earlier run
wrote, then the same edited, then its design JSON; a file of someone's own
there is "not written by @unlayer/migrate". It fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The check reads each word as a reader does, so a hyphen and a minus sign
are the same, and a file name or an address is its letters and digits.
The run and compare then quoted those words as the check reads them:
"A−10293" with a minus sign where the template has "A-10293",
"sarahexamplecom" for "[email protected]", "proofofworkwebp" for
"proof-of-work.webp", which a reader can't find in their template.

Each word is now quoted as the source writes it, without the quotes,
brackets or sentence punctuation around it (an abbreviation keeps its last
period): a lost word as the original has it, an added one as the migrated
template has it. A word only a flipped prop's render shows stays as the
check reads it.

Test: compare on a template that loses "A-10293" and "([email protected])"
and adds "“B‑7”," and "U.S." quotes them as "A-10293",
"[email protected]", "B‑7" and "U.S.", with no minus sign; it fails without
the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A dry run on a folder whose templates are all migrated (beside a shared
component file that still imports React Email) ended "No email templates
left to migrate … Nothing written: pass --write or --out <dir> to write
the migrated templates", though --write would write nothing. A dry run now
says how to write only when a template would be written (one migrated, or
one that failed the check, which is written unless --strict); otherwise it
says "Nothing to write."

Test: after --write, a dry run on the same folder says "Nothing to write."
and nothing about --write; it fails without the change.

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

The report named a class it couldn't inline as `md_text-base`, not
`md:text-base`: React Email's <Tailwind> renames a class it can't inline
(its sanitizeClassName: `:` and other characters to `_`, `!` to
`imprtnt`, a leading digit spelled out), and the converter reported the
renamed leftover. The same name went into the lost-style message for a
class that hides (`dark_hidden`).

resolveTailwind now maps each leftover back to the class the template
wrote (matching the template's classes through the same renaming), and
the report and the lost-style message use it. The renamed name is still
what the phone rules are read by.

Tests: `md:text-base`, `lg:!text-[18px]` and `hover:underline` left
uninlined are reported by those names; the phone test's `hover:` note is
`hover:text-red-500`, not `hover_text-red-500`. The CLI's two
hidden-class regressions now expect `dark:hidden` and `sm:hidden` in the
message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
One project's payout template ran words together ("status.If",
"fee.Note") and lost a link: `let statusMessage: string | React.ReactNode
= ""` is given a fragment with line breaks and links in one branch, and
`<Text>{statusMessage}</Text>` was written as a Paragraph's children,
which Elements reads as plain text. The value's type couldn't be read (an
unresolved React type is `any`) and its declaration holds text, so
nothing said it can be JSX.

A variable given JSX after it's declared (`message = <>…</>`) now counts as
a value that may hold elements: its block is written both ways
(`holdsElements(message) ? <KeptHtml>…</KeptHtml> : <Paragraph/>`), and
a value shown loose that way is kept as it renders.

Test: a template whose message is a fragment with <br />s and a link in
one branch and text in the other passes the check with both values;
before, "status" and "If" were lost as "statusIf" and the link was lost.
The Dub template now passes. Corpus output is unchanged.

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

Two inbox-zero templates ran words together ("2019−", "50+Sarah",
"dealAlso", "JuneAction"): `{renderGuestBriefings(guests)}` and
`{renderBulletSection("Decisions", items)}`, calls to functions of the
template's file that return JSX (a list of Texts), were written as a
Paragraph's children, which Elements reads as plain text, so each Text's
line was lost. Nothing said the call gives elements: its return type
can't be read without React's types.

A call to a function of the file's own that returns JSX now counts as
giving elements: shown loose, it's kept as it renders (an Html block);
in text, it keeps its block.

Test: a recap whose sections come from a helper returning a title and a
list of Texts passes the check with lists and with an empty one; before,
"Ship in June" and "Hire two engineers" were lost as "JuneHire". Both
inbox-zero templates now pass. Corpus output is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Quoting words as the template writes them carried along what a template's
text has but no one sees: a code block's tokens begin with a zero-width
joiner and space, so the run quoted `"‍​client"` and `"‍​DevTool({"`, with
the invisible U+200D and U+200B inside the quotes. Format characters
(Unicode Cf: zero-width spaces and joiners, word joiner, byte order mark,
soft hyphen, direction marks) are now left out of a quoted word. A bracket
the word opens itself is kept at its end ("connect()"), where the ones
closing an aside around it still go.

Test: compare on a template that loses text with zero-width joiners and
spaces, a word joiner, a byte order mark and a soft hyphen in it quotes
"client.connect()", "on", "startup", with no format character; it fails
without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A run into an --out folder that 0.1.0 wrote, without --overwrite, said its
template there was "written by an earlier run", the same as one this
version wrote, though 0.1.0 didn't record a hash, so whether it's been
edited since can't be told. It now says "written by an earlier version of
@unlayer/migrate, which didn't record whether it's edited since", as
--overwrite's refusal does.

Test: a template written into an --out folder, then without its hash line,
is named as an earlier version's when a run won't replace it; it fails
without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`unlayer-migrate nope-dir` said "Not found: nope-dir" and stopped, where
every other usage mistake says where the options are. A template or folder
that isn't there, for a run or for compare, now gets the same line: "Run
npx @unlayer/migrate --help to see the options."

Test: a folder that isn't there, and compare with an original that isn't,
print the mistake and the --help line only; it fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On Node.js 18, `unlayer-migrate --version` said it needs Node.js 20.6
instead of which version it is: the Node.js check runs first, for
everything. A command that is only --version (or -v) is now answered
before the check, from the package's package.json, read with node:fs only,
which any Node.js loads; anything else still stops at the check first.

Test: the built CLI prints its version and nothing else for --version and
-v on a Node.js reporting 18.20.4 and 20.6.0, and on an installed Node.js
18; a run with templates and --version on 18 still stops at the check. It
fails without the change.

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

After --write, the run lists the files it left that still import React
Email (a shared component the templates had inlined), to delete before
React Email is removed. It did so after --write --strict left a template
unwritten too, though that template still imports the footer it named:
deleting it would break the template, and React Email can't go anyway. The
note now shows only when every template the run found was written (or
already migrated).

Test: with a footer two templates import, --write --strict writes the one
that passes, leaves the one whose content differs, and names no files to
delete; it fails without the change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The report's "editable" disagreed with the design JSON for a block written
both ways (`holdsElements(statusMessage) ? <KeptHtml> : <Paragraph>`):
Dub's partner-payout-force-withdrawal reported 87.5% while its design had
two Html blocks of eight (75%). The count came from the converter's
static guess (18a6688), which only knew a value is elements when the
preview props give it as JSX; here it's a local variable that's a
fragment on every path.

verifyConversion now returns `editable`: the share of the design's
content blocks that aren't Html blocks (spacer dividers not counted), so
each block written both ways counts by the branch the preview render
takes, whatever its value is. The CLI's report uses it.

Test (CLI): a template whose message variable is a fragment on both
branches, beside a text, writes a design of [text, html] and reports 50%
editable; before, 100%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ivoIturrieta
ivoIturrieta marked this pull request as ready for review October 13, 2026 19:50
@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.55%. Comparing base (679abd1) to head (b0e602a).

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #67   +/-   ##
=======================================
  Coverage   96.55%   96.55%           
=======================================
  Files          40       40           
  Lines        2789     2789           
  Branches      795      795           
=======================================
  Hits         2693     2693           
  Misses         95       95           
  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
ivoIturrieta merged commit eb0a80d 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.

1 participant