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 311 commits into
mainfrom
feat/react-elements-0.3
Open

ivoIturrieta wants to merge 311 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).
  • Found by eleven independent reviewers at 8581469 and the local CI replay, fixed with tests that fail without each fix: where Vite runs Elements inlined (ssr.noExternal, Vitest's server.deps.inline, a package linked into a monorepo), every render failed ("module is not defined"), as the context cleanup's Node module was CommonJS: it's an ES module too now; a Node server bundled by Rollup's node-resolve at its defaults got the cleanup's no-op, so the context leak above came back there: the no-op is now only for what names React Server Components, a browser, a Worker or an edge runtime; and a background image URL with Braze's ${…} attribute reference in a Liquid tag ({{${first_name} | default: 'friend'}}), or Responsys's on its own, was percent-encoded, which 0.2.1 kept as written.
  • Found by eleven more independent reviewers at 0f5b6cc, fixed with tests that fail without each fix: Markdown with thousands of link reference definitions took time growing with its size squared (134 KB: 2.7 s, blocking the event loop): it's linear now (0.04 s); and padding in a Row's style made an email wider than its layout (a 564 px row in a 500 px email, a 439 px page on a 375 px phone), as did padding in a Column's: a Row's padding goes on the row around its content box in an email, as in 0.2.1, and a Column's padding or border inside its cell.
  • Found by eleven more independent reviewers at 3488c41, fixed with tests that fail without each fix: a <Link> on its own in a Column kept a component's HTML in it as it was where cleaning took a tag out (an event handler or a script URL a Paragraph leaves out): it's cleaned as a Paragraph's now, and a linked image kept, cleaned too; and design-to-code filled a link's values into each other (values nesting placeholders made a design under 1 KB 10 MB of code): each is filled in once.
  • Found by eleven more independent reviewers at 644c2d3, fixed with tests that fail without each fix: on a shared Tailwind handle, a class from data whose arbitrary variant named another class ([&,.p-4]:hidden) changed every later render's use of that class (another tenant's block hidden): a class's rule is filed under that class only; an arbitrary property whose name isn't one ([a(:b())]) made every later synchronous render on the handle throw till the event loop yielded: it's left out before Tailwind builds it; and a memoized or wrapped <Link> alone in a Column skipped the cleaning above: it's cleaned too.
  • Found by eleven more independent reviewers at 787b84d, fixed with tests that fail without each fix: a <Link> a component returned inside a Fragment, an array, StrictMode or a provider skipped the cleaning above; on a shared Tailwind handle, a class from data registering an @property set another tenant's line heights; Markdown's sanitizer read a class's arbitrary value otherwise than Tailwind does (\75_rl(), so a heading inside <Tailwind> inlined a style it promises to leave out; and the body's default link rule (the editor's, blue and underlined) won over a template's own link CSS, which 0.2.1 kept: a root that sets no linkStyle gets no rule for its links now.
  • Found by eleven more independent reviewers at f17ca87, fixed with tests that fail without each fix: a root setting its link style with flat props (linkColor) lost its link rule in the round's own fix: it's read from the root's mapped values; a <Font> family name from data with character references in it could add a CSS declaration to the email's blocks: it's decoded before it's cleaned; and with links: "after", plain text listed a display condition's or a repeated table's links after the text (a conditional link in every recipient's text): they're written where they are.
  • Found by eleven more independent reviewers at 34b0018, fixed with tests that fail without each fix: a Heading level from data that was another tag's name was written as the heading's tag (one whose text a browser runs as code): only h1 to h6 are kept now; and a Button's text in the design became markup in the editor's export (its exporters unescape a Button's text where it has no textJson; 0.2.1's designs too): its < and > are numeric references in the design now.
  • Found by eleven more independent reviewers at a35ac5a, fixed with tests that fail without each fix: a " in an inline tag's attribute in a Button's design text could still end the attribute after the editor's exporters unescape it: it's a numeric reference too; and Vite's Worker build of an app using the async entry without tailwindcss installed threw as it loaded: the async entry loads the Tailwind entry by a name no bundler follows. The README now says the design holds values as given, as the editor does, which the editor's exporters write into its HTML as they are for some fields (preview text, a Menu item's text in an email, a Social icon's name, a link's URL with a quote).
  • Independent review: eleven reviewers at 32deaf1, each with its own method (three reading the code, two of them Codex and one Claude; eight 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, bundlers, runtimes and cost, cross-tenant isolation, and one with no brief), found no blockers: what they found was in 0.2.1 already or advice. The rounds before found what the bullets above list, each fixed with a test that fails without it.
  • 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 229,086 / 229,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; and the last two rounds about 1.5 KB: padding in a Row's and a Column's style kept inside the layout, a lone <Link>'s markup cleaned as a Paragraph's, also through a wrapper or a Fragment, no default link rule for a body that sets no linkStyle, and plain text's links kept in their display conditions and repeated tables; and a Button's design text's attribute quotes; so the budget goes from 203 KB to 229.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.75424% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.73%. Comparing base (eb0a80d) to head (32deaf1).

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.46% 6 Missing ⚠️
packages/react/src/markdown/Markdown.tsx 94.80% 4 Missing ⚠️
packages/react/src/tailwind/guard.ts 94.93% 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/walk.ts 96.15% 3 Missing ⚠️
packages/react/src/markdown/reach.ts 98.23% 3 Missing ⚠️
packages/react/src/codegen/links.ts 97.46% 2 Missing ⚠️
... and 7 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #69      +/-   ##
==========================================
+ Coverage   96.55%   97.73%   +1.17%     
==========================================
  Files          40      139      +99     
  Lines        2789    10476    +7687     
  Branches      795     3874    +3079     
==========================================
+ Hits         2693    10239    +7546     
- Misses         95      237     +142     
+ 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 12:32
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…onous renderers do

The synchronous `renderToHtml()` renders a template with no root (Rows,
a block) as it is, in a document, and `renderToJson()` says it needs a
root. The async renderers awaited such a template to find its root,
found none, and gave the synchronous render the template itself: it
called the template again (`async function Rows() { await load(); …
}` loaded its data twice), then threw that async templates need
"@unlayer/react-elements/async", the entry being used.

Unwrapping a template now also gives, where no root comes out, what it
rendered: the providers around it, and each wrapper made again as what
it returned (with its props and names, and its `useId()` moving the ids
after it), not called. The async renderers give that to the synchronous
render, so the HTML, the plain text and the parts are the synchronous
renderers' for the same template, the design's error is theirs, and the
template runs once a render. A test renders a template of Rows that
loads its data, inside a provider and a wrapper that calls useId(),
with each async renderer, against the synchronous ones.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The three fixes before this one add about 1.3KB to the main entry
(190.8KB, over the 190KB budget): Elements' roots are known by a marker
shared across copies (an older copy's by its name and code), a
suspension reaches the <Suspense> around it whatever `onError` says,
and an async template with no root is rendered from what it rendered,
not called again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A template with no root (Rows, a block or markup with no Email, Page,
Document or Body around them) was called again by the synchronous
renderers: unwrapping it found no root, and the render then rendered
the template itself, apart from the walk its head was made from. Its
wrappers ran twice, and a component in the template's `children` twice
more (once for the HTML, once for the head, which walked the children
as a root's content): `<Layout><Row><Column><Hero /></Column></Row>
</Layout>` called Layout and Hero twice in renderToHtml(),
renderToHtmlParts() and renderToPlainText(). That render was Elements'
own components calling what's in them as plain functions, so a
component of yours in a Column had no hooks (`useState` failed) and an
async one was dropped with a warning; every Row was `u_row_1` and a web
row, in an email too; and a component that suspended elsewhere failed
with "renderToHtml failed: [object Object]".

The synchronous renderers now take what the template rendered
(`Why.rendered`, as the async renderers did: its wrappers made again as
what they returned, not called again) and render it as a root's content
is, without the root's markup (utils/root-content.ts, Body's own
rendering of its rows, moved there): walked once, its Rows and blocks
rendered with the render's config and in its mode, numbered as its head
numbers them, the head and the plain text read from the same walk. A
component that suspends in it fails the render as it does in a root,
naming it and where it is, and `onError` leaves failed parts out.
renderToJson() still says it needs a root, without walking it.

Tests count each component's calls in each renderer for a template
with no root in a wrapper, and check its ids against its head's, its
rows' mode, its failed parts and its markup.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The async renderers awaited a template with no root (Rows, a block or
markup) down to what it rendered, then handed that to the synchronous
render without walking it first. An async component in it (in a
Column, among the Rows, in markup), a thrown promise or `use()` was
therefore met by a synchronous walk: the renderers rejected with
"renderToHtml failed: [object Object]" (since the previous fix, with
the synchronous renderers' "it is async or suspends, and this renderer
is synchronous" from inside the async entry), and a `<Tailwind config>`
around such a template was never compiled.

The async renderers now collect what a template with no root rendered
as they collect a root's content, waiting for its async components,
Suspense and `use()` with siblings at once, and keep it in the render's
scope, where the synchronous render after finds it: nothing is called
again, and its `<Tailwind config>`s are compiled. renderToJson() still
says it needs a root without waiting for what's in it.

A test renders such a template with an async block, an async Row, async
markup and a thrown promise with each async renderer, counting calls,
and checks the synchronous renderers' error, naming the first component
and where it is; another compiles a `<Tailwind config>` around Rows.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The two fixes before this one add about 1KB to the main entry (191.8KB,
over the 191KB budget): a template with no root renders as a root's
content, walked once, its Rows and blocks rendered with the render's
config (Body's rendering of its rows, shared), and the async renderers
collect it as they collect a root's.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Since a template with no root renders as a root's content, its content
was walked as Body's (and as the head's root, read the same way), so
what fails at its top, outside any component of its own, said
`(in <Body>)`, a root the template doesn't have: a module's object
rendered as a component (a story file's default export) failed with
"Element type is invalid: … (in <Body>)" where it said
`(in <template>)` before. The corpus check against the previous head
found it, on React 18.3, 19.1 and 19.3.

The content of a template with no root is now walked as "template" in
the synchronous renderers, their head and plain text, and the async
renderers. A test renders such an object with each renderer.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
loadTailwind kept every config it compiled, and each handle's compiler
every class it built: Tailwind's compiler keeps them all and builds the
stylesheet of all of them again for each new one. A server rendering
each customer's theme, or classes made from data (`w-[123px]`,
`text-[#a1b2c3]`), so grew without end, each new class slower than the
last (7 ms at 2,000 classes, 87 ms at 20,000, about 2.5 MB per 1,000).

loadTailwind now keeps the 50 configs used last (one used again goes
last; a handle given out keeps working). A handle that has read 2,000
classes compiles a fresh compiler in the background, once, and reads
new classes with it when it's ready (one that fails leaves the old one
in use): a class string reads the same whatever its compiler built
before, which a test checks against one that built thousands of others.
Tests check which configs stay compiled, that a handle compiles a fresh
compiler past its classes and reads the same after, and the swap.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A template wrapper (`<Welcome />`, called to reach its root) that set
its state on every render was called 26 times and its last output
rendered, without an error, where a component among the rows fails the
render with React's "Too many re-renders". A wrapper is now called as
the walker calls a component, and fails the same way: a RenderError
naming it, in every renderer, the async ones too. A test renders such
a template with each.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The synchronous renderers called a template's wrappers (`<Welcome />`,
called to reach its root) in the render's scope, before its root's
config set the render's `onError` and `idPrefix`. A wrapper that
rendered another template while it was called (`renderToHtml(…,
{ onError: "omit", idPrefix })`) so shared that scope: its options were
the outer render's (the first given wins), which then left out a part
of its own that fails instead of failing, and gave its ids the inner
render's prefix; the parts the inner render left out were listed in
the outer render's `omitted` too.

A wrapper is now called outside the render's scope, as the content's
components are, in every renderer and the async ones (where the first
wrapper ran in its caller's). Tests render such a template with each
renderer, inside each.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The peer range is React 18 and later, but the walker's version table
started at 18.3: React 18.0, 18.1 and 18.2 took its first row as a
version it doesn't know, and every process warned that React 18.2 "is
newer than the versions Elements is tested with (18.3–19.3)".

The react package's suite passes on React 18.0.0, 18.1.0 and 18.2.0 as
on 18.3 (the walker against react-dom/server, useId()s included, in
React's development and production builds), so the table has a row
for each of React 18's minors, and CI runs the suite on 18.0.0 and
18.2.0 too. A React older than the table's first row is now said to be
older. The tests that render on the client take act() from react-dom's
test utils where React (before 18.3) doesn't have it, and the Node exit
test reads React 18.0.0's version, which has its build after it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
…ne pass

The Markdown sanitizer took out scripts and the like first, then
rewrote the tags left, keeping any tag it didn't know. What was around
a part it took out then joined into a tag: `<scr<script></script>ipt>`
became a working `<script>` in the Html block and the design.

sanitizeHtml now reads the HTML once, as a browser's tokenizer reads
it, and writes back only what's on its lists: the tags of text, links,
images, lists, tables and sections, and the attributes of how they
display (`aria-*` too; no `on…`, `data-*`, `srcset` or `srcdoc`), each
URL checked and each style without `url()`, `image-set()`, a comment
or a CSS escape hiding one. Scripts, style sheets, frames, objects,
templates, form text, SVG and MathML go with what's in them; other
tags alone. A `<` that starts no tag is text, and a tag the HTML ends
in is left out, as a browser leaves it out, so the scan stays linear.

Tests: the reported input through <Markdown> and sanitizeHtml, cases
for each rule, and a seeded fuzz of hostile fragments whose output a
real parser (jsdom) reads back: only listed elements and attributes,
no URL that runs code, no style that loads anything, and the same
output sanitized again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The collector recorded a <Head> without walking what's in it, and its
tags were rendered later by the synchronous string renderer, outside
the render's walk. So `<Head><AsyncMeta /></Head>` rejected the async
renderers with a raw SyncSuspend instead of waiting for it, the
synchronous ones gave an error naming no place, a component in it that
threw failed the render even with `onError: "omit"` (and wasn't listed
in `omitted`), and none read the template's context.

What's in a <Head> is now walked where the template is collected,
inside its record, as the content is: the async renderers wait for it
(siblings at once, in tree order), the synchronous ones throw the
standard error naming the component and where it is (`Head › Meta`),
and one that fails fails the render as a RenderError or, with
`onError: "omit"`, is left out of the head and listed. Its tags are
the HTML it renders, the same bytes as before for what's synchronous.
<Font> has nothing in it to render: its tags come from its props.
Tests render async, context-reading and failing components in a <Head>
with each renderer.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
CodeBlock kept each code language it said it had no grammar for, for
the life of the process, and a Markdown fence's label is whoever
writes the Markdown's: a server rendering users' Markdown kept every
new one. It says twenty, each once, then once that no more are said,
and keeps no more.

Two other module-level sets were keyed by what a template gives: the
walker's warnings about keys in lists (a key can come from data) now
say a hundred, then once that no more are said (development builds
only), and its cache of style names as CSS writes them (a style's
keys can come from data) keeps a thousand, working out the rest each
time. Tests give each more than it keeps.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
renderToJson wrapped whatever a template wrapper threw (`<Welcome />`
failing as it's called to reach its root) in a plain Error with no
cause, saying "Pass the root element (<Email>…</Email>), or a function
component that returns one": misleading for a wrapper that simply
threw, and without the RenderError's `component` and `cause` that
renderToHtml and the async renderToJson give for the same template.

It throws that RenderError as it is now, and keeps the advice for an
async template, which it can't wait for. The test renders a throwing
wrapper with renderToJson and renderToHtml, and an async one.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The head's CSS, JS and tags were read from each block's head functions
(a custom tool's `renderer.head`) after the body rendered, outside the
error policy: a tool whose head threw failed renderToHtml and
renderToHtmlParts with its raw error, naming no tool or place, even
with `onError: "omit"`, while renderToJson (which runs no head) gave
the design.

A head that fails is now a part that fails: by default a RenderError
naming its block and where it is (`head_tool's head failed: …`, with
its `cause`); with `onError: "omit"` or a function, what it gives the
head is left out and listed in `omitted`, and its block still renders,
as in the design, which has no head to disagree with. The same for the
built-in blocks' and containers' heads. Tests render such a tool with
each policy, synchronously and async.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fixes before this one add about 0.7KB to the main entry (192,622
bytes, over the 192KB budget): what's in a <Head> is walked with the
template, a head function that fails is in the error policy,
renderToJson throws a template wrapper's error as it is, and what the
walker says once and its cache of style names are bounded.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
The fuzz ran 10,000 inputs by default, about 11.5 seconds in the full
suite on a fast machine, against a 15-second timeout: a slower CI
runner would time it out. It runs 2,000 by default now, with a timeout
that grows with the runs; SANITIZE_FUZZ_RUNS and SANITIZE_FUZZ_SEED
give longer runs (three seeds of 100,000 each pass).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A component's `style` and `className` go on the tag that renders it.
Where that tag had no style or class attribute, the attribute was added
with a replacement string, so `$&`, `$'` or `$$` in a value were read as
replacement patterns: `fontFamily: "a$'b"` on an Html block wrote the
rest of the tag into the attribute and broke the markup. It's added with
a function now, the value as it is.

Tests: an Html block with such a style and class name keeps its tag.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A block's padding and margin classes are both its containerPadding,
added up. They were added only when both were in pixels; otherwise the
margin took the padding's place without a word: `p-[5%] m-2` gave 8px,
and `p-[5%] m-0` gave 0px.

Lengths in one unit, or with a zero, add up now (`p-[5%] m-[2%]` is
7%), as a Row's padding and its Columns' do. Where they can't, the
padding, the block's own, stays whatever the classes' order, and the
margin is said to be ignored, once.

Tests: percent and pixel combinations, both orders, a margin that can be
added on some sides only, and the warnings.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
Logical classes mapped to fixed sides, as in a left-to-right document:
in `<Email textDirection="rtl">`, `ps-4` on a Column gave left padding,
in the HTML and the design, though inline start is the right there. The
same went for `pe-*`, `ms-*`/`me-*`, `border-s-*`/`border-e-*`, the
`rounded-s-*`/`rounded-e-*` and `rounded-ss-*` corners, `text-start` and
`text-end`, and classes in an Html block's markup.

The render scope now holds whether the root reads right to left, from
the root's resolved config (its `config`, `textDirection`, a Provider's,
the renderer's options), set once per render as `onError` is, so the
HTML, the head and the design, sync or async, read the same direction.
The main entry passes it to the Tailwind rewrites as an argument (an
older copy passes none: left to right). Classes map to the sides they
are in that direction. A Row's padding classes go to the Column on that
side, as Columns lay out from the right, physical ones (`pl-*`) too, and
stacked Columns keep the space between them. In markup, an element's own
`dir`, else the nearest one around it, else the root's, decides.
Left-to-right output is unchanged.

Tests: each family left to right and right to left, in the design and
the HTML; a Row's logical and physical padding and its Columns' margins;
markup with `dir` both ways; direction from the root's config, a
Provider and the renderer's options; concurrent async renders.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
CI's budget for the main entry is 193KB (`MAX_SIZE` in the test
workflow), and the entry is about 192.8KB, but CLAUDE.md still said
173.5KB, CONTRIBUTING.md 60KB, and the README about 12KB gzipped (under
60KB). They now say 193KB, and the README the entry's size (about 54KB
gzipped) and which parts are entries of their own; the workflow's note
has the current size.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
A render that a custom tool's exporter or head, or an `onError`
function, started shared the outer render's scope, where the first
`onError`, `idPrefix` and direction set won: an exporter that rendered
another template with `onError: "omit"` made a block failing after it
be left out of an email whose render should have failed, and each
render listed the parts the other left out.

Each render function now makes a scope of its own, and the user's code
(components, template wrappers, exporters and heads, `onError`
functions, Tailwind rewrites) runs outside the render's scope. The
async renderers run the synchronous renders in their own scope through
the main entry's internals.

Tests: a render started from each place the user's code runs leaves
the outer render's output, errors and left-out parts as they are
without it, and gives what it gives alone.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <noreply@anthropic.com>
ivoIturrieta and others added 30 commits October 28, 2026 09:18
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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.
…lines Elements

The ES module build imported Node's fizz-cleanup module as CommonJS,
which Vite can't run where it inlines a package instead of loading it
from node_modules (`ssr.noExternal`, Vitest's `server.deps.inline`, a
package linked into a monorepo): every render failed with "module is
not defined". Node's module is an ES module too now, which imports
react-dom/server as any module does (Node loads it with Elements
anyway, as the exporters import it), so a bundler bundles the app's
copy. Deno keeps the CommonJS one: its import of react-dom/server is
the browser build, which keeps it running.
…n background image URLs

A background image URL with Braze's attribute reference in a Liquid tag
(`{{${first_name} | default: 'friend'}}`), in a `{% if %}`, or on its
own (Responsys's `${hero!'default'}`) wasn't read as an email service's
tag, so its spaces and quotes were percent-encoded and the service
couldn't fill it in; 0.2.1 wrote it as given. A `${…}` is a tag now, on
its own and inside `{{ }}` and `{% %}`; a `"`, and quotes that would take
a bracket out of the CSS string, are still encoded as before.
…eanup module

The fizz-cleanup export gave its no-op module to every condition but
`node`, so a Node server bundled by a bundler that doesn't set it
(Rollup's node-resolve at its defaults) never had react-dom put back a
context value a failed renderToString left, and the next request's email
read it. The no-op is now only for what names React Server Components,
a browser, a Worker or an edge runtime (`react-server`, `browser`,
`worker`, `workerd`, `edge-light`); Node's module is the default. Every
build already imports react-dom/server (the exporters do), so this
loads nothing new.
… in their number

Each paragraph's inline pass asked whether the Markdown defines a
reference by enumerating its definitions (a `for…in` gathers every key
before its first), so thousands of definitions took time growing with
the Markdown's size squared: 134 KB took 2.7 s, blocking the event loop
in the async renderers too. The answer is kept once it's yes (the
definitions are only added to): 0.04 s, the same HTML.
…l's layout

In an email, a Row's `style` goes on its content box, which has the
content width, as its Columns do: padding there made the row wider than
the email (a 564 px row in a 500 px email, a 439 px page on a 375 px
phone), where 0.2.1, which put a Row's style on the row around it, kept
the layout. A Row's padding goes on that row again in an email (on a
page and in a document the box is as wide as its container). Padding or
a border in a Column's `style` goes inside its cell (border-box), as its
own padding does. The bundle budget goes to 226.5 KB.
…mn, as a Paragraph does

A <Link> alone in a Column is a Paragraph holding it, but where cleaning
its markup took a tag out, the markup was kept as it was, as an HTML
block: a component in it writing HTML (dangerouslySetInnerHTML) kept an
event handler or a script URL a Paragraph leaves out, in the HTML and
the design. It's a Paragraph of the cleaned markup now, and a link
holding an image (a logo's) an HTML block of what a link holds, cleaned
too. The bundle budget goes to 227 KB.
…eir own placeholders as written

designToElements filled each of a link's values in the URL filled in
so far, as the exporters do, so a value's own `{{…}}` was filled in
too: values nesting them made a design under 1 KB 10 MB of code, or ran
the process out of memory. A value is filled in once now; the URL's
placeholders and where they're encoded are the exporters' still.
…itor's is

In an email, the padding in a Row's style is the row's around its
content box, whose sides the editor's phone styles drop, as for the
Row's padding: Column padding insets the content there.
…ared compiler

A class whose arbitrary variant names another class in a selector list
(`[&,.p-4]:hidden`) had its rule filed under that other class, so every
later render on the handle that used `p-4` got it: another tenant's
block hidden, its text made transparent, a remote background added. A
rule of a class's own is filed under that class only. And an arbitrary
property whose name isn't a CSS identifier (`[a(:b())]`) is left out
before Tailwind builds it: its CSS couldn't be read, and every
synchronous render on the handle after it threw till the event loop
yielded.
… too

A lone <Link> in a Column is cleaned as a Paragraph's text (c3fadbb),
but one reached through `React.memo`, `forwardRef` or a component that
returns it (a design system's link) still kept a component's HTML in it
as it was, an event handler or a script URL included. Those are the
Link's markup now, cleaned the same way; a component's own markup is
kept as it is. The bundle budget goes to 227.5 KB.
…ts own

A component returning its lone <Link> inside a Fragment, an array,
StrictMode or a context's provider (which render no markup of their
own) kept a component's HTML in it as it was. The Link is found
through them now, and cleaned as a Paragraph's text; a host element
around it is still the component's own markup.
… rule

An `@property` registered inside a class's rule (an arbitrary variant
from data) set the initial value Tailwind's own variables fall back to
for every class on the compiler: another tenant's `text-sm` and
`text-lg` line heights. Only Tailwind's own registrations, at the top of
its stylesheet, are read.
…wn's sanitizer

Tailwind reads a `_` in a class's arbitrary value as a space, which ends
a CSS escape, so `\75_rl(` was `url(` to it but not to the sanitizer,
and a Markdown heading inside <Tailwind> inlined the style (its markup
wasn't marked sanitized, as a paragraph's is). Both are read as
Tailwind reads them now.
…no linkStyle

The exporters write the editor's default link style (blue, underlined:
a browser's own) for the body's id, which 0.3 writes for the id the
HTML has, so it won over a template's own link CSS (a <Head>
stylesheet's), which 0.2.1 kept. A root that sets no `linkStyle` gets
no rule for its links now; one that sets it, as before. The reviewed
snapshots lose the default rule; the page parity check gives each root
the default link style, as its snapshot had it. The bundle budget goes
to 228.5 KB.
…yle with flat props

f17ca87 left out the body's link rule where the template set no
`linkStyle`, but read only `linkStyle` and `values.linkStyle`, so
`<Email linkColor="#ff0000" linkUnderline={false}>`, which its mapping
folds into `linkStyle`, lost it. The template's mapped values say now.
…cter references decoded

A family name from data with character references (`&#39;&#59;`) or a
control character in it kept them past the check for what could end its
CSS, and a style attribute decodes them: a declaration of the name's
choosing landed on the email's blocks. The name, and its fallback, are
decoded first now, and an `&` or control character is left out too.
…here it is in plain text

With `links: "after"`, every link went in one list after the text, so a
link in a row's display condition was in every recipient's text, and a
table's repeat rule's links one placeholder after the loop. Those
regions are marked for the plain text (only where it lists links after
it), and a link in one is written where it is. The bundle budget goes
to 229 KB.
A level from data (`level`, `as`, `headingType`, `values.headingType`)
that was another tag's name was written as the heading's tag, which the
editor's exporters write as given: one whose text a browser runs as code
made the heading's escaped text code. Only h1 to h6 are kept now; another
is an h1, in the HTML and the design, and said.
… export of its design

The editor's exporters unescape a Button's text before they write it
where it has no textJson, as Elements' designs don't, so a `<` its text
held (`&lt;` in the design) was a tag in their HTML, while the editor's
canvas showed it as text; 0.2.1's designs too. Its `<` and `>` are
numeric references in the design now, which unescaping leaves: the same
text in the editor, its export and Elements' own HTML.
…ribute in the editor's export

a35ac5a wrote a Button's design text's `&lt;` and `&gt;` as numeric
references, but not those in its inline tags: an attribute's `&quot;`
(a span's title, a quoted font family) was a `"` after the exporters'
unescape, which ended the value (an attribute added, a font lost).
`&quot;`, `&lt;` and `&gt;` in its tags are numeric references too. The
bundle budget goes to 229.5 KB.
… the async entry

The async entry imported `@unlayer/react-elements/tailwind` by a literal
name, which Vite's Worker build (`ssr.target: "webworker"`, everything
bundled) followed into `tailwindcss`, an optional peer dependency: where
it isn't installed, the module threw as it loaded, before any render.
It's imported by a name that isn't a literal now, as prettier is.
The editor's exporters write some of a design's values into its HTML
as they are (preview text, a Menu item's text in an email, a Social
icon's name, a link's URL with a quote), for a design made in the
editor too: values from data in a design the editor exports need
escaping first, or rendering with Elements.

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