Skip to content

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

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

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

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

What

The next @unlayer/react-elements release (0.3.0): 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 doesn't render with react-dom: its one call, in Node, renders nothing, so that react-dom puts back a context value a failed renderToString left (react-dom stays a peer dependency because @unlayer/exporters imports it).

  • Works where React works (tested): Node, the edge runtime, Next.js route handlers, Server Components and Client Components (webpack and Turbopack), React 18 and 19.
  • Components run once per render; async siblings load in parallel and always land in tree order.
  • Its HTML is react-dom's: the markup it writes matches react-dom/server's static markup for the installed React (18 or 19), byte for byte: form controls (<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. Five 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, a failure inside <Suspense>, which follows Elements' error policy, and a javascript: URL on React 18, which Elements writes as React 19 does (a URL that throws), with any React. In <Head>, a <style>'s or <script>'s CSS and JS are written as given, and nothing in them can end them early, whatever writes it (a component, a list, text in parts, markup): their end tag, and that of a <noscript> or <title> around them, is escaped as CSS or JS reads it, and under <svg>/<math> their text is escaped as any text. A fuzz test parses random hostile head content with a real HTML parser, with and without scripting, and checks no element appears and no text leaves the element it was written in.
  • Renders are isolated: a render's output and errors depend only on its own arguments. What it works out lives in its own scope, every public render starts a fresh one, and your code (components, wrappers, exporters, callbacks) runs outside it, so a render started inside another can't change it, and elements kept between renders can't carry anything over. A seeded test renders random pairs of templates that share hoisted elements, in both orders and at once, and checks each output never changes. That covers what React leaves behind too: react-dom's streaming render keeps the last values its providers gave in the context after a stream ends (renderToString, after one that fails or suspends inside a provider), and a context the template doesn't provide reads its default, standalone and inside streaming, client and Server Component renders, not another request's value: in Node, before it reads a default, Elements has react-dom/server render nothing, which puts back what a renderToString that failed or suspended inside a provider left, and nothing is kept from one render to the next, so no default is learned wrong; a render reads one value for it throughout. Inside an app's renderToString it reads the app's provider value, as 0.2 did. react-dom/server is loaded for this only in Node, the first time it's needed, through the package's ./fizz-cleanup export, whose conditions give edge runtimes, Workers, browsers and React Server Components a module that does nothing (a fizz-cleanup/ folder does the same for webpack 4 and Jest before 28, which don't read exports). Tests stream a page for one tenant, fail another's renderToString inside its provider, and then render emails (sync, async at once, every way a component reads context) on React 18 and 19, dev and prod; ARCHITECTURE.md lists the one rare leak case left: inside an app's stream or client render, where react-dom/server isn't loaded for this, or where the app's is another copy (Deno's import of it), a template reads what a failed renderToString left, till react-dom's next one. A template's own provider, or a prop, is read wherever it renders.
  • Errors name the component and where it sits: [Unlayer] boom (in <Hero> at Row 3 › Column 1 › Hero), a RenderError with the original as cause. 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 in renderToHtmlParts().omitted. That covers every part: components, Rows and Columns, exporters, template wrappers, a custom tool's head, and what's in a <Head>. An async or suspending template given to a synchronous renderer is a RenderError naming it too.
  • The design is the source of truth: the HTML is the exporters run on the design. The design no longer renders the HTML, so it's fast.
  • React compatibility is checked, not assumed: a once-per-process self-check of React's internals; an unknown React that passes works, one that doesn't fails with "unsupported React x.y.z" (UNLAYER_SKIP_REACT_CHECK=1 to 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), compares useId with react-dom's own, and runs a weekly canary.
  • useId() gives React's own ids (no internal prefix); idPrefix sets a prefix for emails embedded in a page.
  • Divider, Html, Menu and Social write their web and document HTML as strings, byte-identical to the pinned exporters but for a javascript: URL, written as one that throws with React 18 too (a 12k-case test that's also the drift alarm), so pages and documents render on the edge too.
  • packages/react/ARCHITECTURE.md explains the pipeline, the dispatcher, the error policy and the invariants.

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

Entry What it gives you
/tailwind <Tailwind>: classes become each component's props at render time, so the email gets inline styles and the design stays editable. Phone variants become phone settings, hover: becomes button hover colors, container text classes reach the text inside, card grids get gutters, w-*, max-w-* and min-w-* on an Image or the root make one width as CSS sizes it (<Email className="w-[600px] max-w-[800px]"> is 600px wide), a logo's height with an auto width (h-12 w-auto) is the width the image's aspect ratio gives where src has its size (192px for 400×100), phones too, and is left out and said where it hasn't, so the image isn't stretched, and logical classes (ps-*, ms-*, border-s-*, rounded-s-*, text-start) follow the text direction. A prop given as undefined (a wrapper forwarding an optional prop) leaves the class's value, and a class value that could end an HTML attribute or start a tag ([color:'"><img …>'], a class string from data) is refused and said, as is a url() that isn't a web, relative, inline attachment (cid:) or image data URL, base64 included (bg-[url(javascript:…)]). What it compiles stays bounded in a long-lived server (the last 50 configs; a fresh compiler past 2,000 classes, one compiled with the handle and kept ready, so a synchronous loop of renders, which never lets one compile, takes it at once; past 8,000 classes on one compiler with none ready, a render that needs a new class fails with a RenderError saying to yield between renders or use the async renderers, which wait for one: a batch job's loop of one new class per render took 2.6 s for its first 1,000 renders and 32.6 s for its sixth, and 150 Markdowns of 64 KB of classes 164 s and 2.9 GB), and so does what a declaration's variables come to (4,096 characters, left out and said past that), so classes in untrusted Markdown HTML can't exhaust memory; a render's classes (its components', their markup's, a Markdown's, in its <Tailwind>s) are compiled at once, so thousands of them from data take time linear in their number, and a render reads its first 2,000 different ones (the others stay as written, said once), so they can't take more memory either. Tailwind's compiler, which a handle shares across renders and which keeps every class it built, is given only classes it builds in time: at most 512 characters, 8 variants (each part of a compound one), 4 & in its variants and brackets 8 deep, and a render's first 64 KB of classes (one tenant's 100 KB [&:hover&:hover…]: failed every later render on the handle that needed a new class, 40 s later too, and 20 KB made each later build take 0.6 s; chained compound variants took 3 s for 8 KB). A class past them is left out as written and said once a render; within them a class takes Tailwind about a millisecond, and a seeded fuzz finds none that throws or that leaves the compiler unable to read the next. A class Tailwind's build throws for without keeping it is found by halves among the classes built with it and left out, and the render goes on. Each class is read in time linear in its size, whatever its arbitrary values and variants hold (a number in leading-[…], m-[…] or [@media(…)]:, the spaces before an !important: 20 KB took up to 13 s; a media query of many parts with two widths each, time exponential in their number). A plugin's function that throws for a class (or gives CSS that can't be read) never reaches Tailwind's compiler, which a handle shares across renders: the component whose class it is fails with the plugin's error, named, from the first render on and in every render that uses it, synchronous or async (left out with onError: "omit"), whatever CSS another utility gives the class (a static .boom-now beside the plugin's boom) and inside compound variants (group-*, peer-*, not-*), and nothing changes in other renders (another tenant's) on the same handle. A function that gives nothing for a value (if (!ok) return;) gives the class no CSS, a class Tailwind doesn't have, in every render, and one that gives what Tailwind's compiler throws for, and then for every build after it (an @apply of a class or a variant Tailwind doesn't have, or of the class itself; a list holding null; a variant that isn't a selector), fails its component the same way, never reaching the compiler; an @apply in a function's output is read as Tailwind reads it. A plugin's error is tied to its class by the value and the modifier Tailwind gave the function (an arbitrary one as Tailwind decodes it: brand-(--x) is var(--x)), and one that can't be tied to a class fails only the render that built it, so a user's brand-(--x) in Markdown can't fail the app's own brand-blue; a function that gives false gives no CSS, and one that gives what isn't CSS (a string, a number, a function) fails its component. Classes in a Heading's or a Button's children and on a <Link> or an <a> on its own in a Column are inline styles too, as in a Paragraph's; a style prop given as null doesn't count, as one given as undefined doesn't (a content prop given as null, text={null}, is no content, as in 0.2.1). Where Tailwind still compiles again after CSS it can't read (a config's own theme value), a synchronous render that needs a class meanwhile fails saying to render again, and the async renderers wait (and render again where their own class was the one): no render goes out without a class's styles.
/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. A table in a list or quote takes the same html, url, linkAttributes and styles. Markdown from data takes no more than its size calls for: its tables can have, in all, as many cells as it has characters (or 1,000), since marked fills short rows out to the header's width (5 KB was 229 MB of HTML); a table past that fails as a component does (onError: "omit" leaves out just it), and quotes and lists nest 32 deep (deeper is text, where thousands deep overflowed the stack). Its inline syntax is read in time linear in its size: marked looks ahead from each emphasis, strikethrough, link, code or raw HTML opener nothing closes to the paragraph's end (100 KB of *a took a minute, and emphasis nested a few thousand deep overflowed the stack), so each of marked's rules is given only as much of the text as it can look at (checked against marked's own rules on random text) and what they look at is counted, 32 steps a character and about a million more, past which the rest of the inline syntax is text; emphasis, strikethrough and links nest 32 deep. 31 MB of READMEs, changelogs and documentation lex to the same tokens as before (at most 6 steps a character), and the worst shapes render 100 KB in about 0.2 s and a megabyte in 1.5 s. Quotes and task lists are read in time linear in their size too (100 KB of a quote holding a list item and a lazy line, every other line, took 6.6 s), counted per quote, past which a quote is its first chunk and the rest reads on outside it.
/code CodeBlock / CodeInline with highlight.js themes. highlight.js takes time growing with the code's size squared on some code (40 KB of INI took 4.4 s, 100 KB in a Markdown fence 27 s), so it's given at most 8,192 characters at once: a longer block is shown without colors, said; a render's blocks share one budget, every Markdown's and CodeBlock's in it (their sizes squared up to 8,192²: 200 comments of 8 KB of Java, each its own Markdown, took 82 s, now 0.44 s), as do Markdown's inline syntax and quotes.
/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).
/async Async templates and components (React.lazy too), Suspense, and pretty output. A <Suspense> around the root is waited for: its fallback can't make a document, so it never becomes the email.
/codegen designToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones. The editor's email, phone call and text message links become the URLs its export writes (mailto: with the subject and body encoded, tel:, sms:) on Buttons, Image actions and Menu items, which the editor opens as the same actions again; a link with attributes no component's link takes (a rel, a title, code to run) is written as the design has it, with a warning, and compareDesigns() compares it with them. A Menu item without a link comes back without one.

Main entry

  • Link.
  • style, className and skipInText on every component.
  • Plain-text options and toPlainText, which reads HTML in time linear in its size whatever it holds (an element or a < nothing closes: 40 KB of <img alt="a" took a minute).
  • Prop aliases: Menu color, align/textAlign, Heading as/level. Close misspellings get "did you mean" warnings.
  • values accepts partial objects with strict keys (ValuesInput).
  • Rich inline JSX children: a javascript: URL in them is left out however it's encoded (&#106;avascript:, javascript&colon;), and an email service's <%name%> stays in the plain text too. Their markup is read in time linear in its size (a <style> or <script> at the end of a component's markup made 100 KB take up to 16 s).
  • A Button's target is its link's; one that could end its HTML attribute or isn't a string (or a Menu item's) opens in a new tab instead.

HTML / editor parity fixes

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

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

Validation

  • Tests: react 1894, shared 142, convert-core 212, from-react-email 344, migrate 212. Typecheck, test:csp, test:browser-runtime, test:e2e, test:fidelity and test:differential pass, and so does a replay of the CI workflow on a clean checkout of this head (b35cc79), Storybook's visual gate included, and of the three stacked PRs' merges of it. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod) runs in CI on this head; it passed in CI on an earlier head, and at 11ec4e2 it was run locally on 18.0.0, 18.2.0, 18.3.1 and 19.3.1, dev and prod, its tests on Node 20 as in CI (react 1859 on 18.x and 1868 on 19.3.1 in development, 679 and 688 of the engine and regression suites in production). CI's runners had failed three tests and vitest's report of the results, Elements' code passing: the setup now turns the event loop between tests (a file's synchronous timing tests ran past vitest's 60 s report timeout), the Tailwind swap test reads the stylesheets where Elements reads them (vitest gave its tailwindcss mock to one of several imports under way at once, Tailwind itself to the others), the Markdown and rich text growth tests time a shape that looks slow twice more at both sizes and then at ten times the size again, each quote is timed against a quarter of its size on the same machine, the deep code test nests its tokens 1,024 deep (4,096 ran out of Node 20's stack in highlight.js before its code was optimized, which then writes the code without colors), the Tailwind class growth fuzz retimes a shape that looks slow on fresh handles and leaves out the one variant shape Tailwind's own compiler reads in time growing with its size squared (min-[var(--x)…]:, its -s each a variant lookup), which a test reads at the 512-character cap under 100 ms, and a test has 30 s (several took most of vitest's 5 s on the runners); each changed test still fails on the code before its fix. Every timing assertion in the react and shared tests now goes through one helper (src/test-timing.ts): a cost at two sizes that looks slow is timed again, each size's fastest of three samples of 20 ms at least, so a few milliseconds' baseline isn't noise; a cost over an absolute limit runs twice more, its fastest held to the limit times the machine's slowness, measured then; a retime that would read a handle's built classes runs on a fresh handle. Spot-checked on the code each was written against: the many-blocks test (6fc708b's parent), the 8,192-character highlight limit (e044386's parent) and plain text's tag names (c2d414b's parent) still fail. The streamed-context test's helper released its page's data on a 5 ms timer, which a busy machine could fire before React 18's shell (scheduled with setImmediate, run after due timers once a test starts in the setup's turn of the event loop): it now releases it from onShellReady.
  • Hostile inputs found before release, each with a test that fails without its fix: a synchronous loop of renders on one Tailwind handle no longer grows its compiler without end (see the /tailwind row); Tailwind's stylesheet, which each build reads again whole, is read in time linear in its size on Node 20 too (a compiler's of 2,400 classes, 114 KB, took 118 ms a build, now 5.6 ms: the class growth fuzz failed on CI's Node 20 for it); a Tailwind class value that could end markup's escaped style or start a tag once read (font-['\22><img…>']), or a URL a component can't take (url(javascript:…)), isn't inlined in markup either, a Markdown's sanitized HTML included, while what markup can safely hold still is (a theme's double-quoted font names, url("…"), a string's ;); a render's shared work is shared across copies of Elements and with all the code it runs (562 sparse-table comments were 195 MB of HTML and 733 MB RSS); plain text leaves out a link's blocked URL (Pay now (javascript:…)) and keeps its placeholders from text that decodes to them; a render's shared Markdown and code work stays its own (a template wrapper or a nested render reached another render's), and Markdown's table cells count against it (562 sparse tables were 196 MB of HTML and 746 MB of memory, now 35 MB and 246 MB); a code theme named __proto__ and a plain-text skip of ( no longer throw; one tenant's Tailwind class from data no longer breaks or slows other tenants' renders on a shared handle ([&:hover…]: of 100 KB, chained compound variants, max-sm: 3,000 times: see the /tailwind row), and a synchronous batch of 51 recipients renders all 51 where one recipient's class had the 50 after it told to render again; a Markdown's headings with a class each are compiled at once (2,000 took 4.7 s); an image's size given as a string is read once (30,000 digits took 1.1 s); a render of many Markdowns and CodeBlocks shares their work (12 comments of 8 KB of Java, each its own Markdown, took 4.8 s, 200 took 82 s; now 0.5 s and 0.44 s); plain text reads a tag name with a word boundary at each character once (40 KB took 1.3 s and 0.9 GB); a Video's videoId from data (abc" onload="…) ended its player's src on a page or in a document and added an event handler (0.2.1 too), and designToElements() wrote it through: what an id doesn't hold is percent-encoded now, in the HTML, the design and the code, and the editor's ids stay as they are; a Tailwind class from data is read in time linear in its size (a number in an arbitrary value or variant, the spaces before !important: 20 KB took up to 13 s; a media query of many parts with two widths each, exponential); rich text's markup from a component of yours is read in time linear in its size (with a <style> or <script> at its end, 100 KB took up to 16 s); HTML's plain text is written in time linear in its size (40 KB of unclosed <img alt="a" took a minute, 80,000 spaces in a <pre> 11 s); code's trailing whitespace is taken off in time linear in its size (a fence of 100,000 spaces then x, 5.7 s); Markdown's would-be tables, link text, list items, paragraph lines and references are read in time linear in their size (100 KB of a over - took 14 s, [ and 25,000 <!-- 7 s, 200 KB of list item lines 6 s); a design's lengths and mailto: fields in designToElements(), a Lexical paragraph's style, a component's style and pretty's comments are matched in time linear in their size (100,000 ; took 15 s, 400 KB of unclosed comments 12 s); seeded growth tests of random short shapes (10 times the size costs at most 20 times the time) cover code in every language, Markdown, HTML's plain text, Tailwind classes and rich text's markup, and hold in React's production build too; a user's class a Tailwind plugin throws for (brand-(--x), which Tailwind gives the plugin as var(--x)) no longer fails the app's own classes of that utility (brand-blue) for every render after it on the handle; Tailwind classes in a Heading's or a Button's children and on a lone <Link> are inlined, where they stayed as class attributes email clients don't read, without a word; color={null} (any style prop) no longer writes color: ; over the default and the class, and a content prop given as null (<Html html={null}>, text={null}, a Table's cells) renders nothing, as in 0.2.1, not the block's placeholder (Hello, world!, Heading), in the HTML, the plain text and the design, with <Tailwind> too; a <script>'s or <style>'s text in rich text children, which React 19 writes as it is, no longer becomes a live link; a Row's anchor, a Video's link and thumbnail and a Social icon's image can't add markup (0.2.1 wrote them as given), and a Video's javascript: link is one that throws (its thumbnail, as an Image's src, is written as given); a <Font> with a javascript: URL loads nothing (a root's fonts prop still writes one into its <link>, which a browser doesn't run); highlight.js's time is bounded (100 KB of INI in a Markdown fence took 27 s); Markdown's quotes and task lists, and designToElements() (a 200×200 table took 515 s, now 93 ms), take time linear in their size; Markdown's inline syntax from data is read in time linear in its size (100 KB of emphasis nothing closes took a minute, and emphasis nested a few thousand deep overflowed the stack; now 0.2 s, its output the same for Markdown as people write it); a Tailwind plugin's function that gives nothing for a value, or an @apply Tailwind can't compile, no longer breaks the handle every render shares (the first render failed at <Body>, a raw TypeError from renderToJson, another tenant's said to render again, and later ones dropped the class); a plugin's error that can't be tied to a class (a variant named @) no longer falls on every class of its compile without CSS, or on all of them, for every render (another tenant's p-4); classes from data can't add markup through a prop; Markdown's tables and nesting are kept to what its size calls for (adversarial tables of 1 to 100 KB now fail in milliseconds, the whole process under 150 MB, where 5 KB was 229 MB of HTML and almost 1 GB; within the limit the output stays linear, about 340 bytes of HTML per input byte at most, as a Table block writes each cell); encoded javascript: URLs in inline JSX's markup are left out; a javascript: URL in a Menu item's or Social icon's link on a page or in a document, a <Link> or a component's markup is written as react-dom 19 writes it, a URL that throws, with React 18 too (on React 18 a click in JSDOM ran it, as react-dom 18 writes it as given; 0.2.1, on React 19, wrote it as one that throws), and so are the links the editor's exporters write themselves, a Button's href, an Image's action, an email's Menu items and Social icons, in every mode and in the design, in every shape (a prop, values, attrs.href, custom attributes) and read with their character references decoded, as a browser reads what the exporters write as given (&#106;avascript:, javascript&colon;, java&Tab;script:; any other URL kept byte for byte) (0.2.1 wrote those as given; Paragraph's html, <Html> and a Table's cells stay raw HTML, and style props are written as given, as documented); link targets can't end their attribute, nor can a target, a Menu item's text or a Social icon's name that isn't a string (an array from JSON); Markdown in <Tailwind> renders in time linear in its size (a megabyte of elements nested in one with a class, or of unique classes in one paragraph or one to a paragraph, in under a second, where 4,000 classes took 23 seconds and 40,000 nested elements 4.5); a table in nested quotes is charged once against the Markdown's cells; a class whose url() could run code (bg-[url(javascript:…)]) is refused; a megabyte of unique classes in one Markdown attribute took 489 MB and ran a 128 MB heap out of memory: a render now reads its first 2,000 different classes, and a megabyte in one attribute, one to a paragraph, in many attributes or on nested tags peaks at 115 to 353 MB (the last is the paragraphs' own, 309 MB without Tailwind; 290 to 457 MB before), in 120 to 600 ms, and renders with a 128 MB heap; one render whose classes held one Tailwind couldn't read no longer takes an app's other classes (p-4) from the renders after it on the shared handle, nor a plugin's error for one class every class of that render; a class outside any <Tailwind> isn't compiled; a cid: or base64 image url() is kept; a link of null (<Button href={null}>, which failed in 0.2.1 too) is none, and a null Menu item or Social icon is skipped (0.2.1 failed too); a Tailwind plugin that throws for one tenant's class no longer makes another tenant's render on the same handle go out without its styles; and the Markdown guide gives its output, time and memory per kilobyte by shape, the most where every few characters are a block of their own, a heading, a list item or a table row (100 KB: 0.5 to 28 MB of HTML, in up to 0.38 s, a 90 to 350 MB peak, inside a 128 MB heap, the densest shape in 192 MB; 1 MB: 5 to 276 MB, up to 3.4 s, a 270 MB to 1.9 GB peak), and recommends capping user Markdown at about 100 KB.
  • Found by a fresh review at 11ec4e2, fixed with tests that fail without each fix: a Liquid tag with a quoted default ({{ first_name | default: 'friend' }}) beside inline markup in a Paragraph's, Heading's or Button's children came out as &#x27;friend&#x27; in the HTML and the design, which the email service's Liquid rejects (0.2.1 kept it), and a background image URL's {{ campaign_id }} as {{%20campaign_id%20}}: both keep the tag as written now; markup a component writes as HTML with a tag that never ends before an unclosed <style> (<img src=x onerror=alert(1) <style>) came out as a live handler: such a tag is left out with what follows it, as a browser reads it; a Row's or block's display condition (the editor's, {% if customer.vip %} … {% endif %}), which design-to-code keeps from an editor design, was left out of the HTML (0.2.1 too), so a VIP-only row went to everyone: it's written around the row or block now, as the editor's export does, and design-to-code says where a row's or block's merge tag rule isn't applied; a Table's typed headerColor, contentColor and fonts no longer warn that they're ignored. The README now says what mergeTagState is (the editor's state for a Table's repeat rule, not merge tag values).
  • Found by ten independent reviewers at 6472ab3, fixed with tests that fail without each fix: a background image URL's email-service tag with quotes, a comparison or brackets ({{ hero | default: 'hero.png' }}, {% if tier > 1 %}, AMPscript, <%hero%>) was percent-encoded, which failed the service's parse of the whole email (0.2.1 kept it), and a character reference that reads as a quote or a bracket got past that encoding into the row's style: the URL is now written by one function that encodes such references, keeps a tag as written but a " (write a tag's strings in single quotes there), and encodes the whole URL where a tag's quotes would take a bracket out of its CSS string; rich text's double quotes stay as written, as its single quotes do; a lone <Link> to SendGrid's <%asm_group_unsubscribe_raw_url%> stays a Paragraph with its URL as written (it was an Html block with the tag escaped, a dead unsubscribe link); a comment in rich text, one left open too, is left out (it hid the rest of the email); a design's timer, form or custom block kept as an <Html> block keeps its display condition and anchor; a heading's plain-text capitals leave every common service's tags as written ({{ FIRST_NAME }} before, 0.2.1 too); a Table's cells from JSON that aren't strings (a quantity) are their text, where the render failed.
  • Found by ten more independent reviewers at 78bdc8f, fixed with tests that fail without each fix: rich text's quotes, written back as written, could end a kept attribute whose value held a < (the round's own regression): a kept attribute's < and > are references now, so a tag is read whole; a background URL's tag could end the Outlook conditional comment its VML is in: such a URL is encoded whole; the root's values._meta id and class names are kept to what they can hold, as a Row's and a block's are (0.2.1 wrote them as given); and a Date or an object in a Paragraph's, Heading's or Button's text fails the render, as React's does and the README says, where it was left out without a word (0.2.1 too).
  • Found by ten more independent reviewers at 18516bd, fixed with tests that fail without each fix: rich text's stray < and > around a tag taken out are text now (a seeded test builds markup of random pieces and checks only rich text's own tags and attributes come out); a style value with a " (a font stack, url("…")) is kept, written as &quot;, where it was left out (0.2.1 kept a Row's); a Tailwind width class sizes an image whose src gives its natural size (w-8 rendered at 256px); a Table's values type has the merge tag rule the README shows; a Heading level that isn't a tag name is an h1, and said.
  • Found by ten more independent reviewers at ee41b51, fixed with a test that fails without it: toPlainText() of pretty HTML gave links the wrong URLs, as prettier writes a long line's end tag as </a\n >: such end tags are read as one now. An image's src.maxWidth wins over a Tailwind width class again, as a flat maxWidth does.
  • Found by an outside review at b35cc79, fixed with tests that fail without each fix: a context the template doesn't provide read another request's value after the app's renderToString failed inside its provider, and a stream and a failed renderToString could make a tenant's value the default Elements learned for good (see above); a <Link>'s or inline <a>'s href with a quoted Liquid default ({{ email | default: 'none' }}) was written as &#x27;none&#x27;, which Liquid rejects; an Image with its natural size, which an email writes twice (Outlook's copy and everyone else's), got its style and className on Outlook's copy only (a rounded-full avatar came out square elsewhere); and esbuild couldn't bundle the async, Tailwind or codegen entry without prettier (below).
  • Independent review: ten reviewers at b35cc79, each with its own method (three reading the code; seven running it against the built package: real Liquid, Handlebars and Mustache engines on the output, a sanitizer fuzz checked with an HTML parser, 0.2.1 parity on 100+ templates, the editor and design-to-code round trip, the docs' claims run as written, engine, concurrency and cost, and one with no brief), found no blockers. The rounds before found what the bullets above list.
  • Runs the same in Node, Deno and Cloudflare Workers: one script renders every renderer in all three modes, an async template, Tailwind, Markdown, code, design-to-code and plain text, on the packed package; Deno (read, env and sys permissions only) and a Worker in workerd (no Node compatibility flag) give byte-identical output to Node. That check found that esbuild (wrangler's, a serverless bundler's) couldn't bundle the async, Tailwind or codegen entry without prettier installed, as their optional import()s had literal names: prettier is loaded by a name no bundler follows now, and a test bundles every entry with esbuild with prettier missing. The React matrix (18.0.0, 18.2.0, 18.3.1, 19.1.0, 19.2.9, 19.3.1; dev and prod, tests on Node 20) passed locally on b35cc79.
  • Same output as the 0.2 engine: 956/956 outputs byte-identical on React 18.3, 19.1 and 19.3 across the corpus (examples, patterns, showcase, the 152 editor round-trip templates, migrate outputs, i18n), and 45,000 random templates with 0 differences, apart from the intended changes (errors throw, clean ids, react-intl templates that 0.2 rendered without their translations now render fully).
  • Visual: Storybook builds and test:visual matches on 138 stories × 2 viewports. The baseline was regenerated after attributing every changed render to intended changes (exact image widths, the Provider fix, the text block's markup, images sized in their slot).
  • Integrations: the packed tarball builds and verifies in the Next.js 14, 15 (webpack and Turbopack) and 16 apps and the Jest 30 app: route handlers, Server Components, Client Components, async routes and pages, App Router and Pages Router edge routes, with hooks used after Elements' renders.
  • Speed (median, production, vs 0.1.22): renderToHtml 0.97–0.99×, renderToPlainText about 0.96×, renderToJson 1.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.
  • Bundle: the main entry is 225,182 / 225,500 bytes unminified (it now carries Elements' own renderer and HTML writer instead of using react-dom's; the last three rounds add about 3 KB: javascript: URLs written as one that throws with React 18 too and in the exporters' own links, a render's <Tailwind> scopes given only their records and a key for the render, a plugin's error made its component's, base64 image URLs in CSS values, links and Menu and Social entries of null; the last round about 1 KB: a Row's anchor, a Video's link and thumbnail and a Social icon's image kept from adding markup, a Font's javascript: URL loading nothing, Tailwind classes in Heading and Button children and on a lone <Link>, null props, a <script>'s or <style>'s text left out of rich text; this round about 8.7 KB, most of it HTML's plain text read in time linear in its size by scanners of its own, then a content prop given as null rendering nothing, a Video's id kept from ending its player's URL and rich text's markup read in time linear in its size, less 1.4 KB from the scanners sharing one loop and one binary search; this round about 1.9 KB: Markdown's and code's work shared across a render, plain text's tag names indexed by their parts, a rich text block's markup given to <Tailwind>; this round about 1.5 KB: a render's shared work kept to its own render, code themes read by their own names, plain text's skip names read as written; this round about 1.8 KB: a render's work shared across copies of Elements, plain text's placeholders and blocked URLs, text in a Row or Column said once per render; and the last four rounds about 4.2 KB: an email service's tags kept whole in rich text, background URLs and plain text, display conditions, rich text's sanitizer and comments, a root's ids, object children failing the render, style values' double quotes, Tailwind widths on sized images, a Heading's level checked and a Table's cells from data; so the budget goes from 203 KB to 225.5 KB).
  • The package as npm installs it (the packed tarball, in new projects): it has its MIT LICENSE again (0.2.1's did; pnpm skips the repository's copy when another file matches LICENSE, as THIRD_PARTY_LICENSES.md does), and CI's pack steps (Elements' and migrate's) and the publish jobs now fail without one. Each entry's import and require get their own types (.d.ts, .d.cts), and typesVersions gives moduleResolution: "node" the subpaths: arethetypeswrong finds no problems on all 8 entries for node10, node16 (CJS and ESM) and bundler, where 0.2.1's main entry was "Masquerading as ESM". The code and Markdown entries' types no longer import highlight.js, which is built in (TS2307 without skipLibCheck; CI's check now finds an import of it or of marked in either quotes or as an import() type), and registerLanguage() still takes highlight.js's language modules. npm and pnpm projects on React 18 and 19 import or require every entry and type-check them with skipLibCheck: false on TypeScript 5.9 and 7.0 (bundler, node16 ESM and CommonJS, nodenext), and with moduleResolution: "node" with skipLibCheck on (without it, @unlayer/types, a dependency, has no node10 types for its subpaths, as in 0.2.1); the README's quick start renders. The JavaScript each entry resolves to is unchanged.
  • Next.js 16 consumer on 16.4.2: its next build on 16.4.0 now stops with a SecurityFatalError, as npm lists advisories against 16.0.0 to 16.4.0 (two critical, one high) published since its last run; tests/next16-integration pins 16.4.2, the latest 16.x and outside them. The Next.js 15 and 14 apps' builds don't stop, so they're unchanged.
  • Optional peers of any version: tailwindcss and prettier stay optional peer dependencies (pnpm's isolated node_modules and Yarn PnP resolve them through that declaration), with the range *. The ranges this PR had before (>=4.0.0, >=3.0.0) made npm install fail with ERESOLVE in any project that already had Tailwind CSS 3 or prettier 2 (0.2.1 declared only React). With one of those installed, loadTailwind (and <Tailwind config> in the async renderers) and pretty now say to upgrade it (npm install tailwindcss@4, npm install prettier@3), instead of TypeError: run is not a function and "install prettier"; without them, the messages are as before. CI installs the packed tarball in a project with tailwindcss 3.4.17 and prettier 2.8.8 and checks both messages, as a module and required; the main entry's build is unchanged.
  • Real editor round trip (152 templates: feature tests, showcase, CLI starters, all docs patterns and starters, MJML and Beefree imports; editor 1.524.0, re-run on the new engine with 1.526.0 with identical results): every design loads, saves and exports with nothing lost, and the code generated back from it type-checks and gives the same design. Elements' HTML looks identical to the editor's export on desktop for 134/152 (115 before these fixes). The rest are the editor's own behavior (its stylesheet for Google fonts it lists, phone settings that need mobile design editing), the HTML-only props, or the newer editor's page margin for documents. The one Elements difference left is sub-pixel: a fixed-width image keeps the exact px the author asked for (64px), where the editor derives 63.97px from the design's percent.
  • Tailwind accuracy, measured against real Tailwind 4.2.1 in Chrome on 263 classes: 96.9% render like Tailwind in email and 95.7% on web; 168/170 colors are byte-exact; line heights are within 0.5px.

Release notes

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

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

@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.32339% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.58%. Comparing base (eb0a80d) to head (8581469).

Files with missing lines Patch % Lines
packages/react/src/codegen/print.ts 78.04% 9 Missing ⚠️
packages/react/src/async/pretty.ts 70.00% 6 Missing ⚠️
packages/react/src/tailwind/declarations.ts 98.39% 6 Missing ⚠️
packages/react/src/engine/walk.ts 95.23% 5 Missing ⚠️
packages/react/src/async/index.ts 92.72% 4 Missing ⚠️
packages/react/src/markdown/Markdown.tsx 94.73% 4 Missing ⚠️
packages/react/src/codegen/blocks.ts 94.23% 3 Missing ⚠️
packages/react/src/engine/attributes.ts 81.25% 3 Missing ⚠️
packages/react/src/engine/index.ts 96.00% 3 Missing ⚠️
packages/react/src/markdown/reach.ts 98.23% 3 Missing ⚠️
... and 9 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   96.55%   97.58%   +1.02%     
==========================================
  Files          40      139      +99     
  Lines        2789    10392    +7603     
  Branches      795     3818    +3023     
==========================================
+ Hits         2693    10141    +7448     
- Misses         95      251     +156     
+ Partials        1        0       -1     

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

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

ivoIturrieta and others added 25 commits October 17, 2026 08:07
…ain text directly

Each block's, Column's and Row's values in the design were merged, then
copied into the object that adds its _meta and the editor's flags; they
are now written into that object as they're merged. The defaults' own
values, which every render copies, are worked out (their zeros written
`0px`) once per defaults object and copied from there.

A Paragraph's text given as text is Lexical JSON of one plain paragraph,
which textJsonToText now reads by its text instead of parsing and
walking the tree; the model's check of a block's props remembers which
names a block takes; a block's `style`/`className`/`skipInText` check
returns at once where it has none; and an id or class name that holds
only letters, digits, `_` and `-` (as Elements gives them) isn't
sanitized again.

renderToJson on Big goes from 0.65 to 0.51 ms (React 19.1, production).
The output is the same (corpus and 2,000 random templates).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Flattening the walk's output made two bookkeeping objects for each
record and each element of markup; it now makes one only for a part
that can be left out. A production build of React checks no keys, so
the walker keeps no list of the lists it checked there. The model's
Row looks for a rewrite and reads its padding without copying its props,
and the head tells every built-in head the same render settings (a
custom tool's head gets its own), calls none for a Row or Column, and
escapes an id for its phone CSS only where there's phone CSS.

renderToHtml on StripeReceipt goes from 0.28 to 0.26 ms, on Big from 1.57
to 1.46 (React 19.1, production). The output is the same.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderToHtml rendered the root (Email > Body) through the walker, which
wrote the body's HTML out once more inside Body's `<div>`, and then took
the `<div>` off again with a regular expression over the whole body. A
root of Elements' own is now called as the walker would call it (its
components call no hooks, inside the render around it as before), and
the document gets the HTML Body gives, as it is; renderToHtmlParts and
renderToPlainText get it in the `<div>`, as before.

renderToHtml on StripeReceipt goes from 0.26 to 0.24 ms, on Big from
1.46 to 1.27 (React 19.1, production): faster than 0.1.22 (0.25, 1.33).
The output is the same.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The model's Rows, Columns and blocks were made with createElement, which
copies the props object it's given; React 19's jsx takes it as it is
(React 18's copies it, as before). A Row's and Column's children are
given as static children, as the model's lists are (no keys to check).
The design's internal props are a set, not a pattern.

The output is the same.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry had the async walk's holes and the loop that flattens
them once they settle, and awaited a template's async wrappers, though
only "@unlayer/react-elements/async" uses them. The walker now takes how
to wait (`wait`): the synchronous walk has none and throws to the
nearest <Suspense> as before; the async entry gives its own
(src/async/collect.ts), with the main entry's engine through its
internals, so one copy of the engine renders, and takes the steps to a
template's root itself. An unused Menu default goes too.

The main bundle goes from 187,455 to 186,376 bytes, the async one from
3,655 to 5,170. The output is the same.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An email or page placed in a page of the app's own (an iframe-less
preview, a confirmation shown on the page that sent it) had useId()s
that the page's own could repeat: Elements gives React's ids, from the
start of the tree.

`idPrefix` in a render's options (every renderer, the async ones and
renderToJson too), a root's `config` or an <UnlayerProvider>'s config
starts each of the template's useId()s with it, as React's
identifierPrefix does (`«em-R1»` on React 19.1, `:em-R1:` on 18,
`_em-R_1_` on 19.2 and later). A template component around the root
takes it from the render's options or an <UnlayerProvider> around it:
it's called before the root's `config` is read. It's typed on
UnlayerConfig and RenderToJsonOptions, and documented in the README.

A test compares the walker's prefixed ids with react-dom's
identifierPrefix on random trees, and the renderers' with and without.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The Tailwind entry registered its compiler in one global slot, which the
async renderers compiled every <Tailwind config> with: with two copies
of Elements in one app (a package still on another version), the one
loaded last compiled both copies' scopes, and a copy given the other's
compiler can't rewrite its Rows.

A <Tailwind> now carries its copy's compiler on its type; a collected
scope records it, and the async renderers compile its config with it
and give its handle to that <Tailwind>. An older copy's, which carries
none, gets the compiler loaded last, as before. A test registers
another copy's compiler after this one's and renders.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The React Email migration writes a list item that's several Rows as a
list of them (`articles.map((article) => [<Row key={0} />, <Row
key={1} />])`, the weekly-digest fixture's codemod output). Each list's
keys are its own, as in React (react-dom says nothing of them either), so
the same keys in each item aren't children with the same key: the
walker says nothing of them, in any renderer. This pins it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Body rendered each Row with one function of the engine's and the
renderers the root with another, which did the same: call one of
Elements' own components (they call no hooks) as the walker would, and
give the HTML of the `<div>` it returns as it is. They're one now
(renderOwn), and the bundle budget is set to the bundle's size: 186,412
bytes, budget 187,000 (phase 1's was 173,500: the string exporters, the
model and the work it saves).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ARCHITECTURE.md's engine table names renderOwn and the async entry's
walk, the unwrap step says who awaits a template's async wrappers, and
the test table has the tests for renderToJson's exporters, the head's
reuse and idPrefix.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The pinned Page and Document HTML of Divider, Html, Menu and Social is
0.3.0's, with React 18. On the CI's React 19 jobs one template differs,
as react-dom 19 has it too: a Menu link to `javascript:alert(1)`, which
React 19 writes as a URL that throws. The test now checks that case's
React 19 digests and compares the rest with 0.3.0's.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderOwn made a new element (createElement) for an element whose type
isn't one of Elements' own components, to walk it, and React 18's
development build warned a second time where that type is invalid (a
template whose root isn't a component). It now walks a clone of the
element, as the walk did before, which React doesn't check again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
renderToHtml takes about half of 0.3's time (as fast as 0.1.22's),
renderToPlainText about three quarters, renderToJson about a third.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
UnlayerProvider imported createContext and useContext from react by
name. React's react-server build (Next.js's Server Components, route
handlers and edge routes) has neither, and webpack, Next.js 15's
default bundler, fails the build of a module that imports a name the
module it imports doesn't export: `next build` failed for an edge
route that rendered an email, where Turbopack built it. Node's ESM
loader fails it too, as it links the module. They're read off React
now; the context is still made when it's first read.

The react-server build test now imports each ESM entry with that build
(six of the eight failed to load before) and renders.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…n each layer

The Next.js 15 app was built with Turbopack only. CI builds it with
webpack as well (`npm run build:webpack`, Next.js 15's default) and
checks it the same way. Each route renders with every entry of
Elements too (Font, Head, Markdown, code, codegen, and Tailwind with
the async renderers), and a Pages Router API route in the Node.js
runtime is checked. The Pages Router edge API route allows
@unlayer/exporters' dynamic code: webpack rejects lodash's
`Function("return this")()` there, which the edge runtime never
reaches.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…or: "omit"

A Row's or Column's exporter that failed threw even with onError:
"omit", and named the Row as "Row 1" wherever it was (the walk of
Elements' own markup wrapped the error before the model saw it). What
fails in a Row's or Column's own HTML is now a RenderError naming it
and where it is (`Row failed to render: … (in <Column> at Row 3 ›
Column 2)`), and with "omit" it's left out as a block is, listed in
`omitted`: a Row whole; a Column with its cell, its Row made again
without it, so the Columns after it move up and the design's cells
match its columns; a Row with its last Column. The HTML, the plain text
and the design are the template's without that part, which the tests
check against the template written without it.

The bundle budget goes from 187KB to 189KB for it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…nd check

The app's async routes render Tailwind classes, which the async
renderers compile with tailwindcss as they run: installed with the app,
as an app using Tailwind installs it. In a scratch copy the check found
another project's tailwindcss further up the folders; CI's fresh app
has none.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… skip its check

The README didn't say that Elements checks the installed React before
its first render, what it throws when the check fails, or that
UNLAYER_SKIP_REACT_CHECK=1 skips it: only ARCHITECTURE.md and the
changelog did. Installation now says which React versions it's tested
with, what happens with a newer one, the escape hatch, and why
react-dom stays a peer dependency.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The Tailwind guide said they load the compiler only for a template with
a config. A <Tailwind> without one compiles too (Tailwind's default
theme), so it needs tailwindcss installed as well.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A template's own component that throws a promise until its data is
there (React 18's Suspense, react-i18next's `useTranslation` before
its messages load) failed every renderer with `[Unlayer] [object
Promise] (in <ShippedEmail>)`: the call of a wrapper took only `use()`
for a suspension. The synchronous renderers now say what to do, as for
an async template (`async and suspending templates need
"@unlayer/react-elements/async"'s renderers, …`), and the async ones
wait for it and call the component again once it settles, as they do
for `use()`. The same goes for a class around the root and a class
component in the content, which failed the same way; a function
component in the content and in a block's text already suspended.

A component that throws a promise again once it has rejected fails
with what it rejected with, as `use()` of it does. It was waited for
again and again, and the render never ended.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
With two copies of Elements in one process (the module and CommonJS
builds loaded together, as tsx and @unlayer/migrate load them, or two
installs in a monorepo), a template of one copy's components rendered
by the other copy's renderers had its HTML made by its own copy's Body,
in that copy's render scope. With `onError`, a block whose exporter
failed was left out of the HTML but stayed in the design, `omitted`
lacked it, and what the render said (the parts left out, a Row's
padding) was said twice; the async renderers failed outright for an
async component, which that Body walked again without waiting.

The render functions now render a root of another copy (Body, Email,
Page, Document, by name, as another copy's blocks are made this copy's)
as their own copy's, so the copy whose renderer was called renders the
whole template in one scope, whichever copy its components are of. A
test loads both builds in one Node process and renders each one's
template with the other's renderers, on React 18 and 19.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The two fixes before this one add about 1.2KB to the main entry (189.5KB,
over the 189KB budget): a template, a component around its root and a
class component that throw a promise are waited for by the async
renderers and said by the synchronous ones, and a root of another copy
of Elements renders as this copy's.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A <Tailwind> found a Row's Columns by its own copy's Column type. With
the module and CommonJS builds loaded together, a template's <Tailwind>
of one build rendered by the other build's renderers rewrote none of
that render's Columns: a Row's padding classes stayed around the row,
in the design (and, now that the renderer that was called renders the
whole template, in the HTML too), where one build alone puts them in
its Columns, and its text classes didn't reach their blocks. The
rewrites find Columns by the marker every copy's Rows, Columns and
blocks carry. The test of both builds in one process renders a
<Tailwind> template too.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Elements knew its roots by their names: a component of the template's
own given one (a design-system wrapper, `const Email = (props) =>
<UEmail backgroundColor="#f4f4f4" {...props} />` with `Email.displayName
= "Email"`) was taken for the root. The HTML rendered it as this copy's
Email, and the design read its props as the root's, so what the wrapper
sets was in neither.

Elements' roots now carry a marker shared across copies
(`Symbol.for("@unlayer/react-elements/root")`, their name), and an older
copy's roots (0.2.1 and before, which carry none) are known by their
name and their code, as an older copy's Rows are: its Body reads the
`_config` it passes its rows, and its Email, Page and Document are that
Body in their mode. Anything else is called as any component is, in the
HTML and the design alike. Tests render such a wrapper for each root
name, and 0.1.22's roots with this release's renderers.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
With `onError: "omit"` (or a function), a component that suspended in a
synchronous render inside `<Suspense>` was left out by the boundary
around it (a component in a Column is in one) before the `<Suspense>`
saw it: `<Suspense fallback={…}><Row><Column><Price /></Column></Row>
</Suspense>` rendered neither the price nor the fallback, and said that
the component is async. By default the fallback rendered, as react-dom's
synchronous render does.

The walker counts the `<Suspense>`s open where it is, and a boundary
lets a suspension through to the nearest one, so the policy no longer
changes what a `<Suspense>` renders; one with no `<Suspense>` around it
is still a part that failed. Tests render an async component, a thrown
promise and (on React 19) `use()` in a `<Suspense>`, with each policy,
in the synchronous and the async renderers.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ivoIturrieta and others added 30 commits October 28, 2026 00:12
…nd two upgrade changes

mergeTagState was documented as merge tags, as if it filled them in: it
is the editor's merge tag state its Table exporter reads to repeat a
table's rows, and holds no values (Elements writes a tag as given, for
the email service). The README says so, with a Table repeat example, and
that a display condition is written around its row or block. The
changelog notes the display condition fix, that a heading in a
Paragraph's html now takes the paragraph's line height, and that a
<Suspense> inside a template still renders its fallback in the
synchronous renderers, as in 0.2.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 222.1 KB with display conditions written around
rows and blocks, an email service's tags kept whole in rich text and
background URLs, a tag that never ends left out of rich text, and a
Table's typed props named.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…d encode references that would end the URL

A background image URL's tag with quotes, a comparison or brackets
(`{{ hero | default: 'hero.png' }}`, `{% if tier > 1 %}`, AMPscript's
`%%=v(@hero)=%%`, `<%hero%>`) was percent-encoded, so the email
service failed to parse the whole email (0.2.1 wrote it as given). And
a character reference that reads as a quote or a bracket (`&#39;`,
`&rpar;`) got past the encoding, as the browser decodes it before the
CSS reads the style, so a URL from data could add declarations to the
row's style.

The URL is now written by cssUrlValue (utils/css-url.ts): a reference
that reads as a character that could end the attribute, the CSS string
or url(…) is that character encoded; outside a tag those characters are
encoded as before; a tag keeps all but `"`, `\` and control
characters, and where its quotes would take a bracket out of the CSS
string or leave it open, the whole URL is encoded instead.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A Heading's or Button's children with inline markup beside an email
service's tag with a double-quoted string (`{{ name | default: "friend" }}`,
`{% if tier == "gold" %}`) wrote the quotes as `&quot;` in the HTML and
the design, which the service can't read (0.2.1 kept them, as string
children still do). Rich text's text now keeps `"` as written, as it
does `'`; a tag's attributes keep theirs, and the quotes come back
before the <%name%> tags do, so no `>` of one is read as a tag's end.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…ag or its text has a quote

`<Link href="<%asm_group_unsubscribe_raw_url%>">Unsubscribe</Link>` on its
own in a Column became an Html block with the tag escaped
(`&lt;%…%&gt;`), so the unsubscribe link was dead: the block keeps a
component's markup as a Paragraph only where its text is all of it, and
the text writes the tag as written. The markup is compared as text writes
it now, its quotes and <%name%> tags as written, so such a Link (and one
whose text has a quote) is a Paragraph, its URL as written.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Markup a component writes as HTML in a Paragraph's, Heading's or
Button's children kept a comment as written, and one left open
(`<!-- ` from data) hid the rest of the email, the order total and the
unsubscribe link with it, until the next `-->`. Comments, and what a
browser reads as one (`<!…>`, `<?…>`), are left out now, one left open
with what follows it, as a browser reads it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…es as an Html block

A design's timer, form or custom block, which Elements has no component
for, became an <Html> block of its exported HTML without its display
condition or anchor, and without a word: a VIP-only countdown went to
everyone. Such a block now keeps the builder's settings no prop sets in
its values, as every other block does (a display condition is written
around it), and design-to-code says where its merge tag rule isn't
applied.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lain-text capitals

`<Heading>Welcome, {{first_name}}!</Heading>` was `WELCOME, {{FIRST_NAME}}!`
in the plain text (0.2.1 too), which the service doesn't fill in (and
`{% IF %}` failed Liquid's whole text part), though the README says a tag
is kept as written in the plain text. A heading's capitals leave every
common service's tags as written now: `<%name%>` as before, and
`{{ … }}`, `{{{ … }}}`, `{% … %}`, `*|…|*`, `%%…%%`, `%name%`,
`${…}` and `{$…}`.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…eir text

A Table whose `data` came from JSON, with a quantity or a price as a
number (`[name, qty, total]`), failed the whole render (`trim is not a
function`): TypeScript types `data` as strings, but JSON's numbers reach
it as numbers. 0.2.1 sent the email without the table. A cell or header
that isn't a string (a number, a year, `0`, true) is now its text, in
the HTML and the design; null is no text, as before.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…gs, and that headings' capitals keep them

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 223.8 KB with an email service's tags kept whole
in background URLs and their encoding checked, rich text's comments left
out and its double quotes as written, a lone Link's tags kept, a Table's
cells from data written as text, and plain text's heading tags kept.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… or >

Rich text keeps a few attributes of markup a component writes as HTML
(a link's href and title, a style, a class), and writes its text's
quotes back as written. An attribute value holding a raw `<` or `>`
beside a quote reference was read in two parts when the quotes came
back, so a reference inside it became a quote that ended it. A kept
attribute's `<` and `>` are written as references now (the same value
to a browser), so every tag is read whole and keeps its quotes.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
In an email, the exporters write a background image URL in Outlook's
markup too, which sits in a conditional comment: a tag kept as written
in the URL could hold `-->` and end it. Where the URL would end a
comment, it's encoded whole now, as where its quotes would take a
bracket out of its CSS string.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
… can hold

A Row's, a Column's and a block's `values._meta` id and class names
are kept to what an id and a class name can hold; the root's were
written as given (0.2.1 too), so one from data could add markup in
every mode. The root's go through safeMeta now, as theirs do.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…s children

`<Paragraph>Ordered on {order.createdAt}</Paragraph>` (a Date, or an
object like a price) rendered without it and without a word, in a
Paragraph's, Heading's or Button's text (0.2.1 too), though React's
render fails for it and the README says the render fails. The text's
children now fail the render as React's do, naming what they found; a
Set renders its items, as in React.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…ldren failing the render

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Markup a component writes as HTML in a Paragraph's, Heading's or
Button's children is kept to inline formatting, the other tags taken
out. A `<` or `>` in the text around them stayed as written, so what
was left beside a tag taken out could make a new one. The tags kept are
set apart while the markup is read, and every other `<` and `>` is
written as a reference, so only rich text's own tags and attributes
reach the email and the design. A seeded test builds markup of random
pieces and checks that.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…rence

A `style` declaration with a `"` (a font stack's `"Helvetica Neue"`,
`url("…")`) was left out of the HTML without a word, on every component
(0.2.1 kept a Row's). It's written now with its `"` as `&quot;`, which
the browser reads as one and which can't end the attribute.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lwind width class

`<Image src={{ url, width, height }} className="w-8">` rendered at the
image's natural width: the check for a width given read `src.width` as
one, and the class's width went into `src`, under the natural size. An
image's `src` gives no width of its own now, and a width class is its
display width, the flat prop, as for an image whose `src` is a URL.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The README's Table repeat example, `values={{ mergeTagGroup, mergeTagRule }}`,
didn't type-check: Table's values type didn't have the two keys. It has
them now, and the shapes design-to-code writes values from are
regenerated.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The editor's exporters write a Heading's level as its tag's name, so one
from data with a space or a quote in it (`level`, `as`, `headingType`,
`values.headingType`) was written into the markup as given (0.2.1
too). Such a level is an h1 now, and said; a design's own level stays.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The main entry is about 225 KB with rich text's stray brackets kept as
text, a root's ids kept to what they hold, object children failing the
render, style values' double quotes written as references, Tailwind
widths on images with a natural size, and a Heading's level checked.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…lain text

`toPlainText(html)` of `renderToHtml(…, { pretty: true })`'s HTML gave
links the wrong URLs: prettier writes a long line's end tag as
`</a\n  >`, which the plain text's steps didn't read as one, so a link
stayed open and the next link's text got its URL (block breaks and a
heading's capitals were lost too). Such end tags are read as `</a>`
first now, each once.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An image's `src` with its natural size gives no width of its own, so a
width class sizes it (the last fix); its `maxWidth`, though, is a
display width given, which a class doesn't override, as a flat
`maxWidth` isn't.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…codegen entries without it

An app importing `@unlayer/react-elements/async`, `/tailwind` or
`/codegen` failed to build with esbuild (wrangler's for Cloudflare
Workers, a serverless bundler's) unless prettier, an optional peer
dependency, was installed: "Could not resolve prettier/standalone".
Their dynamic imports had literal names, which webpack and Vite are told
to leave but esbuild follows. prettier is now loaded by a name that
isn't a literal, so no bundler follows it, and a test bundles every
entry with esbuild with prettier missing.
A `<Link>`'s, or an inline `<a>`'s, href with an email service's tag
with a quoted string (`{{ email | default: 'none' }}`) was written as
`&#x27;none&#x27;` in the HTML and the design, which the service's
Liquid rejects; a Button's href kept it. Rich text's tags are written
with double-quoted attributes, so their single quotes are as written
now, as the text's are; a `"` stays a reference, which can't end them.
…il writes

With its natural size given (`src={{ url, width, height }}`), an email
writes an Image twice: in Outlook's conditional comment and for every
other client. Its `style` and `className` went on the first only,
Outlook's, so every other client lost them (a `rounded-full` avatar
came out square, an `h-12` lost its height). They go on every <img> it
writes now.
…ot the value it left

A react-dom renderToString that failed or suspended inside a provider
leaves that provider's value in the context, till react-dom's static
renderer renders again. A template that read the context without
providing it read that value: another request's tenant, theme or
locale. In Node, Elements now has react-dom/server render nothing
before it reads a context's default, which puts the default back, and
keeps nothing from one render to the next (the default it learned
could be learned wrong for good).

react-dom/server is loaded only in Node, the first time it's needed,
through the package's fizz-cleanup export: its conditions give every
other runtime (edge, Workers, browsers, React Server Components) a
module that does nothing, and a fizz-cleanup/ folder gives it to
webpack 4 and Jest before 28, which don't read "exports". Inside an
app's stream or client render, and where nothing is loaded, the value
such a render left is read till react-dom's next static render: a
template's own provider, or a prop, is read wherever it renders.
…isn't put back

In Deno, an app's import of react-dom/server is its browser build and
Elements' require its Node one: another copy, whose render can't put
back what the app's left.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants