Repository navigation
@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
Open
ivoIturrieta wants to merge 311 commits into
ivoIturrieta wants to merge 311 commits into
Conversation
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
ivoIturrieta
marked this pull request as ready for review
October 15, 2026 16:58
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>
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
`'none'` 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 (`';`) 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 (`<` 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 `<` and `>` as numeric references, but not those in its inline tags: an attribute's `"` (a span's title, a quoted font family) was a `"` after the exporters' unescape, which ended the value (an attribute added, a font lost). `"`, `<` and `>` 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The next
@unlayer/react-elementsrelease (0.3.0): a new render engine, formats developers already use, a design → code converter, and fixes so the HTML and the design that opens in the Unlayer editor always agree. No API is removed. One deliberate behavior change: a part that fails to render now fails the render (see below).A new render engine
0.2 rendered templates with react-dom: one render collected the template through markers written into its HTML, then about 15 more rendered the rows. In Next.js App Router server code (two React copies) that broke: components using hooks were dropped and async emails came out with no rows, with only a warning.
0.3 walks the template itself and answers its hooks (
useContext,useId,useMemo,use, …) as React's server renderer does. Elements doesn't render with react-dom: its one call, in Node, renders nothing, so that react-dom puts back a context value a failedrenderToStringleft (react-dom stays a peer dependency because@unlayer/exportersimports it).<select value>,<textarea>, inputs),<style>/<script>/<title>text, attribute order, void elements. A differential test checks every element and attribute kind React treats specially (about 115,000 cases) and random trees, on React 18.0, 18.3 and 19.3. 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 ajavascript: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.renderToStringthat 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'srenderToStringit 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-cleanupexport, whose conditions give edge runtimes, Workers, browsers and React Server Components a module that does nothing (afizz-cleanup/folder does the same for webpack 4 and Jest before 28, which don't readexports). Tests stream a page for one tenant, fail another'srenderToStringinside 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'simportof it), a template reads what a failedrenderToStringleft, till react-dom's next one. A template's own provider, or a prop, is read wherever it renders.[Unlayer] boom (in <Hero> at Row 3 › Column 1 › Hero), aRenderErrorwith the original ascause. A failure throws by default, in the sync and async renderers alike;onError: "omit"(or a function) leaves the failed part out of the HTML, the text and the design alike and lists it inrenderToHtmlParts().omitted. That covers every part: components, Rows and Columns, exporters, template wrappers, a custom tool's head, and what's in a<Head>. An async or suspending template given to a synchronous renderer is aRenderErrornaming it too.UNLAYER_SKIP_REACT_CHECK=1to bypass). CI runs the suite on React 18.0, 18.2, 18.3, 19.1, 19.2 and 19.3 in dev and prod (and the release job publishes only after that matrix passes), comparesuseIdwith react-dom's own, and runs a weekly canary.useId()gives React's own ids (no internal prefix);idPrefixsets a prefix for emails embedded in a page.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.mdexplains the pipeline, the dispatcher, the error policy and the invariants.New entries (separate imports, so the main bundle stays small)
/tailwind<Tailwind>: classes become each component's props at render time, so the email gets inline styles and the design stays editable. Phone variants become phone settings,hover:becomes button hover colors, container text classes reach the text inside, card grids get gutters,w-*,max-w-*andmin-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 wheresrchas 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 asundefined(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 aurl()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 aRenderErrorsaying 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 inleading-[…],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 withonError: "omit"), whatever CSS another utility gives the class (a static.boom-nowbeside the plugin'sboom) 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@applyof a class or a variant Tailwind doesn't have, or of the class itself; a list holdingnull; a variant that isn't a selector), fails its component the same way, never reaching the compiler; an@applyin 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)isvar(--x)), and one that can't be tied to a class fails only the render that built it, so a user'sbrand-(--x)in Markdown can't fail the app's ownbrand-blue; a function that givesfalsegives 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 asnulldoesn't count, as one given asundefineddoesn't (a content prop given asnull,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 samehtml,url,linkAttributesandstyles. 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*atook 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./codeCodeBlock/CodeInlinewith 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)./asyncReact.lazytoo), Suspense, andprettyoutput. A<Suspense>around the root is waited for: its fallback can't make a document, so it never becomes the email./codegendesignToElements(design): an Unlayer design becomes readable Elements code. The round trip is exact on 48 of 49 real designs and on fuzzed ones. 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 (arel, atitle, code to run) is written as the design has it, with a warning, andcompareDesigns()compares it with them. A Menu item without a link comes back without one.Main entry
Link.style,classNameandskipInTexton every component.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).color,align/textAlign, Headingas/level. Close misspellings get "did you mean" warnings.valuesaccepts partial objects with strict keys (ValuesInput).javascript:URL in them is left out however it's encoded (javascript:,javascript:), 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).targetis 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'sconfig, then the Provider, then defaults. Safe in Server Components and on the edge.textis stored as typed, so the editor no longer formats markup the email shows literally.0pxin designs.fullWidthdefault agrees between HTML and design.url()values are percent-encoded.dir/langon the body.onError: "omit"), instead of blanking the email.cause.<p>wrapper), so lines with mixed text sizes no longer shift; a root'sfontsreach the design; the document has the editor's base stylesheet for each mode; images and video thumbnails are sized in the block's slot (the default 10px padding), not the column; a fixed-width image's percent is the same in HTML and design; and the design recordsdisplayMode, so code generated from it picks the right root. A per-block test checks every block's HTML equals the editor's export of its design, in all three modes.Row padding keeps the editor's meaning (outer padding, in email, web and print). Elements now warns when it sits outside a content background; use Column padding to inset. Tailwind
p-*on a Row insets, as CSS does.Validation
test:csp,test:browser-runtime,test:e2e,test:fidelityandtest:differentialpass, 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 at11ec4e2it 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 itstailwindcssmock 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./tailwindrow); 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 escapedstyleor 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-textskipof(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/tailwindrow), 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'svideoIdfrom data (abc" onload="…) ended its player'ssrcon a page or in a document and added an event handler (0.2.1 too), anddesignToElements()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 thenx, 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 ofaover-took 14 s,[and 25,000<!--7 s, 200 KB of list item lines 6 s); a design's lengths andmailto:fields indesignToElements(), a Lexical paragraph's style, a component'sstyleandpretty'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 asvar(--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 writescolor: ;over the default and the class, and a content prop given asnull(<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'sjavascript:link is one that throws (its thumbnail, as an Image'ssrc, is written as given); a<Font>with ajavascript:URL loads nothing (a root'sfontsprop 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, anddesignToElements()(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@applyTailwind can't compile, no longer breaks the handle every render shares (the first render failed at<Body>, a raw TypeError fromrenderToJson, 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'sp-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); encodedjavascript:URLs in inline JSX's markup are left out; ajavascript: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'shref, an Image'saction, 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 (javascript:,javascript:,java	script:; any other URL kept byte for byte) (0.2.1 wrote those as given; Paragraph'shtml,<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 whoseurl()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; acid:or base64 imageurl()is kept; a link ofnull(<Button href={null}>, which failed in 0.2.1 too) is none, and anullMenu 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.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'friend'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 typedheaderColor,contentColorand fonts no longer warn that they're ignored. The README now says whatmergeTagStateis (the editor's state for a Table's repeat rule, not merge tag values).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.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'svalues._metaid 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).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); astylevalue with a"(a font stack,url("…")) is kept, written as", where it was left out (0.2.1 kept a Row's); a Tailwind width class sizes an image whosesrcgives its natural size (w-8rendered 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.ee41b51, fixed with a test that fails without it:toPlainText()ofprettyHTML 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'ssrc.maxWidthwins over a Tailwind width class again, as a flatmaxWidthdoes.b35cc79, fixed with tests that fail without each fix: a context the template doesn't provide read another request's value after the app'srenderToStringfailed inside its provider, and a stream and a failedrenderToStringcould 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'none', which Liquid rejects; an Image with its natural size, which an email writes twice (Outlook's copy and everyone else's), got itsstyleandclassNameon Outlook's copy only (arounded-fullavatar came out square elsewhere); and esbuild couldn't bundle the async, Tailwind or codegen entry without prettier (below).8581469and the local CI replay, fixed with tests that fail without each fix: where Vite runs Elements inlined (ssr.noExternal, Vitest'sserver.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.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'sstylemade 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.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.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.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@propertyset 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 nolinkStylegets no rule for its links now.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 withlinks: "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.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 notextJson; 0.2.1's designs too): its<and>are numeric references in the design now.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 withouttailwindcssinstalled 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).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.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 optionalimport()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 onb35cc79.test:visualmatches on 138 stories × 2 viewports. The baseline was regenerated after attributing every changed render to intended changes (exact image widths, the Provider fix, the text block's markup, images sized in their slot).renderToHtml0.97–0.99×,renderToPlainTextabout 0.96×,renderToJson1.3–1.45× (0.11 ms for a 12-row email); against 0.2's engine, HTML takes about half the time and JSON about a third.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 ofnull; 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'sjavascript:URL loading nothing, Tailwind classes in Heading and Button children and on a lone<Link>,nullprops, 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 asnullrendering 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'sstylekept 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 nolinkStyle, 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).LICENSEagain (0.2.1's did; pnpm skips the repository's copy when another file matches LICENSE, asTHIRD_PARTY_LICENSES.mddoes), and CI's pack steps (Elements' and migrate's) and the publish jobs now fail without one. Each entry'simportandrequireget their own types (.d.ts,.d.cts), andtypesVersionsgivesmoduleResolution: "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 withoutskipLibCheck; CI's check now finds an import of it or of marked in either quotes or as animport()type), andregisterLanguage()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 withskipLibCheck: falseon TypeScript 5.9 and 7.0 (bundler, node16 ESM and CommonJS, nodenext), and withmoduleResolution: "node"withskipLibCheckon (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 buildon 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-integrationpins 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.tailwindcssandprettierstay optional peer dependencies (pnpm's isolatednode_modulesand Yarn PnP resolve them through that declaration), with the range*. The ranges this PR had before (>=4.0.0,>=3.0.0) madenpm installfail 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) andprettynow say to upgrade it (npm install tailwindcss@4,npm install prettier@3), instead ofTypeError: run is not a functionand "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.Release notes
onError: "omit"keeps omission, listed); the upgrade guide in the guides PR covers it. The version is committed inpackages/react/package.json, which the release job publishes instead of its default patch. Without it, this would ship as 0.2.2 to every^0.2project.@unlayer/migrate's Elements range goes to^0.3.0here. The release job refuses an Elements outside the published migrate's range, so migrate then republishes after it (0.1.3, the same converter) and asks projects on 0.2.x to upgrade first.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.@unlayer/react-elements-previousalias to 0.3.0.📖 Storybook Preview: https://unlayer.github.io/elements/pr/69/