Repository navigation
@unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes - #69
Open
ivoIturrieta wants to merge 125 commits into
Open
ivoIturrieta wants to merge 125 commits into
ivoIturrieta wants to merge 125 commits into
Conversation
…nd codegen New entries, each its own import so the main bundle stays within budget: - `/tailwind`: <Tailwind> turns classes into each component's props at render time, so the email gets inline styles and the design stays editable; classes no prop takes become inline styles (HTML only, warned); max-sm: → phone settings, hover: → button hover colors. Container text classes reach the text inside, Row padding classes inset content and card grids get gutters built from borders in the surrounding color. - `/markdown` (Markdown → blocks), `/code` (CodeBlock, CodeInline, highlight.js themes), `/font` (<Font> with fallbacks, URL kept in the design), `/head` (<Head>), `/async` (async templates and components, Suspense, `pretty`), `/codegen` (designToElements: an Unlayer design → readable Elements code; exact round trip on 48 of 49 real designs and fuzzed ones). - Main entry: Link, style/className/skipInText on every component, plain-text options and toPlainText, prop aliases (Menu color, align/textAlign, Heading as/level) with "did you mean" warnings, `values` as ValuesInput (partial, strict keys), rich inline children. Editor parity fixes (HTML and design now agree): UnlayerProvider config applies (render options > root config > Provider > defaults); Paragraph text is stored as typed; exact px image widths; bare zeros written as 0px; Row background-image fullWidth default; the body link color selector; url() values percent-encoded; right-to-left dir/lang on body; a Row whose Column count doesn't match its layout is left out alone, with an error; render errors keep their cause. Row padding stays the editor's outer padding, with a warning when it sits outside a content background. The main bundle budget rises from 118 KB to 126 KB (125,962 bytes now). Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Guides for Tailwind, Markdown, code blocks, fonts, async templates and codegen; README sections for Link, Head, aliases, the Provider precedence and Row padding; CHANGELOG Unreleased entries. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
CI's main-entry budget goes to 126,000 bytes. The Next.js 14/15/16 integration apps also render a template inside an UnlayerProvider (route handler, Server Component, Client Component and the Pages edge route) and verify the right-to-left HTML and design. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ding A container with margins around a dark header and footer must stay row-level padding in the design, the outer container in the HTML and `padding` in the TSX, so the gap shows the page background. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A round trip through the real editor (load, save, export) showed where renderToHtml and the editor's export of renderToJson's design differ: - A Paragraph rendered through the Lexical paragraph exporter (a unitless line height and a <p> wrapper) while its design holds a text block, which the editor exports with a percent line height and no wrapper, so lines with text of another size moved. Both now use the text block, and formatted textJson stays HTML in the design. - A root's `fonts` never reached the design. Families a font URL names now get the URL on their fontFamily, as <Font> does; anything else is warned about once. - The document shell lacked the editor's base stylesheet for each mode. - Images and video thumbnails were sized against the column, not the slot the default 10px block padding leaves, so they ran 20px wide. - A pinned image's percent was rounded up in the HTML and to the nearest in the design. - The design now holds the body's displayMode, which the editor keeps and codegen reads to pick the root. A per-block test checks each block's HTML equals its design's exporter output in all three modes. The bundle budget grows by about 3.9KB. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…check The converted template's fonts are already listed in the report, so the new warning for a root font the design can't hold isn't a lost block. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…Mode Also say that the editor links its own stylesheet for the Google fonts it already lists, with weights 400 and 700. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ender With async templates under way at once, <Head> and <Font> recorded into the top of the shared collection stack, which can be another template's: one request's head tags and fonts could land in another's email. They now find their template's collection by the render's ids, as Rows and blocks do. Font's pure helpers move to utils/font-design so codegen still imports nothing that renders. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A component's lone <a> became a Paragraph holding only what text can hold, so a linked image was lost, and an <hr> with a style became a default Divider. Each becomes a block only when the block keeps all of it; anything else stays an HTML block, as in 0.2. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
String.fromCodePoint threw on a reference past U+10FFFF, so untrusted Markdown could make <Markdown> throw and its Column be left out. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #69 +/- ##
==========================================
+ Coverage 96.55% 97.83% +1.28%
==========================================
Files 40 122 +82
Lines 2789 8466 +5677
Branches 795 2999 +2204
==========================================
+ Hits 2693 8283 +5590
- Misses 95 183 +88
+ Partials 1 0 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
… hold it A bare <a> in a Column became a Paragraph only when its markup was exactly what text holds, so a link with another attribute (aria-label) was left out, call to action and all. A link whose tags text holds is a Paragraph again (other attributes aside); one holding more (an image), or a rule with a style, is an HTML block as it is, instead of a block that loses part of it. A component's markup still becomes a block only when nothing is lost, and is otherwise its own HTML block. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta
marked this pull request as ready for review
October 15, 2026 16:58
…o it The release job bumps a patch unless a newer version is committed, so this would have published 0.2.2 and given every ^0.2 project the changed HTML. And it refuses an Elements outside the published @unlayer/migrate's range (^0.2.0) unless the range is raised in the same change. Committing 0.3.0 and raising migrate's range to ^0.3.0 lets Elements publish as 0.3.0 and migrate republish after it. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
0.2.1 only exported the main entry, so Tailwind, Markdown, Code, Font, Head and Async are new entries in 0.3, with <Link>, style/className/ skipInText, the plain-text options and inline JSX children. The changelog listed few of them, and Tailwind's details as fixes. Also adds the RTL alignment, Column markup and async error changes, and drops mentions of CLI commands that aren't released. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The changelog went from Unreleased straight to 0.1.0. Each release since has its entry now, from its release notes and pull requests. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
renderToHtml rethrew "[Unlayer] renderToHtml failed: …" without the error it caught, so tools lost the template's stack and line numbers. The rethrown error now carries it as `cause`. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…om's In Next.js App Router (route handlers and Server Components run on two React copies) and anywhere else the copies differ, the async renderers never detected it, so Rows rendered as plain HTML: a web layout with 3 tables instead of 14 and a design with no rows, with only a warning. They now probe as the sync path does and record without useId, kept apart per render by AsyncLocalStorage (one at a time where there's none). A render whose Rows still weren't collected now throws instead of returning a broken email, sync and async alike; empty, text-only and head-only templates don't. The bundle budget grows by 1 KB for it. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
An async route handler and an async Server Component in the Next 15 and 16 apps, checked to give the synchronous renderers' tables and rows. Both failed before the fix (3 tables, no rows). Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A part of a template that fails (a component that throws, a Row whose Columns don't fit its layout, an exporter that fails) will be a RenderError naming the component and where it is, with the original error as its cause. `onError` on UnlayerConfig (so in render options, a root's `config` and an UnlayerProvider) says what a render does with it: "throw" (the default), "omit", or a function that's given it. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A renderer of React element trees that writes what react-dom's static server render writes, without react-dom: function components called with Elements' own hooks dispatcher (put in React's slot around each call and taken out after, never across an await), class components, Context, Fragments, memo, forwardRef, lazy and Suspense, and host markup with React's attributes, escaping and useId()s (Fizz's tree ids, in the installed React's format). The async walk leaves a hole where a component suspends and walks on, so siblings' promises start at once; the output, the records and the error a render fails with are read in tree order whatever settles first. A collecting walk asks a collector about each element, so the template's Rows, Columns and blocks can be recorded instead of called. A table of the React versions it's tested with (18.3 to 19.3) and a once-per-process check of the installed React's shapes: an unknown version that passes renders (with a warning in development), one that fails throws a named error unless UNLAYER_SKIP_REACT_CHECK=1. A hook it doesn't know fails with a named error. Development builds get React's key warnings for lists. Nothing uses it yet. Tests compare it with react-dom/server on hand-written and random trees. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Elements' own renders no longer use react-dom: the walker collects a
template (Rows, Columns, blocks, <Tailwind> scopes and <Head>/<Font>
entries recorded by their markers, of any copy of Elements) and writes
Elements' markup. The downstream is today's: marks parsed into the
collected tree, which the HTML, the head and the design are made from.
Gone with react-dom: the collecting react-dom render and the Sentinel
that told it apart by useId(); blind mode, its probe and the async
entry's AsyncLocalStorage runner (src/async/running.ts); the streaming
render (src/async/stream.ts); the second react-dom module hunting and
the per-render build stack (src/utils/server-renderer.ts); calling a
component again to say why it failed; the template wrappers' throwaway
renders; the shared collecting and async-collection stacks; the check
for Rows rendered as HTML, which can't happen now. Template wrappers
(class components too, as react-intl's IntlProvider) are called once,
inside the providers around them; components run once per render.
A part that fails now fails the render, the synchronous and async ones
alike, renderToJson and renderToPlainText too: a RenderError naming
the component and where it is ("in <Hero> at Row 3 › Column 1 ›
Hero"), its cause the original error. With `onError: "omit"` it's left
out of the HTML, the plain text and the design alike, said once with
console.error and listed in renderToHtmlParts' `omitted`; a function
gets it instead. renderToJson renders the body's HTML too, so an
exporter that fails can't be in the design and not in the HTML, and
takes an `onError` option. Tests that expected parts to be left out
silently now expect the throw, and the omission with `onError`.
A component's useId() is React's format with nothing of Elements' in
it (`:R1:`, `«R1»`, `_R_1_`), the content's ids following the template
wrappers'. Async components render on React 18 too.
The bundle grows (about 41 KB unminified): the renderer react-dom was.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The async walk: siblings start at once and come out in tree order, byte-identical over random delays (ids too), two renders under way at once stay apart, the first error in tree order wins without waiting for what comes after it, and React's dispatcher is never the walker's across an await or after a render, nested ones too. The error policy: every renderer throws the same RenderError by default; with onError "omit" in the options, a root's config or an UnlayerProvider, the HTML, text and design leave out the same parts (ids still agreeing), each said once and listed; a function gets them instead. React's react-server build: its hooks work, useState is named. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Tests that assumed React 18 now follow the installed React: useId's format, a memo'd function's defaultProps (React 19 doesn't fill them), the React installs the Node scripts run with, React 19's image preloads (react-dom adds them; the walker doesn't). Development-only checks (key warnings, act()) are skipped in React's production build, and a React newer than the version table renders if it passes the self-check. Run locally on 18.3.1, 19.1, 19.2 and 19.3.1, development and production: all pass. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A matrix job runs the react package's suite on React 18.3.1, 19.1, 19.2 and the latest 19, in React's development build (the full suite) and its production build (the engine's, the renderers' and the regression tests). A weekly workflow runs the engine's tests and a longer fuzz (5,000 random trees against react-dom/server, 3,000 random templates against the last release) on React's canary, reporting without blocking. The bundle budget is 173.5KB, for the walker. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ARCHITECTURE.md maps the pipeline (unwrap, walk, model, design, exporters, HTML and text), the engine's files, the hooks dispatcher, the React version table and its self-check (and the escape hatch), the async walk, ids, the error policy, what isn't supported (error boundaries, portals, React 19's hoisting) and the invariants with the tests that hold them. The README gets an Errors section and loses the react-dom workarounds; the async and visual-editing guides, the repository's CLAUDE.md and the changelog follow. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ARCHITECTURE.md's invariant 4 said a render's output depends only on its arguments, but a render a component starts while the walker calls it begins at that component's place in the tree, inside the providers around it, by design: its ids continue the document's, and a context it has no provider for reads the outer render's value, as React's renders inside a render do. The invariant now says so, and where a render starts otherwise (a template wrapper, an exporter, a head, onError: at the top). A test pins both, which nothing did: a caller cleared for every render broke no test. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fixes before this one add about 0.9KB to the main entry (194,331 bytes, over the 193.5KB budget): a <Suspense> around the template is unwrapped, a root the walk of a template with no root meets is said, and which Row or Column failed and what an async render compiled for its <Tailwind>s are kept in the render's scope. CLAUDE.md, CONTRIBUTING.md and the README give the new budget and size. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…s too The async renderers took a template whose wrapper left its root inside markup without a word of their own: only the walk said it, in other words than the synchronous renderers'. They now say what the synchronous ones say, naming the template and the render function, once a render, and the walk says no more. (The main entry is 194,491 bytes, inside the 194.5KB budget.) Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fixes since the 193.5KB budget add about 1KB to the main entry (194,491 bytes), not 0.9KB: the async renderers now say why a root's settings go unread too. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Around a template's root, a <Suspense> is outside the document and its fallback never renders; around a template with no root it's what the template renders, as inside a root, so the synchronous renderers render its fallback where what's in it suspends. The async guide and the changelog say which, and a test holds both. Two comments are worded more plainly. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A `class=` inside another attribute's value made the inliner look up a class attribute the tag doesn't have and throw, failing the render (or leaving the block out with onError: "omit"). The inliner now reads tags with the Markdown sanitizer's tokenizer, moved to its own module, and writes again only a tag's first class attribute and the style it gives: comments, raw text, what isn't a tag and every other attribute stay as written. A seeded fuzz test holds it to that, and to inlining again changing nothing. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A rule was read as the first class's in its selector, so a class under a variant whose selector starts with another class (`[.group:hover_&]:x`, a config's `.group:hover &`) was "a class Tailwind doesn't have", and stayed in markup's class attribute, while `group` itself took that rule's reason and was left out. Tailwind writes each class's rules inside a rule of its own, so the class is that rule's, wherever it is in the selector; a class under a variant's selector says it applies only there (`.group:hover &`). Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…variants In markup, `<div class="hidden md:block">` became `display: none`, with `md:block` left out (it needs a stylesheet), so the text was hidden on every screen. A class that sets on every screen what a phone or breakpoint variant sets on one isn't inlined either now, and says why. A class a `hover:` variant changes still is: without hover, the element is as it says. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…dom does `<select defaultValue="b">` lost its choice: the walker left the select's value out but never marked option B `selected`. It now carries react-dom's format context, so a select's value or default value (an array with `multiple`) marks the options it holds, through optgroups, Fragments and components, an option without a value read by its text, as react-dom reads it for the installed React. The other elements react-dom writes its own way match too: a textarea's value with children and innerHTML throw, a pre's newline only for a string, React 19's form settings after an element's other attributes and an object's `data` as a URL, menuitem's end tag, a tag in capitals as an element of its own. The differential test now writes every element and attribute kind React special-cases, and random markup where templates have it; what the walker means to write otherwise (React 19's hoisting, its document singletons, function actions, an error in a Suspense) is listed, each with why. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…d as given in Head The walker escaped a `<style>`'s text as any text (`'` as `'`, `>` as `>`), which a browser never decodes there: rules broke. react-dom 19 writes a style's or a script's one child as given, escaping only what would end it, and a title's one child; React 18 escapes them, so the walker now writes them as the installed React does. In a <Head>, a style's CSS and a script's text (JSON-LD on a page) are written as given in React 18 and 19 alike, from a component of yours too, and a title's text is all its children's: Head's own rewrite of its direct children goes. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Writing markup as react-dom writes it for the installed React adds about 3.4KB to the main entry (197,899 bytes, over the 194.5KB budget): the format context the walk carries for a select's value, the form controls' rules, React 19's for a style's, script's and title's text, and a Head's style and script text written as given. CLAUDE.md, CONTRIBUTING.md and the README give the new budget and size. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The header now names an error in a Suspense among the differences the walker means, and a comment says plainly that react-dom 18 writes the listed trees as the walker does. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
React 18.2 began rejecting a `<title>`'s dangerouslySetInnerHTML; 18.0 and 18.1 write it, as React 19 does. The walker rejected it on every React 18, which the differential test caught on 18.0: it now rejects it on 18.2 and 18.3 only. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A `<style` or `<script` that would start a tag is escaped too, not only the end tag: the README says so. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A class component in a Column returning `<div style={...}>Hi</div>`
became an Html block holding only `Hi`: the walker kept what a function
component returned, not a class's, so the model took any `<div>` for
`dangerouslySetInnerHTML` and kept what's in it. A memo of a class or of
a forwardRef, a lazy component, a context's Consumer or Provider and a
StrictMode lost their wrappers the same way.
The walker now keeps what every kind of component returned, and the
model takes what's in an element only when it was given
`dangerouslySetInnerHTML`, so any other element is kept whole, with its
styles, as 0.2.1 kept it.
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
In markup, `class="px-4 md:pl-2"` lost all its padding: `md:pl-2` sets `px-4`'s left padding on wider screens, so every declaration of `px-4` was left out, its right padding too, and both classes left the attribute. A shorthand such as `p-4` wasn't compared by its sides at all, so `p-4 md:pl-2` kept its left padding on every screen. Only what a variant sets is left out now, compared as email clients read it (logical sides as physical ones, a shorthand by its sides); the rest of the class is inline (`padding-right: 16px`), and the warning names the property and the variant. A class inlined in part leaves the class attribute, as every class Tailwind knows does: the docs say so. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The Markdown sanitizer keeps `class` and refuses a `style` with `url()`, `expression()` and the like, but inside a `<Tailwind>` the classes of its HTML were inlined after it: `<span class="bg-[url(...)]">` became `style="background-image: url(...)"`, also when the `url(` was hidden behind an HTML reference or a CSS escape. Markdown now marks the blocks holding its HTML, and Tailwind checks each declaration it would inline there with the sanitizer's own style check, leaving out the unsafe ones with a warning. The mark isn't in the design. A developer's own markup is inlined as written: it isn't sanitized, and a background image there is meant. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A style's check let through `filter: progid:...AlphaImageLoader(src=...)`, which loads an image in old Internet Explorer engines, and CSS's `image()`. Both are now refused in a `style` attribute and in what a <Tailwind> inlines into Markdown's HTML, as `expression()` and `behavior` already were. A test also holds that a variant class (`sm:`, `max-sm:`, `hover:`) on Markdown's HTML ends up in no style. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A class like `[color:x(]`, `[color:/*]` or one with an escaped quote in its selector made every class Tailwind built after it unreadable, for every render using that handle: the stylesheet reader took `\(` and `\"` for a bracket and a quote, and Tailwind's compiler keeps every class it has built. The reader now reads CSS as CSS does (escapes, strings to their quote or the line's end, comments, brackets of each kind), so one rule can't change how the next reads. A class with a bracket, a parenthesis, a brace or a quote left open, or a comment, is never built: it's ignored as a class Tailwind can't read here. A build whose stylesheet still doesn't read cleanly is left out, the classes read before keep reading, and a fresh compiler takes over, on which that build's classes are built alone first so the one that was why is never built again. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…riant In markup, a class whose property a phone or breakpoint variant also sets was left out (`px-4 md:pl-2` lost its left padding). Inline styles can't follow a screen size, so leaving the class out made every screen wrong where inlining it leaves only the variant's screen wrong. The rule now holds only for what shows or hides the element, `display` and `visibility`: `hidden md:block` still inlines neither, as showing the element everywhere is the safer mistake. Any other class is inlined as it is on every screen, and the variant is said: `px-4 md:pl-2` gives `padding-left: 16px; padding-right: 16px`. The docs and the changelog say so. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The sanitizer kept `class` whole, so `<div class="bg-[url(...)]">` from Markdown stayed in the HTML and the design, where a later render in a <Tailwind> (from code generated from that design, say) would inline it unchecked. In `html="sanitize"` mode, a class name whose arbitrary value (from its first bracket, as the browser reads the attribute) would fail the style check is now left out, also behind an HTML reference or a CSS escape; the attribute is written as it was where nothing is left out. The check of what a <Tailwind> inlines into Markdown's HTML stays, as a second line. The sanitizer's fuzz has these vectors. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
`# [Pay invoice](https://example.com/pay)` was `PAY INVOICE` in the plain text: the heading pass took the tags out before the link pass saw them, so a Heading's links, and an h1 to h6 in markup, lost their URLs in every `links` mode. A URL with a `'` was cut off at it, an `href` after a `data-href` gave that one's URL, a link in preformatted text lost its URL, and a heading's link whose text is its URL was put in capitals. Links are now read first, each attribute's value whole in either quote, and their URLs set aside until the end, so a heading's capitals and the other steps don't change them; `wordwrap` leaves the URLs listed with `links: "after"` as they are. A property test renders every block and place a link can be in, with seeded URLs, and checks each URL in the HTML is in the design and in the text in each mode, or left out by `skipInText`, `data-skip-in-text` or `skip`. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The React version matrix ran in test.yml, which nothing in publish.yml waited for: a release could publish a commit before, or although, the matrix failed for it. The matrix is now a workflow of its own (react-versions.yml, called with `workflow_call`), unchanged in what it tests. test.yml calls it for every push and pull request, and publish.yml calls it for the commit it releases, whenever Elements would publish; the Elements publish job needs it. The docs point to the new file. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
When a fresh compiler failed to compile, the reader stayed marked as compiling one for good: after a build it couldn't read, that `tw` read no new class for the rest of the process. A failed one is now asked for again by the next read that needs one (no timer, so nothing is compiled while no class needs it), up to three in a row, as compiling the same config again isn't likely to pass after that. Classes left out while a fresh compiler is on its way say so, and after three failures they say Tailwind's error. That a render meeting a new class then renders without it, and the next ones with it, can't be made synchronous: the code and docs/tailwind.md now say why. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The changelog and the README said the plain text without options is as before, but preformatted text now keeps its spaces and line breaks, so 0.2 templates with a `<pre>` get other text. 0.2.1's text and this release's were compared on every story, on each block in each root, and on the same HTML through both conversions. The differences are preformatted text, what `data-skip-in-text` leaves out, how each link's URL is read, links in Paragraph and Heading children, and templates that now render as emails (an UnlayerProvider's mode). Each is a fix or part of a listed change, and each is now said. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Keeping a heading's links in the plain text adds about 0.3KB to the main entry (198,318 bytes, over the 198KB budget): links are read before headings and preformatted text, each attribute whole, with their URLs set aside till the end. CLAUDE.md and CONTRIBUTING.md give the new budget; the README's sizes (about 198KB, 56KB gzipped) hold. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The class-history test builds thousands of Tailwind classes, and the react-dom comparison renders over a hundred thousand elements twice. Each takes under a second on a fast machine but 8 to 11 seconds on a GitHub runner (React's development build, or coverage), past vitest's 5-second default, so the PR's test jobs failed. Both now say their own timeout (60 seconds), as the other heavy tests do. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A Paragraph's, Heading's or Button's children kept a class only inside a <Tailwind>, so a <Link className="mobile-link"> lost the class a <Head> stylesheet styles, and a <Link className> on its own in a Column became an HTML block instead of a Paragraph. The class now stays, escaped as React writes it, in the HTML and the design, as the editor's text keeps it; style was already kept. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The design's 0-to-0px pass looked at a key only when it ended in the last letter of a px key, and that list missed margin's "n", so a body's documentMargin or popupCloseButton_margin given as "0 0 12px" kept its bare zeros. The letters are now taken from the list of px key endings itself, so the two can't drift apart. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The comment on inline-html said a <Tailwind>'s classes style rich text children. Only a Paragraph's are inlined as styles; a Heading's or a Button's are kept as they are, as docs/tailwind.md says. Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
This branch has not been deployed
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.
What
The next
@unlayer/react-elementsrelease (0.3.0): a new render engine, formats developers already use, a design → code converter, and fixes so the HTML and the design that opens in the Unlayer editor always agree. No API is removed. One deliberate behavior change: a part that fails to render now fails the render (see below).A new render engine
0.2 rendered templates with react-dom: one render collected the template through markers written into its HTML, then about 15 more rendered the rows. In Next.js App Router server code (two React copies) that broke: components using hooks were dropped and async emails came out with no rows, with only a warning.
0.3 walks the template itself and answers its hooks (
useContext,useId,useMemo,use, …) as React's server renderer does. Elements never calls react-dom (it stays a peer dependency only because@unlayer/exportersimports it).<select value>,<textarea>, inputs),<style>/<script>/<title>text, attribute order, void elements. A differential test checks every element and attribute kind React treats specially (about 115,000 cases) and random trees, on React 18.0, 18.3 and 19.3. Four differences are deliberate and listed in that test: React 19 moving<title>/<meta>/<link>to the top, the<head>it adds to a bare<html>, function form actions, and a failure inside<Suspense>, which follows Elements' error policy. In<Head>, a<style>'s or<script>'s CSS and JS are written as given.[Unlayer] boom (in <Hero> at Row 3 › Column 1 › Hero), aRenderErrorwith the original ascause. A failure throws by default, in the sync and async renderers alike;onError: "omit"(or a function) leaves the failed part out of the HTML, the text and the design alike and lists it inrenderToHtmlParts().omitted. That covers every part: components, Rows and Columns, exporters, template wrappers, a custom tool's head, and what's in a<Head>.UNLAYER_SKIP_REACT_CHECK=1to bypass). CI runs the suite on React 18.0, 18.2, 18.3, 19.1, 19.2 and 19.3 in dev and prod (and the release job publishes only after that matrix passes), comparesuseIdwith react-dom's own, and runs a weekly canary.useId()gives React's own ids (no internal prefix);idPrefixsets a prefix for emails embedded in a page.packages/react/ARCHITECTURE.mdexplains the pipeline, the dispatcher, the error policy and the invariants.New entries (separate imports, so the main bundle stays small)
/tailwind<Tailwind>: classes become each component's props at render time, so the email gets inline styles and the design stays editable. Phone variants become phone settings,hover:becomes button hover colors, container text classes reach the text inside, card grids get gutters, and logical classes (ps-*,ms-*,border-s-*,rounded-s-*,text-start) follow the text direction. What it compiles stays bounded in a long-lived server (the last 50 configs; a fresh compiler past 2,000 classes)./markdown<Markdown>: Markdown becomes editable blocks. Raw HTML in it (html="sanitize", the default) is read once, as a browser reads it, and written back with only display tags and attributes (allowlists, URLs and styles checked), so nothing removed can rejoin into a tag; a fuzz test reads its output back with a real HTML parser./codeCodeBlock/CodeInlinewith highlight.js themes./font<Font>: web fonts with fallbacks. The URL is kept in the design./head<Head>: custom head tags. Components in it render as the template's do (context, async,onError)./asyncReact.lazytoo), Suspense, andprettyoutput. A<Suspense>around the root is waited for: its fallback can't make a document, so it never becomes the email./codegendesignToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones.Main entry
Link.style,classNameandskipInTexton every component.toPlainText.color,align/textAlign, Headingas/level. Close misspellings get "did you mean" warnings.valuesaccepts partial objects with strict keys (ValuesInput).HTML / editor parity fixes
<UnlayerProvider config>now applies. The most specific setting wins: render options, then the root'sconfig, then the Provider, then defaults. Safe in Server Components and on the edge.textis stored as typed, so the editor no longer formats markup the email shows literally.0pxin designs.fullWidthdefault agrees between HTML and design.url()values are percent-encoded.dir/langon the body.onError: "omit"), instead of blanking the email.cause.<p>wrapper), so lines with mixed text sizes no longer shift; a root'sfontsreach the design; the document has the editor's base stylesheet for each mode; images and video thumbnails are sized in the block's slot (the default 10px padding), not the column; a fixed-width image's percent is the same in HTML and design; and the design recordsdisplayMode, so code generated from it picks the right root. A per-block test checks every block's HTML equals the editor's export of its design, in all three modes.Row padding keeps the editor's meaning (outer padding, in email, web and print). Elements now warns when it sits outside a content background; use Column padding to inset. Tailwind
p-*on a Row insets, as CSS does.Validation
test:csp,test:browser-runtime,test:e2e,test:fidelityandtest:differentialpass. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod) passes.test:visualmatches on 138 stories × 2 viewports. The baseline was regenerated after attributing every changed render to intended changes (exact image widths, the Provider fix, the text block's markup, images sized in their slot).renderToHtml0.97–0.99×,renderToPlainTextabout 0.96×,renderToJson1.3–1.45× (0.11 ms for a 12-row email); against 0.2's engine, HTML takes about half the time and JSON about a third.Release notes
onError: "omit"keeps omission, listed); the upgrade guide in the guides PR covers it. The version is committed inpackages/react/package.json, which the release job publishes instead of its default patch. Without it, this would ship as 0.2.2 to every^0.2project.@unlayer/migrate's Elements range goes to^0.3.0here. The release job refuses an Elements outside the published migrate's range, so migrate then republishes after it (0.1.3, the same converter) and asks projects on 0.2.x to upgrade first.<Link>,style/className, the plain-text options, inline JSX), what changed, and the history from 0.1.1 to 0.2.1.@unlayer/react-elements-previousalias to 0.3.0.📖 Storybook Preview: https://unlayer.github.io/elements/pr/69/